facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
228.71k stars 46.81k forks source link

Feature request: middleware #12085

Closed adrianhelvik closed 6 years ago

adrianhelvik commented 6 years ago

This is an incomplete draft for a feature I think could be really cool. It can replace higher order components and context in a way I think is more in the component spirit of React.

I do not know if this feature is feasible or desirable for React, especially as it would lead to a bigger API surface. The proposal is written as if it was documentation to give a feel for how it would be to use it.

About React middleware

A middleware is applied somewhere in the component tree and are instantiated just after child components are instantiated and just before they mount. In this context, child components means child components at any depth.

Middleware is used just like normal components, but it works slightly differently. When a middleware element is used it added to the middleware stack. If it is already on the middleware stack, it removed from the stack and pushed to the end, with the most innermost props.

Simplified example

In addition to the actual classes, the stack also includes its most recent props. But this is roughly how it works.

<MiddlewareA>
  {/* middleware stack for "A": [MiddlewareA] */}
  <A />
  <MiddlewareB>
    {/* middleware stack for "B": [MiddlewareA, MiddlewareB] */}
    <B>
      <MiddlewareA
        {/* middleware stack for "C": [MiddlewareB, MiddlewareA] */}
        <C />
      </MiddlewareA>
    </B>
  </MiddlewareB>
</MiddlewareB>

Lifecycle methods

Additions to the existing lifecycle methods

Mounting

Unmounting

static shouldMiddlewareMount(ReactComponent)

Determine if the current middleware should apply for a component. If the method isn't implemented, the middleware will always be applied.

If a middleware is on the middleware stack, this method is called every time a component is constructed.

Example

class TransformInlineStyles extends React.Middleware {

  /**
   * Only mount middleware when you set
   * transformInlineStyles to a truthy
   * value. Children of the given
   * component can still enable
   * the middleware
   */
  static shouldMiddlewareMount(Component) {
    return Component.transformInlineStyles
  }

  // ...
}

const A = props => (
  // ...
)

const B = props => (
  // ...
)
B.transformInlineStyles = true

const App = () => (
  <TransformInlineStyles>
    {/* Not applied to "A" */}
    <A>
      {/* Applied to "B" */}
      <B />
    </A>
  </TransformInlineStyles>
)

static shouldMiddlewarePropagate(ReactComponent)

Determine whether the middleware should remain on the middleware stack or be excluded for the subtree below the given component. If not specified it returns false, in other words: The default behavior for middleware is to propagate.

This is useful if you want to limit middleware from affecting deeply nested children. It is also useful for only giving middleware access to its immediate children.

Example

import React from 'react'

class ProvideTheme extends React.Middleware {
  static StopPropagation = props => props.children

  static shouldMiddlewarePropagate(Component) {
    return Component !== this.StopPropagation
  }

  // ...
}

const App = () => (
  <ProvideTheme>
    {/* middleware stack for "A": [ProvideTheme] */}
    <A>
      <ProvideTheme.StopPropagation>
        {/* middleware stack for "B": [] */}
        <B />
      </ProvideTheme.StopPropagation>
      {/* middleware stack for "C": [ProvideTheme] */}
      <C />
    </A>
  </ProvideTheme>
)

middlewareWillMount(reactInstance)

Called before the child component calls componentWillMount. This is a good place to initialize state for the middleware instance.

MiddlewareWillUnmount(reactInstance)

Called before the child component calls componentWillUnmount.

Example

This is a naïve example of how it could be used to trigger automatic updates with Mobx.

class Observer extends React.Middleware {
  middlewareWillMount(reactInstance) {
    this.dispose = autorun(() => {

      // Let Mobx track the observables
      // used in the render method.
      reactInstance.render()

      // Force update the component instance
      // after Mobx has stopped tracking the
      // autorun function.
      //
      // .. yes, I know it's hacky.
      setTimeout(() => {
        reactInstance.forceUpdate()
      })
    })
  }

  middlewareWillUnmount(reactInstance) {
    // Stop listening for changes from Mobx
    this.dispose()
  }
}

interceptRender(children)

interceptRender is called with the result from the render function of the component. The resulting value is what is used to render the DOM.

Example

This is an example of a middleware that transforms object classes into a string. The result works similarly to how ng-class works in AngularJS.

class ObjectClassNames extends React.Middleware {

  /**
   * This is a life cycle method.
   *
   * Intercept the render method and recursively
   * loop through all children, performing
   * this.transformProps() on their props.
   */
  interceptRender(children) {
    return React.Children.map(children, child => {
      if (! React.isValidElement(child)) {
        return child
      }
      return {
        ...child,
        props: this.transformProps(child.props),
        children: this.interceptRender(child.children)
      }
    })
  }

  /**
   * If a child has an object className, call
   * this.transformClassname() on it.
   */
  transformProps(props) {
    if (! props || ! props.className || typeof props.className !== 'object') {
      return props
    }

    return {
      ...props,
      className: this.transformClassname(props.className)
    }
  }

  /**
   * Concatenate the truthy keys of the className
   * object into a string.
   */
  transformClassname(className) {
    const result = []

    for (const key of Object.keys(className)) {
      if (className[key]) {
        result.push(key)
      }
    }

    return result.join(' ')
  }
}

const Widget = (props) => (
  <div className={{ 'Widget': true, 'Widget--active': props.active }}>
    Some widget
  </div>
)

const App = () => (
  <ObjectClassNames>
    <Widget active={true} />
  </ObjectClassNames>
)

Why middleware?

React middleware can replace two problematic patterns used with React.

Context

The h2 on context in the React docs says "Why Not To Use Context". Context is however a very useful feature. And people have been and will continue to use and abuse it in the forseeable future. React Router has started abusing context in its most recent version, which shows that there is clearly a need here.

With middleware, as I propose it, you would be able to inject props into an arbitrary subtree of your app. This has performance implications, but would be an ideal scenario for libraries such as React Router, as the relevant props (or as it is now, context) rarely changes. With middleware shouldComponentUpdate will still function like you would expect.

Higher order components

A primal rule of programming is DRY. When using Mobx with React, you must use the observer decorator on all reactive classes. This isn't a really big deal, but not having to include that would reduce the size of every single observer component by two lines and most importantly, I wouldn't forget it.

When creating a higher order component static properties are no longer available. The package hoist-non-react-static is designed so that you should be able to access static properties of higher order components transparently. If a static property is initialized in the lifecycle methods of a component, it will however not be proxied.

Creating higher order components is also a messy affair.

With middleware you could achive the same thing in a React way. To replace connect from react-redux you could set shouldMiddlewarePropagate to return false, and it would affect only one component.

Alternatively you could use static properties for mapStateToProps and mapDispatchToProps.

gaearon commented 6 years ago

Note that we're actively working on fixing context so that we can embrace it as a supported API. :-) https://github.com/reactjs/rfcs/pull/2 https://github.com/facebook/react/pull/11818

gaearon commented 6 years ago

I appreciate the detailed writeup but overall I think this proposal is counter to how we think about React, and I don’t really see this happening.

We are fixing context so at least that part of the motivation is solved.

While using systems like MobX that wrap all the data structures is possible in React, we don't necessarily want to encourage this style of programming, and I don't think making it even more automatic is something we'd want to do.

I think the biggest drawback of this proposal is that it's both implicit and very powerful. We try not to combine those. The only implicit API we have is context, and even that only affects data and not behavior. It's also opt-in and passes the grep test. The middleware API can't pass it because it affects all components indirectly. This essentially changes the contract between components, saying "here are your props, unless there's middleware on the stack, in which case who knows what you'll really get as props". That defies any potential optimizations React could make, including compilation techniques we're currently exploring.

adrianhelvik commented 6 years ago

Totally understand that!