Skip to content

Instantly share code, notes, and snippets.

@sidola
Last active October 13, 2023 14:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sidola/dfcd1c8a94a4d87c196300c56a66f499 to your computer and use it in GitHub Desktop.
Save sidola/dfcd1c8a94a4d87c196300c56a66f499 to your computer and use it in GitHub Desktop.

React Rendering Cheat Sheet

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


Terms & Fundamentals

  • Rendering (calculate changes to components)
    • Calling the render() method for a class component
    • Calling the Component(props) function for a functional component
  • 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 setters
    • useReducer dispatches
  • Other:
    • Calling ReactDOM.render(<App>) again (which is equivalent to calling forceUpdate() on the root component)

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.

Skipping renders

Function components

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>
    )
}

Class components

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 returning false.
  • Extending React.PureComponent - If we extend this, we get a shallow-prop comparator for free.

React Context

A context solves two problems for us:

  1. How do we pass props deep into the hierarchy without prop drilling?
  2. How do we only update Parent and GrandChild in a Parent -> 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:

  1. Memoize Child
  2. Move your context provider a level up and use props.children in Parent

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>
    )
}

Context + React.memo

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.

Redux

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment