And how we stopped our React Context re-rendering everything
Refs are a seldom-used feature in React. If youâve read the official React guide, theyâre introduced as an âescape hatchâ out of the typical React data flow, with a warning to use them sparingly, and theyâre primarily billed as the correct way to access a componentâs underlying DOM element.
But alongside the concept of Hooks, the React team introduced the useRef Hook, which extends this functionality:
is useful for more than theuseRef()
attribute. Itâs handy for keeping any mutable value around similar to how youâd use instance fields in classes.ref
While I overlooked this point when the new Hook APIs launched, it proved to be surprisingly useful.
The Problem
Iâm a software engineer working on Rowy, an open-source React app that combines a spreadsheet UI with the full power of Firestore and Firebase. One of its key features is the side drawer, a form-like UI to edit a single row, that slides over the main table.
When the user clicks on a cell in the table, the side drawer can be opened to edit that cellâs corresponding row. In other words, what we render in the side drawer is dependent on the currently selected rowâââthis should be stored in state.
The most logical place to put this state is within the side drawer component itself because when the user selects a different cell, it should only affect the side drawer. However:
- We need to set this state from the table component. Weâre using
to render the table itself, and it accepts a callback prop thatâs called whenever the user selects a cell. Currently, itâs the only way to respond to that event.react-data-grid
- But the side drawer and table components are siblings, so they canât directly access each otherâs state.
Reactâs recommendation is to lift this state to the componentsâ closest common ancestor, in this case,
TablePage
. But we decided against moving the state here because:
didnât contain any state and was primarily a container for the table and side drawer components, neither of which received any props. We preferred to keep it this way.TablePage
- We were already sharing a lot of âglobalâ data via a context located close to the root of the component tree, and we felt it made sense to add this state to that central data store.
Side note: even if we put the state in
TablePage
, we would have run into the same problem below anyway.The problem was whenever the user selected a cell or opened the side drawer, the update to this global context would cause the entire app to re-render. This included the main table component, which could have dozens of cells displayed at a time, each with its own editor component. This would result in a render time of around 650 ms(!), long enough to see a visible delay in the side drawerâs open animation.
Notice the delay between clicking the open button and when the side drawer animates to open
The reason behind this is a key feature of contextâââthe very reason why itâs better to use in React as opposed to global JavaScript variables:
All consumers that are descendants of a Provider will re-render whenever the Providerâsprop changes.value
While this Hook into Reactâs state and lifecycle has served us well so far, it seems we had now shot ourselves in the foot.
The Aha Moment
We first explored a few different solutions (from Dan Abramovâs post on the issue) before settling on
useRef
:- Split the context, i.e. create a new
.SideDrawerContext
The table would still need to consume the new context, which still updates when the side drawer opens, causing the table to re-render unnecessarily. - Wrap the table component in
orReact.memo
.useMemo
The table would still need to call
to access the side drawerâs state and neither API prevents it from causing re-renders.useContext
- Memoize the
component used to render the table.react-data-grid
This would have introduced more verbosity to our code. We also found it prevented necessary re-renders, requiring us to spend more time fixing or restructuring our code entirely, solely to implement the side drawer.
While reading through the Hook APIs and
useMemo
a few more times, I finally came across that point about useRef
:is useful for more than theuseRef()
attribute. Itâs handy for keeping any mutable value around similar to how youâd use instance fields in classes.ref
And more importantly:
doesnât notify you when its content changes. Mutating theuseRef
property doesnât cause a re-render..current
And thatâs when it hit me:
We didnât need to store the side drawerâs stateâââwe only needed a reference to the function that sets that state.
The Solution
- Keep the open and cell states in the side drawer.
- Create a ref to those states and store it in the context.
- Call the set state functions (inside the side drawer) using the ref from the table when the user clicks on a cell.
The code below is an abbreviated version of the code used on Rowy and includes the TypeScript types for the ref:
Side note: since function components run the entire function body on re-render, whenever the
cell
or open
state updates (and causes a re-render), sideDrawerRef
always has the latest value in .current
.This solution proved to be the best since:
- The current cell and open states are stored inside the side drawer component itself, the most logical place to put it.
- The table component has access to its siblingâs state when it needs it.
- When either the current cell or open states are updated, it only triggers a re-render for the side drawer component and not any other component throughout the app.
When to useRef
This doesnât mean you should go ahead and use this pattern for everything you build, though. Itâs best used for when you need to access or update another componentâs state at specific times, but your component doesnât depend or render based on that state. Reactâs core concepts of lifting state up and one-way data flow are enough to cover most app architectures anyway.
Thanks for reading! You can find out more about Rowy here and follow me on Twitter @nots_dney.