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.
Here's a button component that uses UNSAFE_componentWillReceiveProps
to keep its internal state in sync with the external props.
- Initialize the
y
in state from props in the constructor. - If the
x
prop changes, resety
. - 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>
}
}
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 incomponentDidUpdate()
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:
componentDidUpdate
is called for updates to both props and state- Calling
setState
will always lead to a re-render. - There's a potential race condition between
onClick
andcomponentDidUpdate
.
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>
}
}
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:
- The
x
prop updates from 10 to 15. componentDidUpdate
callssetState
to updatey
to match- Before that change is committed, the user clicks the button.
onClick
callssetState
to incrementy
by 1componentDidUpdate
callssetState
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>
}
}
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:
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>
}
}
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.
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
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)
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'
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)
})
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
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)
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