Last updated: 2023-10-13 (React v18)
Personal cheat sheet for react rendering details. Most of the stuff here is sourced from Mark Erikson's great blogpost. Don't trust anything you read here, go read his post instead.
Code examples can be found here: website, repo
- Rendering (calculate changes to components)
- Calling the
render()
method for a class component - Calling the
Component(props)
function for a functional component
- Calling the
- Committing (push changes to the DOM)
These two are most of what we need to know. React first renders our components (we should get involved here), then it diffs and commits any changes to the DOM (we should not get involved here).
To get started, we need something to trigger a rendering phase in React, this can be done in a couple of ways:
- Class components:
this.setState()
this.forceUpdate()
- Function components:
useState
settersuseReducer
dispatches
- Other:
- Calling
ReactDOM.render(<App>)
again (which is equivalent to callingforceUpdate()
on the root component)
- Calling
By default[1], when this happens React will grab the component that issued the event and render it and every component below it in the tree. There is no memoization by default, React doesn't check any props, it will just blindly call render on every component in the tree. — Remember this does not equal committing these new render results to the DOM, as the differ will stop us from doing that, unless the render output has actually changed.
[1] Let's define "default" in this context as using plain function components without any memoization. Class components complicate things.
The only way for a component to control it's own rendering is by using the React.memo()
HoC. This gives us shallow prop comparisons by default, and additionally allows us to define our own comparison function for more control.
In addition to that, we can also control rendering of a component by using it as a props.children
element in another component. As long as the parent doesn't render, we can use the props.children
element in our output without having to re-render it. — Logically this makes sense, since it's impossible for us to have modified that component in any way that would require a re-render.
QUESTION: How does all this interact with props.children(...)
. Surely this has to force the child element to also re-render since we're now modifying props?
ANSWER: When using props.children(...)
we will revert to the normal rendering behavior and must resort to memoization if we want the passed elements to skip their renders.
function Parent(props) {
/*
The Child and GrandChild elements below are both
owned by Parent. If Parent is rendered, both of
these components will have to re-render as well.
*/
return (
<Child>
<GrandChild />
</Child>
)
}
function Child(props) {
/*
The GrandChild element inside `props.children` here
is still owned by Parent, meaning if Child is rendered,
we will not issue a re-render to GrandChild.
Dog on the other hand _is_ owned by us, so that will
have to be re-rendered.
*/
return (
<>
{props.children}
<Dog />
</>
)
}
function GrandChild(props) {
// ...
}
function Dog(props) {
// ...
}
However, there is also another quirk at play here we have to keep in mind. If we mix React.memo
and props.children
, we will not get memoization due to React always creating a new reference when passing props.children
.
QUESTION: What's the workaround here? Can we make the props.children
reference stable? Can we automatically warn if we're mixing memo
and props.children
?
ANSWER: The workaround is to memoize one level higher. So in the example below, we would not only memoize Child
, we'd expand it and include GrandChild
as well.
function GrandChild(props) {
// ...
}
function Child(props) {
// ...
}
const MemoChild = React.memo(Child)
function Parent(props) {
/*
Due to React internals, the `props.children` reference
being passed in here will always be different on each
render. Meaning the memoization we're trying to accomplish
here will not work out of the box.
*/
return (
<MemoChild>
<GrandChild />
</MemoChild>
)
}
We can control how class components render using two mechanisms.
shouldComponentUpdate
- An optional method we can implement, it receives the incoming props and allows us to cancel the render by returningfalse
.- Extending
React.PureComponent
- If we extend this, we get a shallow-prop comparator for free.
A context solves two problems for us:
- How do we pass props deep into the hierarchy without prop drilling?
- How do we only update
Parent
andGrandChild
in aParent
->Child
->GrandChild
relationship?
On the surface it might seem that we get memoization for free when using a context. A naive interpretation is that if Parent
provides a value, and GrandChild
consumes it, React should be smart enough to skip rendering Child
when we update the context value, right?
Wrong. By default, changing a context value in the parent will force a re-render of everything below, regardless of if it subscribes to the context or not.
There are two ways to bypass this default behavior:
- Memoize
Child
- Move your context provider a level up and use
props.children
inParent
In scenario 1 we memoize Child
, therefore it won't render when Parent
updates its context value. GrandChild
however is hooked into React using useContext
, so it will receive its new context value and be forced to render.
const MemoChild = React.memo(Child)
function Parent(props) {
const contextValue = { ... }
return (
<MyContext.Provider value={contextValue}>
{/* Memoized Child, won't be re-rendered on state changes */}
<MemoChild />
</MyContext.Provider>
)
}
function Child(props) {
return (
<GrandChild />
)
}
function GrandChild(props) {
// Is hooked into context, will receive a forced re-render from React
const contextValue = useContext(MyContext)
return (
<div>
{contextValue.a}
</div>
)
}
In scenario 2 we leverage a somewhat unintuitive side-effect of using props.children
. The mental model I find helps the most is to remember that React will only ever re-render things at and below the level of the component that received a state change.
When we use props.children
, whatever element is contained within originates above the level of our component. Therefore it is impossible for our state changes to have affected that component, therefore neither it or any of its descendants will be re-rendered automatically.
Note: None of this holds if we instead use props.children(...)
, in that case we need to rely on memoization again.
function GrandParent() {
return (
<Parent>
{/* Child is now owned by GrandParent */}
{/* Only state changes in GrandParent can force a re-render */}
<Child />
</Parent>
)
}
function Parent(props) {
const contextValue = { ... }
return (
<MyContext.Provider value={contextValue}>
{/* Whatever element is in here is outside our hierarchy */}
{/* and cannot be affected by our state changes */}
{props.children}
</MyContext.Provider>
)
}
function Child(props) {
return (
<GrandChild />
)
}
function GrandChild(props) {
// Is hooked into context, will receive a forced re-render from React
const contextValue = useContext(MyContext)
return (
<div>
{contextValue.a}
</div>
)
}
One more note on Context, when used together with React.memo
, the memoized component will treat Context value changes like prop changes, which means it will re-render only when the reference changes.
In general, we can reason about Redux like we reason about Context. There is some nuance we have to understand about Redux if want to squeeze performance out of it though.
A quick primer on how Redux does things. We have a global state, actions we can dispatch to modify that state, and our components can listen to parts of that state and react to them.
In order to listen to state, we use connect
or useSelector
and provide it with a function that defines which part of the state we're interested in. Redux calls this "subscribing" to state changes.
Now anytime an action is dispatched, Redux runs its reducers and updates its global state. Then it iterates every single subscriber function and passes them the new global state. If the result from the subscription function has changed, Redux knows that component needs to be re-rendered and forces a re-render.
What this means in the end is that we save on overall renders, as Redux can precision target the components that need rendering (keep in mind that all of their children are still getting re-rendered, as per the rules we defined above), BUT at the expense of Redux having to run all the subscriber functions on every single action dispatch.
Simplified; if Redux were Context, it would be like having a Context where we can request contextValue.nested.value
and having our component only render when that nested value was changed.
Optimizing Redux is a whole other topic which mostly invovles memoizing expensive subscribers and optimizing reducers, but the rendering principles are all the same:
- React waits for a state change event
- React renders the component issuing the event and all of its children
- We can tell React to stop rendering at a given point in the hierarchy using the tools outlined above