Table of Contents
- PureComponent, shouldComponentUpdate: which problems do they solve?
- PureComponent/shouldComponentUpdate vs functional components & hooks
- Bonus: bailing out from state updates quirk
- TL;DR Summary
There is more
Nadia Makarevich
PureComponents vs Functional Components with hooks
Looking into PureComponent
and the problem it solved, how it can be replaced now in the hooks & functional components world, and discovering an interesting quirk of React re-renders behavior
Do you agree that everything was better in the good old days? Cats were fluffier, unlimited chocolate didn’t cause diabetes and in React we didn’t have to worry about re-renders: PureComponent
or shouldComponentUpdate
would take care of them for us.
When I read comments or articles on React re-renders, the opinion that because of hooks and functional components we’re now in re-rendering hell keeps popping up here and there. This got me curious: I don’t remember the “good old days” being particularly good in that regard. Am I missing something? Is it true that functional components made things worse from re-renders perspective? Should we all migrate back to classes and PureComponent
?
So here is another investigation for you: looking into PureComponent
and the problem it solved, understanding whether it can be replaced now in the hooks & functional components world, and discovering an interesting (although a bit useless) quirk of React re-renders behavior that I bet you also didn’t know 😉
PureComponent, shouldComponentUpdate: which problems do they solve?
First of all, let’s remember what exactly is PureComponent
and why we needed shouldComponentUpdate
.
Unnecessary re-renders because of parents
As we know, today a parent's re-render is one of the reasons why a component can re-render itself. If I change state in the Parent
component it will case its re-render, and as a consequence, the Child
component will re-render as well:
const Child = () => <div>render something here</div>;const Parent = () => {const [counter, setCounter] = useState(1);return (<><button onClick={() => setCounter(counter + 1)}>Click me</button><!-- child will re-render when "counter" changes--><Child /></>)}
It’s exactly the same behavior as it was before, with class-based components: state change in Parent
will trigger re-render of the Child
:
class Child extends React.Component {render() {return <div>render something here</div>}}class Parent extends React.Component {super() {this.state = { counter: 1 }}render() {return <><button onClick={() => this.setState({ counter: this.state.counter + 1 })}>Click me</button><!-- child will re-render when state here changes --><Child /></>}}
And again, exactly the same story as today: too many or too heavy re-renders in the app could cause performance problems.
In order to prevent that, React allows us to override shouldComponentUpdate
method for the class components. This method is triggered before the component is supposed to re-render. If it returns true
, the component proceeds with its lifecycle and re-renders; if false
- it won’t. So if we wanted to prevent our Child
components from parent-induced re-renders, all we needed to do is to return false
in shouldComponentUpdate
:
class Child extends React.Component {shouldComponentUpdate() {// now child component won't ever re-renderreturn false;}render() {return <div>render something here</div>}}
But what if we want to pass some props to the Child
component? We actually need this component to update itself (i.e. re-render) when they change. To solve this, shouldComponentUpdate
gives you access to nextProps
as an argument and you have access to the previous props via this.props
:
class Child extends React.Component {shouldComponentUpdate(nextProps) {// now if "someprop" changes, the component will re-renderif (nextProps.someprop !== this.props.someprop) return true;// and won't re-render if anything else changesreturn false;}render() {return <div>{this.props.someprop}</div>}}
Now, if and only if someprop
changes, Child
component will re-render itself.
Even if we add some state to it 😉. Interestingly enough, shouldComponentUpdate
is called before state updates as well. So this method is actually very dangerous: if not used carefully, it could cause the component to misbehave and not update itself properly on its state change. Like this:
class Child extends React.Component {constructor(props) {super(props);this.state = { somestate: 'nothing' }}shouldComponentUpdate(nextProps) {// re-render component if and only if "someprop" changesif (nextProps.someprop !== this.props.someprop) return true;return false;}render() {return (<div><!-- click on a button should update state --><!-- but it won't re-render because of shouldComponentUpdate --><button onClick={() => this.setState({ somestate: 'updated' })}>Click me</button>{this.state.somestate}{this.props.someprop}</div>)}}
In addition to props, Child
component has some state now, which is supposed to be updated on button click. But clicking on this button won’t cause Child
component to re-render, since it’s not included in the shouldComponentUpdate
, so the user will actually never see the updated state on the screen.
In order to fix it, we also need to add state comparison to the shouldComponentUpdate
function: React sends us nextState
there as the second argument:
shouldComponentUpdate(nextProps, nextState) {// re-render component if "someprop" changesif (nextProps.someprop !== this.props.someprop) return true;// re-render component if "somestate" changesif (nextState.somestate !== this.state.somestate) return true;return false;}
As you can imagine, writing that manually for every state and prop is a recipe for a disaster. So most of the time it would be something like this instead:
shouldComponentUpdate(nextProps, nextState) {// re-render component if any of the prop changeif (!isEqual(nextProps, this.prop)) return true;// re-render component if "somestate" changesif (!isEqual(nextState, this.state)) return true;return false;}
And since this is such a common use case, React gives us PureComponent
in addition to just Component
, where this comparison logic is implemented already. So if we wanted to prevent our Child
component from unnecessary re-renders, we could just extend PureComponent
without writing any additional logic:
// extend PureComponent rather than normal Component// now child component won't re-render unnecessaryclass PureChild extends React.PureComponent {constructor(props) {super(props);this.state = { somestate: 'nothing' }}render() {return (<div><button onClick={() => this.setState({ somestate: 'updated' })}>Click me</button>{this.state.somestate}{this.props.someprop}</div>)}}
Now, if we use that component in the Parent
from above, it will not re-render if the parent’s state changes, and the Child’s state will work as expected:
class Parent extends React.Component {super() {this.state = { counter: 1 }}render() {return <><button onClick={() => this.setState({ counter: this.state.counter + 1 })}>Click me</button><!-- child will NOT re-render when state here changes --><PureChild someprop="something" /></>}}
Unnecessary re-renders because of state
As mentioned above, shouldComponentUpdate
provides us with both props AND state. This is because it is triggered before every re-render of a component: regardless of whether it’s coming from parents or its own state. Even worst: it will be triggered on every call of this.setState
, regardless of whether the actual state changed or not.
class Parent extends React.Component {super() {this.state = { counter: 1 }}render() {<!-- every click of the button will cause this component to re-render --><!-- even though actual state doesn't change -->return <><button onClick={() => this.setState({ counter: 1 })}>Click me</button></>}}
Extend this component from React.PureComponent
and see how re-renders are not triggered anymore on every button click.
Because of this behavior, every second recommendation on “how to write state in React” from the good old days mentions “set state only when actually necessary” and this is why we should explicitly check whether state has changed in shouldComponentUpdate
and why PureComponent
already implements it for us.
Without those, it is actually possible to cause performance problems because of unnecessary state updates!
To summarise this first part: PureComponent
or shouldComponentUpdate
were used to prevent performance problems caused by unnecessary re-renders of components caused by their state updates or their parents re-renders.
PureComponent/shouldComponentUpdate vs functional components & hooks
And now back to the future (i.e. today). How do state and parent-related updates behave now?
Unnecessary re-renders because of parents: React.memo
As we know, re-renders from parents are still happening, and they behave in exactly the same way as in the classes world: if a parent re-renders, its child will re-render as well. Only in functional components we don’t have neither shouldComponentUpdate
nor PureComponent
to battle those.
Instead, we have React.memo
: it’s a higher-order component supplied by React. It behaves almost exactly the same as PureComponent
when it comes to props: even if a parent re-renders, re-render of a child component wrapped in React.memo won’t happen unless its props change.
If we wanted to re-implement our Child
component from above as a functional component with the performance optimization that PureComponent
provides, we’d do it like this:
const Child = ({ someprop }) => {const [something, setSomething] = useState('nothing');render() {return (<div><button onClick={() => setSomething('updated')}>Click me</button>{somestate}{someprop}</div>)}}// Wrapping Child in React.memo - almost the same as extending PureComponentexport const PureChild = React.memo(Child);
And then when Parent
component changes its state, PureChild
won’t re-render: exactly the same as a PureChild
based on PureComponent
:
const Parent = () => {const [counter, setCounter] = useState(1);return (<><button onClick={() => setCounter(counter + 1)}>Click me</button><!-- won't re-render because of counter change --><PureChild someprop="123" /></>)}
Props with functions: React.memo comparison function
Now let’s assume PureChild
accepts onClick
callback as well as a primitive prop. What will happen if I just pass it like an arrow function?
<PureChild someprop="123" onClick={() => doSomething()} />
Both React.memo
and PureComponent
implementation will be broken: onClick
is a function (non-primitive value), on every Parent
re-render it will be re-created, which means on every Parent
re-render PureChild
will think that onClick
prop has changed and will re-render as well. Performance optimization is gone for both.
And here is where functional components have an advantage.
PureChild
on PureComponent
can’t do anything about the situation: it would be either up to the parent to pass the function properly, or we would have to ditch PureComponent
and re-implement props and state comparison manually with shouldComponentUpdate
, with onClick
being excluded from the comparison.
With React.memo
it’s easier: we can just pass to it the comparison function as a second argument:
// exclude onClick from comparisonconst areEqual = (prevProps, nextProps) => prevProps.someprop === nextProps.someprop;export const PureChild = React.memo(Child, areEqual);
Essentially React.memo
combines both PureComponent
and shouldComponentUpdate
in itself when it comes to props. Pretty convenient!
Another convenience: we don’t need to worry about state anymore, as we’d do with shouldComponentUpdate
. React.memo
and its comparison function only deals with props, Child’s state will be unaffected.
Props with functions: memoization
While comparison functions from above are fun and look good on paper, to be honest, I wouldn’t use it in a real-world app. (And I wouldn’t use shouldComponentUpdate
either). Especially if I’m not the only developer on the team. It’s just too easy to screw it up and add a prop without updating those functions, which can lead to such easy-to-miss and impossible-to-understand bugs, that you can say goodbye to your karma and the sanity of the poor fella who has to fix it.
And this is where actually PureComponent
takes the lead in convenience competition. What we would do in the good old days instead of creating inline functions? Well, we’d just bind the callback to the class instance:
class Parent extends React.Component {onChildClick = () => {// do something here}render() {return <PureChild someprop="something" onClick={this.onChildClick} />}}
This callback will be created only once, will stay the same during all re-renders of Parent
regardless of any state changes, and won’t destroy PureComponent’s shallow props comparison.
In functional components we don’t have class instance anymore, everything is just a function now, so we can’t attach anything to it. Instead, we have… nothing… a few ways to preserve the reference to the callback, depending on your use case and how severe are the performance consequences of Child’s unnecessary re-renders.
1. useCallback hook
The simplest way that will be enough for probably 99% of use cases is just to use useCallback hook. Wrapping our onClick
function in it will preserve it between re-renders if dependencies of the hook don’t change:
const Parent = () => {const onChildClick = () => {// do something here}// dependencies array is empty, so onChildClickMemo won't change during Parent re-rendersconst onChildClickMemo = useCallback(onChildClick, []);return <PureChild someprop="something" onClick={onChildClickMemo} />}
What if the onClick
callback needs access to some Parent’s state? In class-based components that was easy: we had access to the entire state in callbacks (if we bind them properly):
class Parent extends React.Component {onChildClick = () => {// check that count is not too big before updating itif (this.state.counter > 100) return;// do something}render() {return <PureChild someprop="something" onClick={this.onChildClick} />}}
In functional components it’s also easy: we just add that state to the dependencies of useCallback
hook:
const Parent = () => {const onChildClick = () => {if (counter > 100) return;// do something}// depends on somestate now, function reference will change when state changeconst onChildClickMemo = useCallback(onChildClick, [counter]);return <PureChild someprop="something" onClick={onChildClickMemo} />}
With a small caveat: useCallback
now depends on counter
state, so it will return a different function when the counter changes. This means PureChild
will re-render, even though it doesn’t depend on that state explicitly. Technically - unnecessary re-render. Does it matter? In most cases, it won’t make a difference, and performance will be fine. Always measure the actual impact before proceeding to further optimizations.
In very rare cases when it actually matters (measure first!), you have at least two more options to work around that limitation.
2. setState function
If all that you do in the callback is setting state based on some conditions, you can just use the pattern called “updater function” and move the condition inside that function.
Basically, if you’re doing something like this:
const onChildClick = () => {// check "counter" stateif (counter > 100) return;// change "counter" state - the same state as abovesetCounter(counter + 1);}
You can do this instead:
const onChildClick = () => {// don't depend on state anymore, checking the condition insidesetCounter((counter) => {// return the same counter - no state updatesif (counter > 100) return counter;// actually updating the counterreturn counter + 1;});}
That way onChildClick
doesn’t depend on the counter state itself and state dependency in the useCallback
hook won’t be needed.
3. mirror state to ref
In case you absolutely have to have access to different states in your callback, and absolutely have to make sure that this callback doesn’t trigger re-renders of the PureChild
component, you can “mirror” whichever state you need to a ref object.
Ref object is just a mutable object that is preserved between re-renders: pretty much like state, but:
- it’s mutable
- it doesn’t trigger re-renders when updated
You can use it to store values that are not used in render function (see the docs for more details), so in case of our callbacks it will be something like this:
const Parent = () => {const [counter, setCounter] = useState(1);// creating a ref that will store our "mirrored" counterconst mirrorStateRef = useRef(null);useEffect(() => {// updating ref value when the counter changesmirrorStateRef.current = counter;}, [counter])const onChildClick = () => {// accessing needed value through ref, not statej - only in callback! never during render!if (mirrorStateRef.current > 100) return;// do something here}// doesn't depend on state anymore, so the function will be preserved through the entire lifecycleconst onChildClickMemo = useCallback(onChildClick, []);return <PureChild someprop="something" onClick={onChildClickMemo} />}
First: creating a ref object. Then in useEffect hook updating that object with the state value: ref is mutable, so it’s okay, and its update won’t trigger re-render, so it’s safe. Lastly, using the ref value to access data in the callback, that you’d normally access directly via state. And tada 🎉: you have access to state value in your memoized callback without actually depending on it.
Full disclaimer: I have never needed this trick in production apps. It’s more of a thought exercise. If you find yourself in a situation where you’re actually using this trick to fix actual performance problems, then chances are something is wrong with your app architecture and there are easier ways to solve those problems. Take a looks at Preventing re-renders with composition part of React re-renders guide, maybe you can use those patterns instead.
Props with arrays and objects: memoization
Props that accept arrays or objects are equally tricky for PureComponent
and React.memo
components. Passing them directly will ruin performance gains since they will be re-created on every re-render:
<!-- will re-render on every parent re-render --><PureChild someArray={[1,2,3]} />
And the way to deal with them is exactly the same in both worlds: you either pass state directly to them, so that reference to the array is preserved between re-renders. Or use any memoization techniques to prevent their re-creation. In the old days, those would be dealt with via external libraries like memoize
. Today we can still use them, or we can use useMemo hook that React gives us:
// memoize the valueconst someArray = useMemo(() => ([1,2,3]), [])<!-- now it won't re-render --><PureChild someArray={someArray} />
Unnecessary re-renders because of state
And the final piece of the puzzle. Other than parent re-renders, PureComponent
prevents unnecessary re-renders from state updates for us. Now that we don’t have it, how do we prevent those?
And yet another point to functional components: we don’t have to think about it anymore! In functional components, state updates that don’t actually change state don’t trigger re-render. This code will be completely safe and won’t need any workarounds from re-renders perspective:
const Parent = () => {const [state, setState] = useState(0);return (<><!-- we don't actually change state after setting it to 1 when we click on the button --><!-- but it's okay, there won't be any unnecessary re-renders--><button onClick={() => setState(1)}>Click me</button></>)}
This behavior is called “bailing out from state updates” and is supported natively in useState hook.
Bonus: bailing out from state updates quirk
Fun fact: if you don’t believe me and react docs in the example above, decide to verify how it works by yourself and place console.log
in the render function, the result will break your brain:
const Parent = () => {const [state, setState] = useState(0);console.log('Log parent re-renders');return (<><button onClick={() => setState(1)}>Click me</button></>)}
You’ll see that the first click on the button console.log
is triggered: which is expected, we change state from 0 to 1. But the second click, where we change state from 1 to 1, which is supposed to bail, will also trigger console.log
! But third and all the following clicks will do nothing… 🤯 WTF?
Turns out this is a feature, not a bug: React is being smartass here and tries to make sure that it’s actually safe to bail out on the first “safe” state update. The “bailing out” in this context means that children won’t re-render, and useEffect
hooks won’t be triggered. But React will still trigger Parent’s render function the first time, just in case. See this issue for more details and rationale: useState not bailing out when state does not change · Issue #14994 · facebook/react
TL;DR Summary
That is all for today, hope you had fun comparing the past and the future, and learned something useful in the process. Quick bullet points of the above wall of text:
- when migrating PureComponent to functional components, wrapping component in React.memo will give you the same behavior from re-renders perspective as PureComponent
- complicated props comparison logic from shouldComponentUpdate can be re-written as an updater function in React.memo
- no need to worry about unnecessary state updates in functional components - React handles them for us
- when using “pure” components in functional components, passing functions as props can be tricky if they need access to state since we don’t have instance anymore. But we can use instead:
- useCallback hook
- updater function in state setter
- “mirror” necessary state data in ref
- arrays and objects as props of “pure” components need to be memoized both for PureComponent and React.memo components
Live long and prosper in re-renders-free world! ✌🏼