Originally published at https://www.developerway.com. The website has more articles like this đ
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-render
return 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-render
if (nextProps.someprop !== this.props.someprop) return true;
// and won't re-render if anything else changes
return 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" changes
if (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" changes
if (nextProps.someprop !== this.props.someprop) return true;
// re-render component if "somestate" changes
if (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 change
if (!isEqual(nextProps, this.prop)) return true;
// re-render component if "somestate" changes
if (!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 unnecessary
class 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 PureComponent
export 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 comparison
const 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-renders
const 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 it
if (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 change
const 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" state
if (counter > 100) return;
// change "counter" state - the same state as above
setCounter(counter + 1);
}
You can do this instead:
const onChildClick = () => {
// don't depend on state anymore, checking the condition inside
setCounter((counter) => {
// return the same counter - no state updates
if (counter > 100) return counter;
// actually updating the counter
return 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" counter
const mirrorStateRef = useRef(null);
useEffect(() => {
// updating ref value when the counter changes
mirrorStateRef.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 lifecycle
const 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 value
const 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! âđŒ
Originally published at https://www.developerway.com. The website has more articles like this đ
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.
Top comments (10)
Great article explaining how to use memorization to achieve the same effect that PureComponent had!
I think the reason why we're in re-render hell is because some people started putting everything in useEffect, for example when they have derived state, they make a useEffect to update the derived state, which causes an extra re-render, what they should have done is useMemo. Also some people are so convinced about NOT using useMemo that they will actually argue that there's no difference between using useEffect or useMemo for derived state (yes I had that discussion on this platform and was unable even with codesandbox example to convince them otherwise). Hopefully good articles like this will help us to escape re-render hell!
No difference between
useEffect
anduseMemo
for state? For real? I thought I've seen everything, but that's new. What kind of argument can even be made towards them being the same? đŹThey made a codesandbox to proof it, by putting a console.log in the
useEffect
for theiruseEffect
example and putting a console.log in the render function for theiruseMemo
example and then saying: see they both log the same number of times! đNo words đđđ
Thank You!!
Great in-depth article, but my goodness - this does add a lot of mental overhead to React development, compared to frameworks/libraries which just manage this for you (e.g. Vue with it's "refs").
Ironic how React is one of the few frameworks that does NOT manage 'React'ivity for the developer :)
(you can say it's not hard once you properly understand it but still - arguably this is kinda low level stuff that you shouldn't have to worry about, and that distracts from what you're actually trying to do i.e. implement your functionality)
I'm sure they have their own caveats, once you dig deep enough into those :) Especially when it comes to fine tuning performance in a big app.
For a simple UI none of the things described here are mandatory to know, they just come in handy for complicated use cases or for fixing performance issues.
Agreed to all of that, at some point you'll always need to know how stuff works under the hood, also in Vue :)
I like that for the vast majority of cases, none of it really matters. React will be fast enough and that makes it easier to get started just building things.
Once you do find yourself in the situation where you have performance problems and need to start optimizing, you can see it gets much deeper and React has a lot of tools to help. Nadia's various posts have helped me immensely with the React Native app I build/maintain for work in this regard.
Great article. I appreciate all the detail.
I'm just going to share my thoughts as an every day react dev. These are the reasons why I'm a solidjs enthusiast. I just see less and less reasons to put up with all the tricks that need to be made to rerender nothing else than required. It seems like fighting against the framework design to correct its default behaviour.