React reconciliation: how it works and why should we care
Nadia Makarevich
Posted on May 18, 2023
Table of content
- The mystery: conditional rendering of components
- React reconciliation algorithm
- Reconciliation and state update
- The answer to the mystery
- Reconciliation and arrays
- Reconciliation and "key"
- Using "key" to force reuse of an existing element
- Why we don't need keys outside of arrays?
- Dynamic arrays and normal elements together
- Why we can't define components inside other components
This article is available in video format. Fewer details, but nice animations and voice instead of letters 😉
Every time I think I surely know everything about how React renders components, the universe finds a way to surprise me. Something as innocent as a simple if
statement can completely blow your mind. Happened again just this Saturday when I was randomly browsing through React documentation as a way to procrastinate over what I was actually supposed to do, according to the "Weekend ToDo" list on my wall. Yet another moment of "wait a second, that can't be right" resulted in yet another weekend plan disappearing into the void, followed by yet another deep dive investigation and article. Who needs those ToDo things anyway? It's not like they were important, right?
So let's take a look at that mini-mystery that destroyed my weekend commitments, understand how the reconciliation algorithm works, and answer a few other questions as a result. Have you ever wondered why if we create components inside other components, they will re-mount themselves on every re-render? Or why we don't need the "key" attribute outside of lists, even if we're rendering the same data with the same behavior? It's time to find out!
The mystery: conditional rendering of components
First, the mystery itself.
Imagine you render a component conditionally. If "something" - show me this component. Otherwise, show me something else. For example, I'm developing a "sign up" form for my website, and part of that form is whether those who sign up are a company or just a regular human fellow. For some crazy tax purposes. So I want to show the "Company Tax ID" input field only after the user clicks "yes, I'm signing up as a company" checkbox. And for people, show a text "You don't have to give us your tax ID, lucky human."
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
... // checkbox somewhere here
{isCompany ? (
<Input id="company-tax-id-number" placeholder="Enter you company ID" ... />
) : (
<TextPlaceholder />
)}
</>
)
}
What will happen here from re-renders and mounting perspective if the user actually claims that they are a company and the value isCompany
changes from the default false
to true
?
No surprises here, and the answer is pretty intuitive: the Form
component will rerender itself, the TextPlaceholder
component will be unmounted, and the Input
component will be mounted. If I flip the checkbox back, the Input
will be mounted again, and the TextPlaceholder
will be unmounted.
From a behavioral perspective, all of this means that if I type something in the Input
, flip the checkbox, and then flip it back - whatever I typed there will be lost. Input
has its own internal state to hold the text, which will be destroyed when it unmounts and will be re-created from scratch when it mounts back.
But what will happen if I actually need to collect the tax ID from people as well? And the field should look and behave exactly the same, but it will have a different id
, different onChange
callback, and other different settings. Naturally, I'd do something like this:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
... // checkbox somewhere here
{isCompany ? (
<Input id="company-tax-id-number" placeholder="Enter you company Tax ID" ... />
) : (
<Input id="person-tax-id-number" placeholder="Enter you personal Tax ID" ... />
)}
</>
)
}
What will happen here now?
The answer is, of course, again pretty intuitive and exactly as any sensible person would expect 😅. The unmounting doesn't happen anymore! If I type something in the field and then flip the checkbox, the text is still there! React thinks that both of those inputs are actually the same thing, and instead of unmounting the first one and mounting the second one, it just re-renders the first one with the new data from the second one.
Here is the code sandbox to play around with it. Open the console to see how mounting happens when you flip the checkbox back and forth in the first example but never happens in the second.
If you're not surprised by this at all and can without hesitation say: "Ah, yeah, it's because of [the reason]" then wow, can I get your autograph? For the rest of us, who got an eye twitch and a mild headache because of this behavior, it's time to dive into React's reconciliation process to get the answer.
React reconciliation algorithm
It's all because of the DOM and how slow adding or removing anything to it is. When we write anything in React, our ultimate goal is to make React transform whatever we give to it into DOM elements on the screen with appropriate data. When we write code like this:
const Input = ({ placeholder }) => {
return <input type="text" id={id} />
}
// somewhere else
<Input placeholder="Input something here" />
we expect React to add the normal HTML input
tag with placeholder
set in the appropriate place in the DOM structure. And if we change the placeholder
value in the React component, we expect React to update our DOM element with the new value and to see that value on the screen. Ideally instantly. So we can't just remove the previous input, and append a new one with the new data. That would be terribly slow. Instead, we need to identify that input's DOM element and just update its attributes. If we didn't have React, we'd have to do something like this:
const input = document.getElementById('input-id');
input.placeholder = "new data";
Luckily, we don't need to do that manually anymore. React handles it for us. And it does so by creating and modifying what we sometimes call the "Virtual DOM". This Virtual DOM is just a giant object with all the components that are supposed to render, all their props, and their children - which are also objects of the same shape. Just a tree. What the Input
component from the example above should render will be represented as something like this:
{
type: "input", // type of element that we need to render
props: {...}, // input's props like id or placeholder
... // bunch of other internal stuff
}
If our Input
component was rendering something more:
const Input = () => {
return <>
<label htmlFor={id}>{label}</label>
<input type="text" id={id} />
</>
}
then label
and input
from React perspective would be just an array of those objects:
[
{
type: 'label',
... // other stuff
},
{
type: 'input',
... // other stuff
}
]
DOM elements like input
or label
will have their "type" as strings, and React will know to convert them to the DOM elements directly. But if we're rendering React components, they are not directly correlated with DOM elements, so React needs to work around that somehow.
const Component = () => {
return <Input />
}
In this case, it will put the component's function as the "type." It just grabs the entire function that we know as the Input
component and puts it there:
{
type: Input, // reference to that Input function we declared earlier
... // other stuff
}
And then, when React gets a command to mount the app (initial render), it iterates over that tree and does the following:
- If the "type" is a string, it generates the HTML element of that type.
- If the "type" is a function (i.e., our component), it calls it and iterates over the tree that this function returned.
Until it eventually gets the entire tree of DOM nodes that are ready to be shown. A component like this, for example:
const Component = () => {
return (
<div>
<Input placeholder="Text1" id="1" />
<Input placeholder="Text2" id="2" />
</div>
)
}
will be represented as:
{
type: 'div',
props: {
// children are props!
children: [
{
type: Input,
props: { id: "1", placeholder: "Text1" }
},
{
type: Input,
props: { id: "2", placeholder: "Text2" }
}
]
}
}
Which will on mounting resolve into HTML like this:
<div>
<input placeholder="Text1" id="1" />
<input placeholder="Text2" id="2" />
</div>
Finally, when everything is ready, React appends those DOM elements to the actual document
with JavaScript's appendChild command.
Reconciliation and state update
After that, the fun begins. Suppose one of the components from that tree has state, and its update was triggered (re-render is triggered). React needs to update all the elements on the screen with the new data that comes from that state update. Or maybe add or remove some new elements.
So it begins its journey through that tree again, starting from where the state update was initiated. If we have this code:
const Component = () => {
// return just one element
return <Input />
}
React will understand that the Component
returns this object when rendered:
{
type: Input,
... // other internal stuff
}
It will compare the "type" field of that object from "before" and "after" the state update. If the type is the same, the Input
component will be marked as "needs update," and its re-render will be triggered. If the type has changed, then React, during the re-render cycle, will remove (unmount) the "previous" component and add (mount) the "next" component. In our case, the "type" will be the same since it's just a reference to a function, and that reference hasn't changed.
If I were doing something conditional with that Input
, like returning another component:
const Component = () => {
if (isCompany) return <Input />
return <TextPlaceholder />
}
then, assuming that the update was triggered by isCompany
value flipping from true
to false
, the objects that React will be comparing are:
// Before update, isCompany was "true"
{
type: Input,
...
}
// After update, isCompany is "false"
{
type: TextPlaceholder,
...
}
You guessed the result, right? "Type" has changed from Input
to TextPlaceholder
references, so React will unmount Input
and remove everything associated with it from the DOM. And will mount the new TextPlaceholder
component and append it to the DOM for the first time. Everything that was associated with the Input
field, including its state and everything you typed there, is destroyed.
The answer to the mystery
Now let's take a look at the mysterious code from the beginning with the new knowledge:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
... // checkbox somewhere here
{isCompany ? (
<Input id="company-tax-id-number" placeholder="Enter you company Tax ID" ... />
) : (
<Input id="person-tax-id-number" placeholder="Enter you personal Tax ID" ... />
)}
</>
)
}
If the isCompany
variable changes from true
to false
here, which objects will be compared?
Before, isCompany
is true
:
{
type: Input,
... // the rest of the stuff, including props like id="company-tax-id-number"
}
After, isCompany
is false
:
{
type: Input,
... // the rest of the stuff, including props like id="person-tax-id-number"
}
From the React perspective, the "type" hasn't changed. Both of them have a reference to exactly the same function: the Input
component. The only thing that has changed, thinks React, are the props: id
changed from "company-tax-id-number"
to "person-tax-id-number"
, placeholder changed, and so on.
So, in this case, React does what it was taught: it just takes the existing Input
component and updates it with the new data. I.e., re-renders it. Everything that is associated with the existing Input
, like its DOM element or state, is still there. Nothing is destroyed. This results in the behavior that we've seen: I type something in the input, flip the checkbox, and the text is still there.
This behavior is not necessarily bad. I can see a situation where re-rendering instead of re-mounting is exactly what I would want. But in this case, I'd probably want to fix it and ensure that inputs are reset and re-mounted every time I switch between them: they are different entities from the business logic perspective, so I don't want to re-use them.
There are at least two easy ways to fix it: arrays and keys.
Reconciliation and arrays
Until now, I've only mentioned the fact of arrays in that data tree. But it's highly unlikely that anyone can write a React app where every single component returns only one element. We have to talk about arrays of elements and how they behave during re-renders in more detail now. Even our simple Form
actually has an array:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
... // checkbox somewhere here
{isCompany ? (
<Input id="company-tax-id-number" ... />
) : (
<Input id="person-tax-id-number" ... />
)}
</>
)
}
It returns a Fragment (that thing: <>...</>
) that has an array of children: there is a checkbox hidden there. The actual code is more like this:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? (
<Input id="company-tax-id-number" ... />
) : (
<Input id="person-tax-id-number" ... />
)}
</>
)
}
During re-render, when React sees an array of children instead of an individual item, it just iterates over it and then compares "before" and "after" elements and their "type" according to their position in the array.
Basically, if I flip the checkbox and trigger the Form
re-render, React will see this array of items:
[
{
type: Checkbox,
},
{
type: Input, // our conditional input
}
]
and will go through them one by one. First element. "Type" before: Checkbox
, "type" after: also Checkbox
. Re-use it and re-render it. Second element. Same procedure. And so on.
Even if some of those elements are rendered conditionally like this:
isCompany ? <Input /> : null
React will still have a stable number of items in that array. Just sometimes, those items will be null
. If I re-write the Form
like this:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? <Input id="company-tax-id-number" ... /> : null}
{!isCompany ? <Input id="person-tax-id-number" ... /> : null}
</>
)
}
it will be an array of always three items: Checkbox
, Input
or null,
and Input
or null.
So, what will happen here when state changes and re-render runs throughout the form?
Before, isCompany
is false
:
[
{ type: Checkbox },
null,
{ type: Input }
]
After, isCompany
is true
:
[
{ type: Checkbox },
{ type: Input },
null
]
And when React starts comparing them, item by item, it will be:
- the first item,
Checkbox
before and after → re-renderCheckbox
- the second item,
null
before andInput
after → mountInput
- third item,
Input
before,null
after → unmountItem
And 💥: magically, by changing the inputs' position in the render output, without changing anything else in the logic, the bug is fixed, and inputs behave exactly as I would expect! See the full example in code sandbox
Reconciliation and "key"
There is another way to fix the same bug: with the help of the "key" attribute.
The "key" should be familiar to anyone who has written any lists in React. React forces us to add it when we iterate over arrays of data:
const data = ["1", "2"];
const Component = () => {
// "key" is mandatory here!
return data.map(value => <Input key={value} />)
}
The output of this component should be clear by now: just an array of objects with the "type" Input
:
[
{ type: Input }, // "1" data item
{ type: Input } // "2" data item
]
But the problem with dynamic lists like that is that they are dynamic. We can re-order them, add new items at the beginning or end, and just generally mess around with them.
And now React faces an interesting task: all components in that array are of the same type. How to detect which one is which? If the order of those items changes:
[
{ type: Input }, // "2" data item now, but React doesn't know that
{ type: Input } // "1" data item now, but React doesn't know that
]
how to make sure that the correct existing element is re-used? Because if it just relies on the order of elements in that array, it will re-use the instance of the first element for the data of the second element, and vice versa. This will result in weird behavior if those items have state: it will stay with the first item. If you type something in the first input field and re-order the array, the typed text will remain in the first input.
This is why we need "key": it's basically React's version of a unique identifier of an element within children's array that is used between re-renders. If an element has a "key" in parallel with "type," then during re-render, React will re-use the existing elements, with all their associated state and DOM, if the "key" and "type" match "before" and "after." Regardless of their position in the array.
With this array, the data would look like this. Before re-ordering:
[
{ type: Input, key: "1" }, // "1" data item
{ type: Input, key: "2" } // "2" data item
]
After re-ordering:
[
{ type: Input, key: "2" }, // "2" data item, React knows that because of "key"
{ type: Input, key: "1" } // "1" data item, React knows that because of "key"
]
Now, with the key present, React will know that after re-render, it needs to re-use an already created element that used to be in the first position. So it will just swap input
DOM nodes around. And the text that we typed in the first element will move with it to the second position. For more examples of how different keys will behave this article might be useful: https://www.developerway.com/posts/react-key-attribute
See the full example in codesandbox
Why does all of this matter for our Form
component and its bug? Fun fact: "key" is just an attribute of an element, it's not limited to dynamic arrays. In any children's array, it will behave exactly the same way. And as we already found out, object definition of our Form
with the bug from the very beginning:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? (
<Input id="company-tax-id-number" ... />
) : (
<Input id="person-tax-id-number" ... />
)}
</>
)
}
has an array of children:
[
{ type: Checkbox },
{ type: Input } // react thinks it's the same input between re-renders
]
All we need to fix the initial bug is to make React realize that those Input
components between re-renders are actually different components and should not be re-used. And if we add a "key" to those inputs, we'll achieve exactly that.
{isCompany ? (
<Input id="company-tax-id-number" key="company-tax-id-number" ... />
) : (
<Input id="person-tax-id-number" key="person-tax-id-number" ... />
)}
Now, the array of children before and after re-render will change.
Before, isCompany
is false
:
[
{ type: Checkbox },
{ type: Input, key: "person-tax-id-number" }
]
After, isCompany
is true
:
[
{ type: Checkbox },
{ type: Input, key: "company-tax-id-number" }
]
Boom, the keys are different! React will drop the first Input
and mount from scratch the second one. State is now reset to empty when we switch between inputs.
Using "key" to force reuse of an existing element
Another fun fact: if we actually needed to reuse an existing element, "key" could help with that as well. Remember this code, where we fixed the bug by rendering the Input
element in different positions in the children array?
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? <Input id="company-tax-id-number" ... /> : null}
{!isCompany ? <Input id="person-tax-id-number" ... /> : null}
</>
)
}
When the isCompany
state variable changes, Input
components will unmount and mount since they are in different positions in the array. But! If I add the "key" attribute to both of those inputs with the same value, the magic happens.
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? <Input id="company-tax-id-number" key="tax-input" ... /> : null}
{!isCompany ? <Input id="person-tax-id-number" key="tax-input" ... /> : null}
</>
From the data and re-renders' perspective, it will now be like this.
Before, isCompany
is false
:
[
{ type: Checkbox },
null,
{ type: Input, key: "tax-input" }
]
After, isCompany
is true
:
[
{ type: Checkbox },
{ type: Input, key: "tax-input" }
null
]
React sees an array of children and sees that before and after re-renders, there is an element with the Input
type and the same "key." So it will think that the Input
component just changed its position in the array and will re-use the already created instance for it. If we type something, the state is preserved even though the Input
s are technically different.
For this particular example, it's just a curious behavior, of course, and not very useful in practice. But I could imagine it being used for fine-tuning the performance of components like accordions, tabs content, or some galleries.
Why we don't need keys outside of arrays?
Let's have a bit more fun with reconciliation, now that the mystery is solved and the algorithm is more or less clear. There are still a few mini-questions and mysteries there. For example, have you noticed that React never forced you to add keys to anything unless you're iterating over an array?
The object definition of this:
const data = ["1", "2"];
const Component = () => {
// "key" is mandatory here!
return <>{data.map(value => <Input key={value} />)}</>
}
and this:
const Component = () => {
// no-one cares about "key" here
return (
<>
<Input />
<Input />
</>
)
}
will be exactly the same, just a fragment with two inputs as a children array:
[
{ type: Input },
{ type: Input },
]
So why, in one case we need a "key" for React to behave, and in another - don't?
The difference is that the first example is a dynamic array. React doesn't know what you will do with this array next re-render: remove, add, or rearrange items, or maybe leave them as-is. So it forces you to add the "key" as a precautionary measure: in case you're messing with the array on the fly.
Where is the fun here, you might ask? Here it is: try to render those inputs that are not in an array with the same "key," applied conditionally:
const Component = () => {
const [isReverse, setIsReverse] = useState(false);
// no-one cares about "key" here
return (
<>
<Input key={isReverse ? 'some-key' : null} />
<Input key={!isReverse ? 'some-key' : null} />
</>
)
}
Try to predict what will happen if I type something in those inputs and toggle the boolean on and off. I'll leave it as homework for you 😅
Check the code out in the codesandbox
Dynamic arrays and normal elements together
If you've read the entire article carefully, there is a possibility that by now you might get a minor heart attack. I certainly had one when I was investigating all of this. Because…
- If dynamic items are transformed into an array of children that is no different than normal elements stuck together
- and if I put normal items after a dynamic array
- and add or remove an item in the array
does it mean that items after this array will always re-mount themselves?? 😱 Basically, is this a performance disaster or not:
const data = ['1', '2'];
const Component = () => {
return (
<>
{data.map((i) => <Input key={i} id={i} />)}
<!-- will this input re-mount if I add a new item in the array above? -->
<Input id="3" />
</>
)
}
Because if this is transformed into an array of three children - the first two are dynamic, and the last one static - it will be. If this is the case, then the definition object will be this:
[
{ type: Input, key: 1 }, // input from the array
{ type: Input, key: 2 }, // input from the array
{ type: Input }, // input after the array
]
And if I add another item to the data
array, on the third position there will be an Input
element with the key="3"
from the array, and the "manual" input will move to the fourth position, which would mean from the React perspective, that it's a new item that needs to be mounted.
Luckily, this is not the case. React is smarter than that 🥵😅.
When we mix dynamic and static elements, like in the code above, React simply creates an array of those dynamic elements and makes that entire array the very first child in the children's array. This is going to be the definition object for that code:
[
// the entire dynamic array is the first position in the children's array
[{ type: Input, key: 1 }, { type: Input, key: 2 }],
{
type: Input // this is our manual Input after the array
}
]
Our manual Input
will always have the second position here. There will be no re-mounting. No performance disaster. The heart attack was uncalled for.
Why we can't define components inside other components
One final interesting thing about reconciliation is worth mentioning here: we can have a definitive answer to why declaring components inside other components is an antipattern.
I.e., when we do this:
const Component = () => {
const Input = () => <input />;
return <Input />
}
the gods of re-renders and re-mounting will be very unhappy. If the parent Component
re-renders itself for any reason, the Input
component that is declared inside will re-mount itself on every re-render. Terrible for performance, lots of potential bugs.
If we look at this code from the reconciliation and definition object perspective, this is what we have in Component
:
{
type: Input,
}
Just an object that has a "type" property that points to a function. However, the function is created inside Component
. It's local to this component and will be re-created with every re-render as a result. So when React tries to compare those types, it will compare two different functions: one before re-render and one after re-render. And you can't compare functions in JavaScript, not like this.
const a = () => {};
const b = () => {};
a === b; // will always be false
As a result, the "type" of that child will be different with every re-render, so React will remove the "previous" component and mount the "next" one. Hence the re-mount.
That is all for today, hope that from now on you'll see only objects instead of React elements and can predict which component will re-render and which will re-mount just by looking at their position in the render output. And next time someone wakes you up in the middle of the night and asks: "why do we need "keys" in React?" you'll be able to hit them in the eye to come up with a good answer. Your colleagues might start using you as a re-renders debugging tool though, so watch out for this one.
The video based on the material in this article is available below. It's less detailed but has nice animations and visuals, so it could be useful to solidify the knowledge.
Recommended read for even more details: https://overreacted.io/react-as-a-ui-runtime/
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.
Posted on May 18, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.