DEV Community

Cover image for React: should state live in parents?
Jessica Cecilia Budianto
Jessica Cecilia Budianto

Posted on • Originally published at linkedin.com

React: should state live in parents?

(With that cliche sound of N*tfl*x series opening) Previously in my writing...

Yea yea I wrote about Virtual DOM, but that's not what I meant. What I will write today is related with what I wrote in Component Scoping in my previous article. If you haven't read it or in case you forgot already, you can check it here (and boost my views too 😌).

Table of Contents

Initial experiment: compare the state change in parent and child

So actually, I have been playing around with useState, doing some tiny experiments. My previous article was actually one of the insights I got from these experiments, and I decided to correlate it with my understanding about Virtual DOM. So now you know that I didn't just understand Virtual DOM last month ✌️

Last time, I placed the state in the Child component. This time, I tried to centralize all states in parent instead. To give you the big picture , here is how the App looks like.

import { useState } from 'react';
import Child from './Child';
import Title from './Title';
import Button from './Button';

export default function App() {
  const [shown, setShown] = useState(false);
  const [shown2, setShown2] = useState(false);

  return (
    <div>
        <Title/>
        <Child order={1} shown={shown} />
        <Child order={2} shown={shown2} />
        <Button order={1} onClick={() => setShown(prev => !prev)}/>
        <Button order={2} onClick={() => setShown2(prev => !prev)}/>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now let's start to visit the components one by one.

export default function Title() {
    console.log('Rendering title');
    return <h1>TITLE HERE</h1>
}
Enter fullscreen mode Exit fullscreen mode

Yea very simple right for Title? Just a mere heading and a console.log to inform when this component is rendered.

import { useState } from "react";

export default function Child ({ order, shown }) {
    const [count, setCount] = useState(0);

    console.log('Render child', order)

    return (
        <div onClick={() => setCount(prev => prev + 1)}>
            CHILD {order} {String(shown)} count {count}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

The Child component receives 2 props:

  • order as the identifier of the component
  • shown as the state that will be changed from its parent

Aside from the passed props, I also add a local state count for comparison later.

export default function Button ({ order, onClick }) {
    console.log('Render button', order)
    return <button onClick={onClick}>BUTTON {order}</button>
}
Enter fullscreen mode Exit fullscreen mode

Lastly there's also this Button component with passed onClick which will trigger the change in Child, another order as identifier, and console.log.

Now let's see the result here.

Aside from value change in Child 1, clicking the Button 1 triggers console log for Title, Child 1, Child 2, Button 1, and Button 2. Similar things happened when clicking Button 2.

It's expected right that Child 1 is re-rendered? Since it accepts props from its parent and the value changes, of course the component will be re-rendered

What about Button 1? Sure it's clicked to trigger change in Child 1 and it accepts props too. But the value doesn't change right, so is it expected?

What about Child 2 and Button 2 also accept props but the value don't even change. Are they expected to get re-rendered too?

And the weirdest one is for Title which doesn't accept any props from its parent, yet it still gets re-rendered.

Now what about the state that only exists in Child component? Since its parent doesn't aware of their existence, the parent should not get re-rendered right?

Aside from value change in Child 1, clicking Child 1 only triggers console for Child 1. Similar thing happened when clicking Child 2

And yep only the Child itself that is re-rendered. In fact this is already spoiled in my previous article 😂 or to quote it again:

By separating Child component from App, it is only normal if the state change in Child will only re-render Child right? After all only Child knows the existence of count state. And yes it is.

I have heard a saying that props/state change is the one that causes re-render. Not wrong, of course, but there seems to be a caveat.

Sure, props/state change triggers re-render, but it seems not to be the only reason. In fact, the parent component re-render will also re-render its children.

I know some of you will be like:
🗣️ That's why you should pay attention to the documentation

And does it exist in React documentation? Yes, it does. I don't know if you guys can find the more specific statement about this somewhere in the docs, but here is the one I found in useState caveats

I know that there are a lot of optimization done by React like memoization, but it seems that re-rendering its children after props / state change is remain the component default behavior.

And this actually leads us to one of the most famous performance-related questions:

Is re-render a bad thing?

Second experiment: profile the re-render

In general, labeling something as 'good' or 'bad' is subjective, right? Something that's considered 'good' can be considered 'bad' instead by others, depends on circumstances and preferences. Just like instant noodle that's not healthy, but healthier for your budget 🍜

Goodness how random I am 🤣 but what I'm saying is, we can't judge anything without 'proof'. Only after we prove it, we can take decision whether to improve it or let it slide for higher priorities.

So let's start by separating the code in App into component called Parent as shown below.

import { useState } from 'react';
import Child from './Child';
import Title from './Title';
import Button from './Button';

export default function Parent() {
  const [shown, setShown] = useState(false);
  const [shown2, setShown2] = useState(false);
  const [count, setCount] = useState(0);

  return (
    <div>
        <Title/>
        <Child
            order={1}
            shown={shown}
            count={count}
            setCount={setCount}
        />
        <Child order={2} shown={shown2} />
        <Button order={1} onClick={() => setShown(prev => !prev)}/>
        <Button order={2} onClick={() => setShown2(prev => !prev)}/>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Doesn't look exactly the same like before right? I deliberately put out the count state into Parent and pass them to Child 1. I also adjusted the Child component as shown below.

export default function Child ({
    order,
    shown,
    count = 0,
    setCount = () => {}
}) {
    console.log('Render child', order)

    return (
        <div onClick={() => setCount(prev => prev + 1)}>
            CHILD {order} {String(shown)} count {count}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Now for the App, aside from importing the Parent I wrap it with Profiler component which is used to measure the rendering performance. Don't worry if you have never heard of this, you can check the documentation later attached at the end of this article. You'll see how this works after this.

import { Profiler } from "react";
import Parent from "./Parent";

export default function App() {
  const handleRender = (
    _id,
    phase,
    actualDuration,
    baseDuration
  ) => {
    console.log(
      'RERENDERING',
      phase,
      'ACTUAL',
      actualDuration,
      'BASE',
      baseDuration
    );
  }

  return (
    <Profiler id="Parent" onRender={handleRender}>
      <Parent/>
    </Profiler>
  );
}
Enter fullscreen mode Exit fullscreen mode

To give you a brief explanation of parameters in onRender:

  • phase indicates whether the component is just mounted or there has been state change either in the component or its children
  • actualDuration is, to make it short, how long the whole wrapped component is rendered.
  • baseDuration is the one that I bet all of you gets confused with actualDuration 😂 same guys same. Now this one is the estimation how long the component will get rendered.

Oh and also, please ignore the console.log that is printed twice per component re-render. The component is still only gets rendered once per re-render. This is how it looks like.

State in parent performance

Either clicking on Child 1 or Button 1 trigger the re-render for the whole components. On mounted it already takes 8ms, longer than estimated 5.5ms to render the whole components. The re-render takes shorter time (1 - 3ms) than the initial render as mentioned in React Profiler documentation.

Now let's put back the count state into the Child again. To avoid confusion, here is how Parent component become.

import { useState } from 'react';
import Child from './Child';
import Title from './Title';
import Button from './Button';

export default function Parent() {
  const [shown, setShown] = useState(false);
  const [shown2, setShown2] = useState(false);

  return (
    <div>
        <Title/>
        <Child order={1} shown={shown} />
        <Child order={2} shown={shown2} />
        <Button order={1} onClick={() => setShown(prev => !prev)}/>
        <Button order={2} onClick={() => setShown2(prev => !prev)}/>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the Child component looks like this again.

import { useState } from "react";

export default function Child ({ order, shown }) {
    const [count, setCount] = useState(0);

    console.log('Render child', order)

    return (
        <div onClick={() => setCount(prev => prev + 1)}>
            CHILD {order} {String(shown)} count {count}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

And here is the result.

State in child performance

It takes 7.3ms to perform the initial render for the whole components, but I don't think we can consider the 0.7ms difference is the improvement effect since it's still a small difference. This can be biased by the cache and stuffs anyway.

But what's actually significant is it only takes 0.8ms to render the change on clicking Child component. While it still takes the usual 1 - 3ms to render the change on clicking Button component.

So, what's the verdict and what's next?

Now we know that it definitely takes shorter time to re-render only the child than the whole component, which, is of course a good thing.

Aside from performance, there's actually no use right to put count state in Parent component? If only Child component is the one that needs it, why putting it in its parent? There is no need for its parent component to know the existence of this state unless they also consume it. So in a way, this also encourages us to maintain better architecture right?

But come to think of performance again, 3ms to 0.8ms is not that huge difference right? They are still in millisecond anyway, so small that user won't directly feel the difference. But what about for a page with more complex component structures and more complex data? Would that still be the same?

Lots of more questions right instead of answer? 😂 I didn't plan to give you the exact answer when writing this. But hopefully I manage to intrigue your curiosity to learn deeper, as I plan to research more about re-render in my next writings.

Until then, see ya! 👋

Further Readings

Top comments (0)