Lessons Learned from "Advanced React"
Last updated at
You can purchased the book at Advanced React and I highly recommend it to anyone who is interested in react performance. The book goes deep into the underlying workings of React yet remains very approachable.
Diffing, Reconciliation and Children as Props
With the following JSX
The underlying representation is
When this component needs to re-render, react compares the object from “before” and “after” the state update, if an entry is the same (by reference) before and after the update, it won’t re-render. If an entry changes, there are some variations
-
if the
type
is the same and the props changes, theInput
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.
Let’s say we have a different component that does conditional rendering
then, assuming that the update was triggered by isCompany
value
flipping from true to false, the objects that React will be comparing
are:
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.
This diffing mechanism explains several things
- When the parent needs to re-render, a child component also needs to re-render (assuming no memoization) even if it does not need the state, with the following component
While Child
does not touch the state, <Child />
is just a syntax
sugar for creating the object
and objects are compared by reference, so in React’s world, the two
objects before and after render are always different and Child
needs
to re-render.
- However, if an entry is created outside of a rendering cycle of
Parent
, then the re-rendering ofParent
does not recreate the entry’s jsx object, and React will not re-render that entry. This is often the case with children props
Now the jsx object for Child
is created outside of the parent,
Parent
simply passes down the object reference, so it remains the same
inside the Parent
rendering cycle.
Similar render props and children
props won’t re-render if it does not
rely on the parent state. Remember, children nesting are just sytnax
sugar for an explicit children
prop.
- Nest component definitions is harmful because functions are also
compared by reference. If you define a child component directly inside
the parent, that child component will be seen as a different
type
on every render and go through unmount-remount everytime.
The component returns
Force Un-mounting
As mentioned before, react uses type
to determine if a component at
the particular position in the tree can be reused.
Consider the following example
If we toggle isCompany
from false
to true
, react will compare the
following tree
Before
After
Checkbox
is re-rendered as usual. The critical piece is that the
element Input
is considered a component that simply needs a prop
update, because type
refers to the function component. As a result, if
the input already contains certain HTML state, such as an existing
input, it will be persisted after we toggle the state, because react
only updates the prop without un-mounting. This is not necessarily bad
but something to consider when designing the UX.
If we do want to re-mount Input
after toggling, we could consider
changing the JSX structure
Before, isCompany
is false
:
After, isCompany
is true
:
Here, react will decide to mount Input
for the second item, and
unmount Input
for the third item.
This technique can be used reversely. With the same JSX structure, what if we indeed want to keep the state and reused the same input, we can just use the same key:
Before
After
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.
Keys
There is an alternative way to force mounting by using the key
attribute.
So, the root of our problem is that react uses type
to distinguish
components, if an element has a key
attribute, it will be used as an
additional identifier: an element will be considered to be of the same
sort if it has the same type
and key
before and after render. To
solve our problem using key
:
Before
After
They are now considered different components.
The Problem of index-based key
When rendering a static list, it’s usually fine to use the array index
as key
. But, if the list item can be reordered, index-based keys will
be problematic, consider the following example:
The tree is
If you reorder the two Input
, the tree becomes
Since the keys are index-based, they don’t change after the reorder.
While we know that the two items are swapped, react sees the same type
and key
and will reuse state. So if you type something in the first
input and then swap, the text still exist in the first input (by
position).
This won’t happen if we are using a real id that uniquely identifies the input.
Before
After
Key is no longer the same for both positions. So react will bring the
existing state to the correct Input
and swap the two DOM nodes.
Split providers to improve context performance
A typical setup with React context is
The problem with this is that as long as the state isNavExpanded
is
updated, the value
object is also created as a new object, thus
triggering a re-render from the downstream components, even if it only
needs access to the setIsNavExpanded
action.
This also has the problem that if the parent of NavigationController
re-renders often, any child of NavigationControler
will also have to
re-render, e.g:
Layout
re-renders on every scroll, triggering a re-render of
NavigationController
, it recreates the value
object, thus any
subscriber to it will re-render.
Memoization can help solve problem 2 but not problem 1. Consider the following changes
With a memoized value
, context subscribers won’t re-render if Layout
updates, but the state isNavExpanded
and the action toggle
is still
coupled together such that even if a component only needs the action, it
still re-renders when the state changes.
Solution: two providers
This way the actions do not depend on the state, and two useContext
are also independent.
An Alternative to Spliting Providers with HOC
AnyComponentMemo
is wrapped in React.Memo
, the open
callback is
memoized in the context itself, so AnyComponentMemo
wont’ re-render if
only the context state changes.
Maintaining Stateful Closures with Ref
When passing callbacks to child components, we often face the dilemma
that the callback needs access to some state, so you must put the state
in the corresponding useCallback
’s dependency array. Now the callback
is recreated every time the state changes, which triggers a re-render of
the callback’s consumer component and break its memoization.
Solution: create the callback only once, inside the callback refer to a
ref
that is updated with the latest state.
Implement debouncing
There are two variants of debouncing in react
- debounce the state, and only calls the function when the debounced state is updated. This is a simpler approach
- debounce the function (what the book teaches). With this approach we
can rely on
debounce
from lodash (or similar utility libraries), the gist is that we only create the debounced function once, and use the ref trick to make sure it always has the latest state.
useLayoutEffect
On a 60 FPS machine, browser repaints the screen approximately every 16ms. When a new task is pulled from the task queue, if the task takes longer than 16ms, the browser will wait until the task is finished before repainting the screen. In this code
useLayoutEffect
and the return statement are considered as one
task. In other words useLayoutEffect
runs synchronously so every UI
update will be in sync with the useLayoutEffect
side effect. In
contrast, useEffect
runs asynchronously and is executed after
repainting.
As a result, any side effect in useLayoutEffect
will be processed
before the browser repaints the screen, at the cost of a slower UI
update. If the useLayoutEffect
task takes longer than 16ms, the UI
will become unresponsive.
See the react official
example for
positioning a tooltip element with useLayoutEffect
.
To do this, you need to render in two passes:
-
Render the tooltip anywhere (even with a wrong position).
-
Measure its height and decide where to place the tooltip.
-
Render the tooltip again in the correct place.
Even though we have the first, incorrect render, the user won’t see it
because the browser will repaint the screen after the useLayoutEffect
task is finished, and by that time the positioning is finished.
React Portal
Events from portals propagate according to the React tree rather than
the DOM tree. For example, if you click inside a portal, and the portal
is wrapped in <div onClick>
, that onClick handler will fire. If this
causes issues, either stop the event propagation from inside the portal,
or move the portal itself up in the react tree.
In contrast, non-react, standard dom events will propagate according to
the DOM tree. If you listen for the event via el.addEventListener
, it
won’t fire if the event is triggered by a portal element.
A gotcha is that onSubmit
on a form element is a standard DOM event,
so if you have a portal inside a form, the submit event won’t be
triggered by the portal element.
Data Fetching in useEffect
with Race Conditions
The gist is that the result callback in fetchBio
captures the “stale”
variable ignore
, which is a reference that points to true
if the
component is unmounted. So although we create a new ignore
variable
for each render, the callback still cancels correctly because it access
the previous scope.