Avoiding unnecessary renders with React context

Before you replace Redux's <Provider> component with React's new context API, there's a thing or two that you should know about performance.

James K Nelson

Hi! I've been playing with JavaScript for over half my life, and am building Frontend Armory to help share what I've learned along the way!

Read more by James

Earlier this year, the React team introduced an improved context API. The new API made a big splash: it looks nicer, uses render functions, and to top it all off, it’s finally official. But one of the most important changes is hidden under the hood.

Until the new context API was released, React made it difficult to use context with PureComponent or shouldComponentUpdate. To solve this problem, many developers turned to state management tools like Redux. But with the recent update, context now works great with PureComponent and shouldComponentUpdate. So people have been asking the question: does context replace Redux?

To cut a long story short, context can replace Redux, but it won’t do everything that Redux does. In particular, it won’t perform any of the optimizations that Redux gives you for free. So before you replace Redux with context, there’s a thing or two that you should know about performance.

Oops, I re-rendered the entire app #

Context API Reference

This guide assumes that you already know the basics of the new context API. If you’d like to brush up on the basics, see React’s official documentation.

One thing about data provided by context is that it’s usually global; you want it to be available everywhere. This means that your <Context.Provider> components will be near the top of your component tree. And if you’re not careful, this can cause your entire app to re-render over and over again – a performance nightmare.

To get some intuition for this, let’s do a quiz.

The editor below contains a demo of a small app that uses context to implement push-state routing. Within the app, I’ve added six components that log messages to the console whenever they’re rendered.

Your task is to decide which of these console.log() statements will be executed when you click each of the two links.

Once you’ve decided on your answer, click the “Home” and “Browse” links to check!

You can click the fullscreen icon at the top right of the editor to make it easier to scroll within the code.

App.js
main.js
index.html
New
Build In Progress

    As you can see, the answer is that:

    Everything other than the unmounted route is re-rendered on each click!

    So what’s going on here?

    Renders take time #

    Whenever you click a <Link> component in the above example, the navigate() method of the <App> component will be called. This causes the <App> component’s state to update, which in turn causes it to re-render, which will cause its children to re-render, which will cause the children of those children to re-render, and so on.

    In the above example, you need to re-render the entire app just to update the value prop of the <NavigationContext.Provider> component. And while this app re-renders instantly, re-rendering even smaller real-world apps can cause a perceivable delay. You can get a feel for this by trying to use this form that renders a large list on each keystroke…

    App.js
    TasksView.js
    main.js
    index.html
    New
    Build In Progress

    Now it should be said that causing a delay after some action isn’t a problem in and of itself. Users will expect a delay after they click a link – so the above pattern would be fine if you only want to provide navigation state via context.

    But what if you want to provide all the state via context?

    To render or not to render #

    The new context API’s <Context.Provider> components is a bit smarter about re-rendering than your average component. In fact, there is only a single scenario in which it will re-render its children:

    <Context.Provider> will only re-render if its children prop does not share reference equality with its previous children prop.

    Notably, <Context.Provider> will not re-render if its value changes while its children stay the same. In this case, only the associated <Context.Consumer> components will re-render. This makes it possible to update a context’s consumers without requiring that the entire app be re-rendered.

    But wait a minute… in the first example, the provider’s children did stay the same, right? After all, there’s no interpolated content or props within this provider’s children?

    <NavigationContext.Provider value={this.state}>
      <Log name="NavigationProvider" />
      <AppLayout>
        <Log name="AppLayout" />
        <Route href="/">
          <Log name="home Route" />
          <h1>Welcome to Frontend Armory!</h1>
        </Route>
        <Route href="/browse/">
          <Log name="browse Route" />
          <h1>Browse courses and guides</h1>
        </Route>
      </AppLayout>
    </NavigationContext.Provider>
    

    When I first saw the above example, it took me a while to realize that the above provider’s children do actually change between renders. To understand why, you need to keep in mind how JSX works. So let me transform the above JSX to raw JavaScript:

    React.createElement(NavigationContext.Provider, { value: this.state },
      React.createElement(Log, { name: "NavigationProvider" }),
      React.createElement(AppLayout, null,
        React.createElement(Log, { name: "AppLayout" }),
        React.createElement(Route, { href: "/" },
          React.createElement(Log, { name: "home Route" }),
          React.createElement('h1', null, "Welcome to Frontend Armory!")
        ),
        React.createElement(Route, { href: "/browse/" },
          React.createElement(Log, { name: "browse Route" }),
          React.createElement('h1', null, "Browse courses and guides")
        ),
      )
    )
    

    JSX tags correspond to calls to React.createElement(), with each call creating a new element object. Thus when the App component creates a new provider element to hold the updated state, it also passes in a new children element. No two values of the provider’s children will ever be equal, so the children will be re-rendered on each state change.

    Preventing unnecessary renders #

    To ensure that the entire app isn’t re-rendered on each context change, you’ll need to keep the children props of your providers equal between renders. And luckily, this is surprisingly easy! All you’ll need to do is move the state that you’ll provide into a separate component, with the children passed into that component from above.

    Or to put it simply, you just need to create a <SomethingProvider> component that doesn’t except any props other than children.

    Here’s how you’d rewrite the logging example to follow this approach – you can see the implementation of <NavigationProvider> by looking at the Navigation.js tab.

    App.js
    Navigation.js
    main.js
    index.html
    New
    Build In Progress

      In the above example, the element that represents the provider’s content is only ever created once – which you can confirm by adding a console.log() to the App component’s render() method. This element is passed to the <NavigationProvider> component as its children prop, and then re-used on subsequent renders, ensuring that the app is never unnecessarily re-rendered – just as if you were using Redux!

      If you haven’t already, keep up to date with the latest Frontend Armory content by getting a free membership – it will also give you access to all of our printable cheatsheets! And as always, if you have any questions, comments or feedback, let me know by tweeting at @james_k_nelson, or emailing me at james@frontarm.com. I can’t wait to hear from you, and thanks so much for reading!