Advanced React c6
Advanced React c6
Also, we know that if the reference to that object itself changes between
re-renders, then React will re-render this Element if its type remains
the same and the component in type is not memoized with
React.memo .
But this is just the beginning. There are more variables and moving
pieces here, and understanding this process in detail is very important. It
will allow us to fix some very not-obvious bugs, implement the most
performant lists, reset the state when we need it, and avoid one of the
biggest performance killers in React. All in one go. None of it seems
connected at first glance, but all of this is part of the same story: how
React determines which components need to be re-rendered, which
components need to be removed, and which ones need to be added to the
screen.
In this chapter, we'll investigate a few very curious bugs, dive very deep
into how things work under the hood, and in the process of doing so, we
will learn:
Page 91
How React's Diffing and Reconciliation algorithm works.
What happens when a state update is triggered and React needs to
re-render components.
Why we shouldn't create components inside other components.
How to solve the bug of two different components sharing the
same state.
How React renders arrays and how we can influence that.
What is the purpose of the "key" attribute.
How to write the most performant lists possible.
Why we would use the "key" attribute outside of dynamic lists.
The code for this app will look something like this:
return (
<>
{/* checkbox somewhere here */}
{isCompany ? (
<Input id="company-tax-id-number" placeholder="Enter you
company ID" ... />
Page 92
) : (
<TextPlaceholder />
)}
</>
)
}
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:
return (
<>
{/* checkbox somewhere here */}
{isCompany ? (
<Input id="company-tax-id-number" placeholder="Enter you
company Tax ID" ... />
Page 93
) : (
<Input id="person-tax-id-number" placeholder="Enter you
personal Tax ID" ... />
)}
</>
)
}
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.
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.
Page 94
transforms whatever we give to it into DOM elements on the screen with
appropriate data. When we write code like this:
// somewhere else
<Input placeholder="Input something here" />;
In React, we don't have to; it handles it for us. 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:
Page 95
{
type: "input", // type of element that we need to render
props: {...}, // input's props like id or placeholder
... // bunch of other internal stuff
}
[
{
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.
Page 96
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:
Until it eventually gets the entire tree of DOM nodes that are ready to be
shown. A component like this, for example:
Page 97
{
type: 'div',
props: {
// children are props!
children: [
{
type: Input,
props: { id: "1", placeholder: "Text1" }
},
{
type: Input,
props: { id: "2", placeholder: "Text2" }
}
]
}
}
<div>
<input placeholder="Text1" id="1" />
<input placeholder="Text2" id="2" />
</div>
Page 98
So it begins its journey through that tree again, starting from where the
state update was initiated. If we have this code:
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.
Page 99
are:
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 it 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.
Page 100
If we look at this code from the reconciliation and definition object
perspective, this is what our Component returns:
{
type: Input,
}
It's just an object that has a "type" property that points to a function.
However, the function is created inside Component . It's local to it and
will be recreated 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 as we know from Chapter 5.
Memoization with useMemo, useCallback and React.memo, we can't
compare functions in JavaScript, not like this.
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.
Page 101
https://advanced-react.com/examples/06/02
In the associated code example above, you can see how it behaves: the
input component triggers a re-render with every keystroke, and the
"ComponentWithState" is re-mounted. As a result, if you click on that
component to change its state to "active" and then start typing, that state
will disappear.
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" ... />
)}
</>
)
}
Page 102
If the isCompany variable changes from true to false here, which
objects will be compared?
{
type: Input,
... // the rest of the stuff, including props like id="company-
tax-id-number"
}
{
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 simply 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 isn't 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
Page 103
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.
return (
<>
{/*checkbox somewhere here*/}
{isCompany ? (
<Input id="company-tax-id-number" ... />
) : (
<Input id="person-tax-id-number" ... />
)}
</>
)
}
Page 104
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? (
<Input id="company-tax-id-number" ... />
) : (
<Input id="person-tax-id-number" ... />
)}
</>
)
}
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.
Page 105
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:
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? <Input id="company-tax-id-number" ... /> : null}
{!isCompany ? <Input id="person-tax-id-number" ... /> : null}
</>
)
}
So, what will happen here when the state changes and re-render runs
throughout the form?
And when React starts comparing them, item by item, it will be:
Page 106
And voila! 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!
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:
[
{ type: Input }, // "1" data item
{ type: Input }, // "2" data item
];
But the problem with dynamic lists like this is that they are, well,
dynamic. We can re-order them, add new items at the beginning or end,
and generally mess around with them.
Page 107
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.
Page 108
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.
Page 109
Interactive example and full code
https://advanced-react.com/examples/06/04
const data = [
{ id: 'business', placeholder: 'Business Tax' },
{ id: 'person', placeholder: 'Person Tax' },
];
const InputMemo = React.memo(Input);
const Component = () => {
// array's index is fine here, the array is static
return data.map((value, index) => (
<InputMemo
key={index}
placeholder={value.placeholder}
/>
));
};
Page 110
If the re-render of the Parent is triggered, none of the InputMemo
components will re-render: they are wrapped in React.memo , and the
key for any of the items hasn't changed.
With dynamic arrays, it's a bit more interesting, and this is where the
key plays a crucial role. What will happen here if what triggered the re-
render is the re-ordering of that array?
If we just use the array's index as a key again, then from React's
perspective, the item with the key="0" will be the first item in the
array before and after the re-render. But the prop placeholder will
change: it will transition from "Business Tax" to "Person Tax." As a
result, even if this item is memoized, from React's perspective, the prop
on it changed, so it will re-render it as if memoization doesn't exist!
Page 111
The fix for this is simple: we need to make sure that the key matches
the item it identifies. In our case, we can just put the id there:
If the data has nothing unique like an id , then we'd need to iterate over
that array somewhere outside of the component that re-renders and add
that id there manually.
In the case of our inputs, if we use the id for key , the item with the
key="business" will still have the prop placeholder="Business
Page 112
Tax," just in a different place in the array. So React will just swap the
associated DOM nodes around, but the actual component won't re-
render.
And exactly the same story happens if we were adding another input at
the beginning of the array. If we use the array's index as key , then
the item with the key="0" , from React's perspective, will just change
its placeholder prop from "Business Tax" to "New tax"; key="1"
item will transition from "Person Tax" to "Business Tax". So they both
will re-render. And the new item with the key="2" and the text
"Person Tax" will be mounted from scratch.
Page 113
And if we use the id as a key instead, then both "Business Tax" and
"Person Tax" will keep their keys, and since they are memoized, they
won't re-render. And the new item, with the key "New tax", will be
mounted from scratch.
Page 114
Interactive example and full code
https://advanced-react.com/examples/06/05
Page 115
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? (
<Input id="company-tax-id-number" ... />
) : (
<Input id="person-tax-id-number" ... />
)}
</>
)
}
[
{ 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. 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.
Page 116
[
{ type: Checkbox },
{
type: Input,
key: 'person-tax-id-number',
},
];
[
{ type: Checkbox },
{
type: Input,
key: 'company-tax-id-number',
},
];
Voila, 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.
Page 117
// grab the current url from our router solution
const { url } = useRouter();
// I want to reset that input field when the page URL changes
return <Input id="some-id" key={url} />;
};
But be careful here, though. It's not just "state reset" as you can see. It
forces React to unmount a component completely and mount a new one
from scratch. For big components, that might cause performance
problems. The fact that the state is reset is just a by-product of this total
destruction.
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{isCompany ? <Input id="company-tax-id-number" ... /> : null}
{!isCompany ? <Input id="person-tax-id-number" ... /> : null}
</>
)
}
Page 118
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.
[
{ type: Checkbox },
null,
{
type: Input,
key: 'tax-input',
},
];
[
{ 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
Page 119
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.
Page 120
);
};
and this:
will be exactly the same, just a fragment with two inputs as a children
array:
The difference is that the first example is a dynamic array. React doesn't
know what you will do with this array during the 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:
Page 121
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.
does it mean that items after this array will always re-mount
themselves?? Basically, is this code a performance disaster or not?
Page 122
<>
{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" />
</>
);
};
[
{ 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. Phew... 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
Page 123
[
{ 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.
Key takeaways
Ooof, that was a long chapter! Hope you had fun with the investigation
and the mysteries and learned something cool while doing so. A few
things to remember from all of that:
Page 124
remove), and especially important if those elements are wrapped
in React.memo .
We can use the key outside of dynamic arrays as well to force
React to recognize elements at the same position in the array with
the same type as different. Or to force it to recognize elements at
different positions with the same type as the same.
We can also force unmounting of a component with a key if that
key changes between re-renders based on some information (like
routing). This is sometimes called "state reset".
Page 125