Skip to main content

Starting from componentWillReceiveProps

Free2018-08-26#JS#React Controlled Component#React Anti-Pattern#When to use getDerivedStateFromProps#React受控组件#React反模式

Reunderstanding controlled and uncontrolled components

1. Misunderstanding of componentWillReceiveProps

componentWillReceiveProps is usually considered as propsWillChange, and we do use it to judge props change. But in reality, componentWillReceiveProps is called every time during rerender, regardless of whether props changed:

class A extends React.Component {
  render() {
    return <div>Hello {this.props.name}</div>;
  }

  componentWillReceiveProps(nextProps) {
    console.log('Running A.componentWillReceiveProps()');
  }
}

class B extends React.Component {
  constructor() {
    super();
    this.state = { counter: 0 };
  }

  render() {
    return <A name="World" />
  }

  componentDidMount() {
    setInterval(() => {
      this.setState({
        counter: this.state.counter + 1
      });
    }, 1000)
  }
}

ReactDOM.render(<B/>, document.getElementById('container'));

In the example above, the state change of parent component B triggers the child component A's render and componentWillReceiveProps to be called, but A didn't experience props change.

That's right, as long as new props are received, componentWillReceiveProps will be called, even if the new props are exactly the same as the old ones:

UNSAFE_componentWillReceiveProps() is invoked before a mounted component receives new props.

Note that if a parent component causes your component to re-render, this method will be called even if props have not changed.

The relevant implementation is as follows:

updateComponent: function () {
  var willReceive = false;
  var nextContext;

  if (this._context !== nextUnmaskedContext) {
    nextContext = this._processContext(nextUnmaskedContext);
    willReceive = true;
  }

  // Not a simple state update but a props update
  if (prevParentElement !== nextParentElement) {
    willReceive = true;
  }

  if (willReceive && inst.componentWillReceiveProps) {
    inst.componentWillReceiveProps(nextProps, nextContext);
  }
}

(Excerpt from the collector's edition ReactDOM v15.6.1)

That is to say, the timing for componentWillReceiveProps to be called is:

Trigger current component update && (context changes || parent component render result changes, i.e., current component needs rerender)

Note that there is no diff on props here:

React doesn't make an attempt to diff props for user-defined components so it doesn't know whether you've changed them.

Because props values have no constraints and are difficult to diff:

Oftentimes a prop is a complex object or function that's hard or impossible to diff, so we call it always (and rerender always) when a parent component rerenders.

The only guarantee is that props change will definitely trigger componentWillReceiveProps, but the reverse is not true:

The only guarantee is that it will be called if props change.

P.S. For more related discussions, see Documentation for componentWillReceiveProps() is confusing

2. How to Understand getDerivedStateFromProps

getDerivedStateFromProps is used to replace componentWillReceiveProps, dealing with scenarios where state needs to be associated with props changes:

getDerivedStateFromProps exists for only one purpose. It enables a component to update its internal state as the result of changes in props.

That is, it allows props changes to trigger state changes (called derived state, i.e., derived state), although most of the time there's no need to stuff props values into state, but in some scenarios it's unavoidable, such as:

The characteristic of these scenarios is that they are related to props changes and require comparing/calculating new and old props,

Similar to componentWillReceiveProps, getDerivedStateFromProps is not only triggered when props change. Specifically, its trigger timing is:

With React 16.4.0 the expected behavior is for getDerivedStateFromProps to fire in all cases before shouldComponentUpdate.

It's called before shouldComponentUpdate in the update flow. That is to say, as long as you enter the update flow (regardless of whether the update reason is props change or state change), getDerivedStateFromProps will be triggered.

In terms of specific implementation, similar to calculating nextContext (nextContext = this._processContext(nextUnmaskedContext)), before determining whether an update is needed (shouldComponentUpdate), nextState must be calculated first:

export function applyDerivedStateFromProps(
  workInProgress: Fiber,
  ctor: any,
  getDerivedStateFromProps: (props: any, state: any) => any,
  nextProps: any,
) {
  const prevState = workInProgress.memoizedState;

  const partialState = getDerivedStateFromProps(nextProps, prevState);

  // Merge the partial state and the previous state.
  const memoizedState =
    partialState === null || partialState === undefined
      ? prevState
      : Object.assign({}, prevState, partialState);
  workInProgress.memoizedState = memoizedState;

  // Once the update queue is empty, persist the derived state onto the
  // base state.
  const updateQueue = workInProgress.updateQueue;
  if (updateQueue !== null && workInProgress.expirationTime === NoWork) {
    updateQueue.baseState = memoizedState;
  }
}

(Excerpt from react/packages/react-reconciler/src/ReactFiberClassComponent.js)

getDerivedStateFromProps becomes a necessary step in calculating nextState:

getDerivedStateFromProps is invoked right before calling the render method, both on the initial mount and on subsequent updates.

function mountIndeterminateComponent(
  current,
  workInProgress,
  Component,
  renderExpirationTime,
) {
  workInProgress.tag = ClassComponent;
  workInProgress.memoizedState =
    value.state !== null && value.state !== undefined ? value.state : null;

  const getDerivedStateFromProps = Component.getDerivedStateFromProps;
  if (typeof getDerivedStateFromProps === 'function') {
    applyDerivedStateFromProps(
      workInProgress,
      Component,
      getDerivedStateFromProps,
      props,
    );
  }

  adoptClassInstance(workInProgress, value);
  mountClassInstance(workInProgress, Component, props, renderExpirationTime);
  // Call render, first phase ends
  return finishClassComponent(
    current,
    workInProgress,
    Component,
    true,
    hasContext,
    renderExpirationTime,
  );
}

(Excerpt from react/packages/react-reconciler/src/ReactFiberBeginWork.js)

So it will also be called during initial rendering, which is the biggest difference compared to componentWillReceiveProps.

3. Derived State Practice Principles

There are two ways to implement derived state:

  • getDerivedStateFromProps: Derive part of state from props, and its return value will be merged into the current state

  • componentWillReceiveProps: Call setState in this lifecycle function

In practical applications, problems easily occur in two common scenarios (called anti-patterns):

  • Unconditionally updating state when props change

  • Updating cached props in state

Unconditionally updating state in componentWillReceiveProps will cause state manually updated via setState() to be overwritten, resulting in unexpected state loss:

When the source prop changes, the loading state should always be overridden. Conversely, the state is overridden only when the prop changes and is otherwise managed by the component.

For example (using componentWillReceiveProps as an example only, getDerivedStateFromProps is the same):

class EmailInput extends Component {
  state = { email: this.props.email };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  componentWillReceiveProps(nextProps) {
    // This will erase any local state updates!
    // Do not do this.
    this.setState({ email: nextProps.email });
  }
}

In the example above, when the user types a string into the input control (equivalent to manually updating state), if at this time the parent component updates and triggers this component's rerender, the user's input will be overwritten by nextProps.email (see online Demo), resulting in state loss.

To address this problem, we generally solve it like this:

class EmailInput extends Component {
  state = {
    email: this.props.email
  };

  componentWillReceiveProps(nextProps) {
    // Any time props.email changes, update state.
    if (nextProps.email !== this.props.email) {
      this.setState({
        email: nextProps.email
      });
    }
  }
}

Precisely limit props change to email, no longer unconditionally reset state. Seems perfect, really?

Actually, there's still an awkward problem: sometimes state needs to be reset from the outside (such as resetting password input), and after limiting the state reset condition, props.email updates from the parent component are no longer unconditionally passed to the input control. So, previously you could use triggering EmailInput component's rerender to reset the input content to props.email, but now it doesn't work.

So, we need to find a way to reset the input content back to props.email from the outside. There are many ways:

  • EmailInput provides a resetValue() method, called externally through ref

  • The outside changes EmailInput's key, forcing a new EmailInput to be created, thereby achieving the goal of resetting to the initial state

  • If key is too destructive (delete and recreate, plus component initialization cost), or inconvenient (key already has other purposes), add a props.myKey combined with componentWillReceiveProps to achieve partial state reset

Among them, the first method only applies to class-form components, while the latter two don't have this limitation and can be flexibly chosen according to specific scenarios. The third method is slightly convoluted; for specific operations, see Alternative 1: Reset uncontrolled component with an ID prop

The reason why similar scenarios easily cause problems lies in the root cause:

when a derived state value is also updated by setState calls, there isn't a single source of truth for the data.

On one hand calculating state from props, on the other hand manually calling setState to update. At this point, the state has two sources, violating the single source of truth principle for component data.

The key to solving this problem is to ensure a single data source and avoid unnecessary copying:

For any piece of data, you need to pick a single component that owns it as the source of truth, and avoid duplicating it in other components.

So there are two solutions (just eliminate one data source):

  • Completely remove state, so there's no conflict between state and props

  • Ignore props change, only keep the first passed props as the default value

Both methods ensure a single data source (the former is props, the latter is state). Such components can also be called fully controlled components and fully uncontrolled components.

4. "Controlled" and "Uncontrolled"

Components are divided into controlled and uncontrolled components. Similarly, data can also be understood this way.

Controlled and Uncontrolled Components

This is a concept proposed for form input controls (<input>, <textarea>, <select>, etc.). The semantic difference lies in that controlled components' form data is handled by React components (controlled by React components), while uncontrolled components' form data is handled by DOM mechanisms (not controlled by React components).

Controlled components maintain their own state and update this state based on user input:

An input form element whose value is controlled by React is called a controlled component. When a user enters data into a controlled component a change event handler is triggered and your code decides whether the input is valid (by re-rendering with the updated value). If you do not re-render then the form element will remain unchanged.

When users interact with controlled components, whether user input is fed back to the UI depends on the handler function corresponding to the change event (whether to change the internal state, fed back to the UI through rerender). User input is controlled by React components, for example:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
  }

  handleChange(event) {
    // Decide here whether to feed input back to UI
    this.setState({value: event.target.value});
  }

  render() {
    return (
      <input type="text" value={this.state.value} onChange={this.handleChange} />
    );
  }
}

Uncontrolled components don't maintain such state; user input is not controlled by React components:

An uncontrolled component works like form elements do outside of React. When a user inputs data into a form field (an input box, dropdown, etc) the updated information is reflected without React needing to do anything. However, this also means that you can't force the field to have a certain value.

User interaction with uncontrolled components is not controlled by React components; input is immediately fed back to the UI. For example:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.input = React.createRef();
  }

  handleSubmit(event) {
    // Input from input is directly fed back to UI, only read from DOM when needed
    alert('A name was submitted: ' + this.input.current.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" ref={this.input} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

Understanding Controlled and Uncontrolled from a Data Perspective

Uncontrolled components treat the DOM as the data source:

An uncontrolled component keeps the source of truth in the DOM.

While controlled components treat their own maintained state as the data source:

Since the value attribute is set on our form element, the displayed value will always be this.state.value, making the React state the source of truth.

The key to making program behavior predictable lies in reducing variables, i.e., ensuring a unique data source. Then there are components with unique data sources, called fully controlled components and fully uncontrolled components.

Corresponding to the previous derived state scenarios, there are these two solutions:

// (Data) Fully controlled component, no longer maintains input state, value is completely controlled externally
function EmailInput(props) {
  return <input onChange={props.onChange} value={props.email} />;
}

// (Data) Fully uncontrolled component, only maintains its own state, completely不接受 any updates from props
class EmailInput extends Component {
  state = { email: this.props.defaultEmail };

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }
}

P.S. Note, "data-controlled components" and "controlled components" are two completely different concepts. According to the definition of controlled components, both examples above are controlled components.

So, in scenarios where props need to be copied to state, either consider bringing props in completely as your own state, no longer affected by the outside (making data controlled):

Instead of trying to "mirror" a prop value in state, make the component controlled

Or discard your own state, completely giving up control of the data:

Remove state from our component entirely.

5. Caching Calculation Results

At other times, copying props to state is to cache calculation results, avoiding repeated calculations.

For example, a common scenario of filtering list items by input keywords:

class Example extends Component {
  state = {
    filterText: "",
  };

  static getDerivedStateFromProps(props, state) {
    if (
      props.list !== state.prevPropsList ||
      state.prevFilterText !== state.filterText
    ) {
      return {
        prevPropsList: props.list,
        prevFilterText: state.filterText,
        // Cache props calculation result to state
        filteredList: props.list.filter(item => item.text.includes(state.filterText))
      };
    }
    return null;
  }

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{this.state.filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

It works, but it's too complicated. Creating another variable (state.filteredList) through getDerivedStateFromProps, so both props change and state change can affect the filtering result, which easily causes problems.

In fact, if you want to avoid repeated calculations, you don't need to cache a result to state. For example:

class Example extends PureComponent {
  state = {
    filterText: ""
  };

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    const filteredList = this.props.list.filter(
      item => item.text.includes(this.state.filterText)
    )

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

Using the characteristic of PureComponent's render() being called again only when props change or state change, you can safely do calculations directly in render().

Looks perfect, but in actual scenarios state and props are generally not this simple. If another calculation-unrelated props or state updates, it will also trigger rerender, causing repeated calculations.

So simply put aside the "unreliable" PureComponent and solve it like this:

import memoize from "memoize-one";

class Example extends Component {
  state = { filterText: "" };

  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    const filteredList = this.filter(this.props.list, this.state.filterText);

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

Don't put calculation results into state, nor avoid rerender, but cache externally. Both clean and reliable.

References

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment