(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 π).
React: how I understand Virtual DOM through useState
Jessica Cecilia Budianto γ» Oct 11
Table of Contents
- Initial experiment: compare the state change in parent and child
- Second experiment: profile the re-render
- So, what's the verdict and what's next?
- Further Readings
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>
);
}
Now let's start to visit the components one by one.
export default function Title() {
console.log('Rendering title');
return <h1>TITLE HERE</h1>
}
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>
)
}
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>
}
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.
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?
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 fromApp
, it is only normal if the state change inChild
will only re-renderChild
right? After all onlyChild
knows the existence ofcount
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>
);
}
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>
)
}
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>
);
}
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 withactualDuration
π 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.
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>
);
}
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>
)
}
And here is the result.
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! π
Top comments (0)