Skip to content

Instantly share code, notes, and snippets.

@heygrady
Last active June 27, 2021 21:21
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save heygrady/9f447b215da5c4222168c773f15c9e40 to your computer and use it in GitHub Desktop.
Save heygrady/9f447b215da5c4222168c773f15c9e40 to your computer and use it in GitHub Desktop.
Refactoring derived state

How to refactor derived state

In our aging react code base we had been using componentWillRecieveProps for situations where we wanted to recalculate "stuff" when props change. Recent versions of react renamed this method to UNSAFE_componentWillRecieveProps for a variety of reasons. We recently went through an exercise where we tried to kill UNSAFE_componentWillRecieveProps once and for all.

The following is a contrived example of how to refactor such a component.

Step 1: How not to do it.

Here's a button component that uses UNSAFE_componentWillReceiveProps to keep its internal state in sync with the external props.

  1. Initialize the y in state from props in the constructor.
  2. If the x prop changes, reset y.
  3. If the user clicks the button, increment y.

This button is very silly but it highlights the core problem we're trying to refactor.

class MyButton extends Component {
  constructor(props) {
    super(props)
    this.state = { y: props.x }
    this.onClick = this.onClick.bind(this)
  }
  UNSAFE_componentWillReceiveProps(nextProps) {
    const { x } = nextProps
    this.setState({ y: x })
  }
  onClick() {
    const { y } = this.state
    this.setState({ y: y + 1 })
  }
  render () {
    const { y } = this.state
    return <button onClick={this.onClick}>Clicked {y} times</button>
  }
}

Step 2: kill UNSAFE_componentWillReceiveProps

The first step is to stop using a deprecated lifecycle method. In our first attempt we're opting for componentDidUpdate, since that's not deprecated.

NOTE: componentDidUpdate is a poor choice for this situation, which will be discussed in more detail below.

There are some subtle but important differences between UNSAFE_componentWillReceiveProps and componentDidUpdate. Mainly, componentDidUpdate happens after the component has rendered and UNSAFE_componentWillReceiveProps happens before. The refactor below will lead to additional renders.

Another key difference between the two methods is componentDidUpdate happens after props or state have updated. This means that calling setState from within componentDidUpdate can lead to an endless render loop.

The docs highlight this issue:

You may call setState() immediately in componentDidUpdate() but note that it must be wrapped in a condition or you’ll cause an infinite loop.

There is another subtle problem with this design. The onClick handler is called from user-land and might happen at any random time during the render cycle. There are some edge cases where componentDidUpdate and onClick could be called in a sequence that results in the wrong state.

Problems:

  1. componentDidUpdate is called for updates to both props and state
  2. Calling setState will always lead to a re-render.
  3. There's a potential race condition between onClick and componentDidUpdate.
class MyButton extends Component {
  constructor(props) {
    super(props)
    this.state = { y: props.x }
    this.onClick = this.onClick.bind(this)
  }
  componentDidUpdate(prevProps) {
    const { x } = this.props
    const { y } = this.state
    // Problem: we only care about prop changes
    // only call set state when props changed and state doesn't match
    if (prevProps.x !== x && y !== x) {
      // Problem: setState() will always lead to a re-render
      this.setState({ y: x })  
    }
  }
  onClick() {
    const { y } = this.state
    // Problem: potential race condition
    this.setState({ y: y + 1 })
  }
  render () {
    const { y } = this.state
    return <button onClick={this.onClick}>Clicked {y} times</button>
  }
}

Step 3: use "updater function" version of setState

Let's use the new "updater function" version of setState to fix our potential race condition. It's now the recommended way to pass an updater function to setState but it's still possible to do it the "old" way.

The reason for switching to the "new" way is to ensure that the state your intending to set is correct. Calling setState is asynchronous, meaning, the state will eventually be what you set but it may not change right away. If an important change to state were to happen before state was updated then unexpected things can happen.

Let's imagine the following scenario:

  1. The x prop updates from 10 to 15.
  2. componentDidUpdate calls setState to update y to match
  3. Before that change is committed, the user clicks the button.
  4. onClick calls setState to increment y by 1
  5. componentDidUpdate calls setState again

What is the new value of y? It's hard to know for sure. It could be 11, 15, or 16.

Below we have changed both of our calls to setState to use update functions instead. Now if we re-rerun the scenario we should always get 16.

class MyButton extends Component {
  constructor(props) {
    super(props)
    this.state = { y: props.x }
    this.onClick = this.onClick.bind(this)
  }
  componentDidUpdate(prevProps) {
    const { x } = this.props
    const { y } = this.state
    // Problem: we only care about prop changes
    // only call set state when props changed and state doesn't match
    if (prevProps.x !== x && y !== x) {
      // Problem: setState() will always lead to a re-render
      this.setState((state, props) => {
        const { x } = props
        const { y } = state
        if (y !== x) {
          return { y: x }
        }
        return null
      })
    }
  }
  onClick() {
    this.setState(({ y }) => ({ y: y + 1 }))
  }
  render () {
    const { y } = this.state
    return <button onClick={this.onClick}>Clicked {y} times</button>
  }
}

Step 4: use getDerivedStateFromProps

Moving to the improved "updater function" version of setState solves one problem but the larger issue is that deriving state from props in componentDidUpdate will lead to at least one extra render cycle any time the props are updated. Luckily, the react team thought of this when they deprecated componentWillRecieveProps. We can fix our problem using getDerivedStateFromProps.

getDerivedStateFromProps slots in where componentWillRecieveProps would be used except it is only capable of updating state. In practice, getDerivedStateFromProps is similar to the "updater function" version of setState and it is executed exactly where we need it to be in order to avoid additional renders.

See below that we can greatly simplify our code. You may notice that guts of our getDerivedStateFromProps function are exactly what we had in our state updater from before.

Problems:

  1. We don't really need derived state
class MyButton extends Component {
  constructor(props) {
    super(props)
    this.state = { y: props.x }
    this.onClick = this.onClick.bind(this)
  }
  // Problem: we don't really need derived state
  static getDerivedStateFromProps(props, state) {
    const { x } = props
    const { y } = state
    if (y !== x) {
      return { y: x }
    }
    return null
  }
  onClick() {
    this.setState(({ y }) => ({ y: y + 1 }))
  }
  render () {
    const { y } = this.state
    return <button onClick={this.onClick}>Clicked {y} times</button>
  }
}

Step 5: stop deriving state from props

The react team has made a big effort to get people to stop deriving state from props. The solution is to "lift state" out of our components. It's worth noting that lifting state is precisely the problem that redux was designed to solve.

Taking this step means spreading our code out among many more files. If you ignore the boilerplate of importing the various libraries involved, the resulting code is similar in size. If you look below, the MyButton component is significantly simplified.

The final code has no need for derived state and is free of unnecessary rerenders and race conditions.

Make a module

We're going to organize our code into a "module".

NOTE: The details of configuring redux for your application are outside the scope of this document. For simplicity, we're assuming our reducer will be wired up to manage state.myButton. The basics here will be familiar to anyone who has completed the counter example in the redux manual.

- modules/myButton/actions.js
- modules/myButton/constants.js
- modules/myButton/reducer.js
- modules/myButton/selectors.js

Actions

We need to create an incrementX action. We're going to use createAction from redux-actions to simplify things. You can read more about actions in the redux manual.

// modules/myButton/actions.js
import { createAction } from 'redux-actions'

import { INCREMENT_X } from './constants'

export const incrementX = createAction(INCREMENT_X)

Constants

Our action is really just a constant that the reducer will use to make state changes.

// modules/myButton/constants.js
export const INCREMENT_X = '@@my-app/myButton/INCREMENT_X'

Reducers

We will dispatch our action to a reducer. You can read more about reducers in the redux manual. To make things simple we'll use handleAction from redux-actions along with combineReducers from redux.

// modules/myButton/reducer.js
import { combineReducers } from 'redux'
import { handleAction } from 'redux-actions'

import { INCREMENT_X } from './constants'

export default combineReducers({
  x: handleAction(INCREMENT_X, (state) => state + 1, 0)
})

Selectors

We will need a selectX selector to read the current value of x from the redux state.

// modules/myButton/selectors.js
export const selectX = (state) => state.myButton.x

Make a container

Our container will use the connect function from react-redux to connect our simplified component to our lifted state in redux.

import { connect } from 'react-redux'

import { selectX } from './module/selectors'
import { incrementX } from './module/actions'

import MyButton from './MyButton'

const mapStateToProps = (state) => ({ x: selectX(state) })
const mapDispatchToProps = (dispatch) => ({ onClick: () => dispatch(incrementX()) })

const MyButtonContainer = connect(mapStateToProps, mapDispatchToProps)(MyButton)

Simplify our component

Now our component can be changed from a class component to a functional component. The value for x is now a prop that we can rely on to always be correct. There's no need to keep an internal state!

import React from 'react'

const MyButton = ({ x, onClick }) => (
  <button onClick={onClick}>Clicked {x} times</button>
)

export default MyButton
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment