React 16.4 - manual form input fill along with its updates from getDerivedStateFromProps?

203 views Asked by At

I face a problem once update on React 16.4 where we have some breaking changes with getDerivedStateFromProps logic. Now it fires on each component update on both incoming and own component's props.

So, I've read the docs and manuals, but still can't figure out with cases where form input fields should be based on incoming props (controlled component) and, at the same time, be able to modify by the user own input?

I've also tried this post, but it just covers cases for a one-time update, not the manual input case: Why getDerivedStateFromProps is called after setState?

Here is my little code to reproduce:

import PropTypes from 'prop-types'
import React from 'react'

export class NameEditor extends React.Component {
  static propTypes = {
    currentLevel: PropTypes.number
  }

  static defaultProps = {
    currentLevel: 0
  }

  constructor(props) {
    super(props)

    this.state = {
      currentLevel: 0
    }
  }

  static getDerivedStateFromProps(nextProps) {
    return {
      currentLevel: nextProps.currentLevel
    }
  }

  _handleInputChange = e => {
    this.setState({
      currentLevel: e.target.value
    })
  }

  render() {
    const { currentLevel } = this.state

    return (
        <input
          placeholder={0}
          value={currentLevel}
          onChange={this._handleInputChange}
        />
    )
  }
}

export default NameEditor
3

There are 3 answers

1
Sviat Kuzhelev On BEST ANSWER

SOLUTION #1 (with key and remount):

You probably need to make your current component remount on each outer props update by providing it with a key, based on your incoming prop: currentLevel. It would looks like:

class Wrapper ... {
...

  render() {
    const { currentLevel } = this.props;

    return (
     <NameEditor key={currentLevel} {...currentLevel} />
    )
  }
}

export default Wrapper

...and make some extra changes on your component to block derived props replacing by telling it - is it a first time render or not (because we plan to control its state from inside only and from outer only by remount, when it really so):

import PropTypes from 'prop-types'
import React from 'react'

export class NameEditor extends React.Component {
  static propTypes = {
    currentLevel: PropTypes.number
  }

  static defaultProps = {
    currentLevel: 0
  }

  constructor(props) {
    super(props)

    this.state = {
      currentLevel: 0,
      isFirstRender: false
    }
  }

  static getDerivedStateFromProps(nextProps, prevProps) {
    if (!prevProsp.isFirstRender) {
      return {
        currentLevel: nextProps.currentLevel,
        isFirstRender: true
      };
    }

    return null;
  }

  _handleInputChange = e => {
    this.setState({
      currentLevel: e.target.value
    })
  }

  render() {
    const { currentLevel } = this.state

    return (
        <input
          placeholder={0}
          value={currentLevel}
          onChange={this._handleInputChange}
        />
    )
  }
}

export default NameEditor

So, by that scenario you'll achieve chance to manipulate your component state by manually inputed value from form.

SOLUTION #2 (without remount by flag):

Try to set some flag to separate outer (getDerived...) and inner (Controlled Comp...) state updates on each rerender. For example by updateType:

import PropTypes from 'prop-types'
import React from 'react'

export class NameEditor extends React.Component {
  static propTypes = {
    currentLevel: PropTypes.number
  }

  static defaultProps = {
    currentLevel: 0
  }

  constructor(props) {
    super(props)

    this.state = {
      currentLevel: 0,
      updateType: 'props' // by default we expecting update by incoming props
    } 
  }

  static getDerivedStateFromProps(nextProps, prevProps) {
    if (!prevState.updateType || prevState.updateType === 'props') {
      return {
        updateType: 'props',
        currentLevel: nextProps.currentLevel,
        exp: nextProps.exp
      }
    }

    if (prevState.updateType === 'state') {
      return {
        updateType: '' // reset flag to allow update from incoming props
      }
    }

    return null
  }

  _handleInputChange = e => {
    this.setState({
      currentLevel: e.target.value
    })
  }

  render() {
    const { currentLevel } = this.state

    return (
        <input
          placeholder={0}
          value={currentLevel}
          onChange={this._handleInputChange}
        />
    )
  }
}

export default NameEditor

P.S. It's probably an anti-pattern (hope Dan will never see this), but I can't find a better solution in my head now.

SOLUTIONS #3:

See Sultan H. post under this one, about controlled logic with explicit callback from wrapper component.

3
Vadim Hulevich On

because after set state React invoke render, but before render you always invoke getDerivedStateFromProps method.

setState schedules an update to a component’s state object. When state changes, the component responds by re-rendering

getDerivedStateFromProps is invoked right before calling the render method, both on the initial mount and on subsequent updates. It should return an object to update the state, or null to update nothing.

https://reactjs.org/docs/react-component.html#static-getderivedstatefromprops

3
Sultan H. On

UPDATED:

Currently, _handleInputChange method will only modify the state of the child component, which will invoke the getDerivedStateFromProps.

The way that method works is, it get's invoked when every newProps or a setState call has occured.

Thus, the behavior is as follows:

  1. You change the value with your handler.
  2. getDerivedStateFromProps get's invoked, which will get the currentLevel value from the parent component, which is still not modified as we didn't do any changes there, therefore, it will overwrite the new value coming from invoking your handler, with the value that exist in the parent component, which wasn't modified.

To solve this: we will need a callback function coming from the parent component, which does the same work as the handleInputChange.

So:

  1. Add a handleCurrentLevelChange method to your parent component, which will have only one parameter e.target.value, it job is to modify the currentLevel at your parent state.
  2. Pass the handleCurrentLevelChange you created to your NameEditor the name you want, possibly the same name.
  3. Modify your child's handlr as follows:
  _handleInputChange = (e, cb) => {
    this.setState({
      currentLevel: e.target.value
    }, () => {
      cb && cb(e.target.value) //this makes the callback optional.
    });
  }
  1. Modify your onChange property to fit the new updates:
        <input
          placeholder={0}
          value={currentLevel}
          onChange={(e) => this._handleInputChange(e, handleCurrentLevelChange)}

The new behavior of the onChange property and handler will allow the changes to happen both at your child and your parent.

This should be solving the current problem.