DEV Community

Nadia Makarevich
Nadia Makarevich

Posted on • Edited on • Originally published at developerway.com

PureComponents vs Functional Components with hooks

Image description

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 />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 />
    </>
  }
}
Enter fullscreen mode Exit fullscreen mode

See example in codesandbox

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>
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

See example in codesandbox

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;
  }
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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" />
    </>
  }
}
Enter fullscreen mode Exit fullscreen mode

See example in codesandbox

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>
    </>
  }
}
Enter fullscreen mode Exit fullscreen mode

See example in codesandbox

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);
Enter fullscreen mode Exit fullscreen mode

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" />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

See example in codesandbox

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()} />
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Essentially React.memo combines both PureComponent and shouldComponentUpdate in itself when it comes to props. Pretty convenient!

See example in codesandbox

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} />
  }
}
Enter fullscreen mode Exit fullscreen mode

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} />
}
Enter fullscreen mode Exit fullscreen mode

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} />
  }
}
Enter fullscreen mode Exit fullscreen mode

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} />
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

That way onChildClick doesn’t depend on the counter state itself and state dependency in the useCallback hook won’t be needed.

See example in codesandbox

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} />
}
Enter fullscreen mode Exit fullscreen mode

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.

See example in codesandbox

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]} />
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

See example in codesandbox

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)

Collapse
 
brense profile image
Rense Bakker

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!

Collapse
 
adevnadia profile image
Nadia Makarevich • Edited

No difference between useEffect and useMemo 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? 😬

Collapse
 
brense profile image
Rense Bakker

They made a codesandbox to proof it, by putting a console.log in the useEffect for their useEffect example and putting a console.log in the render function for their useMemo example and then saying: see they both log the same number of times! 😁

Thread Thread
 
adevnadia profile image
Nadia Makarevich

No words 😄😄😄

Collapse
 
devarshishimpi profile image
Devarshi Shimpi

Thank You!!

Collapse
 
leob profile image
leob • Edited

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)

Collapse
 
adevnadia profile image
Nadia Makarevich

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.

Collapse
 
leob profile image
leob

Agreed to all of that, at some point you'll always need to know how stuff works under the hood, also in Vue :)

Collapse
 
jakecarpenter profile image
Jake Carpenter

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.

Collapse
 
chrisczopp profile image
chris-czopp • Edited

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.