Case Study: Making React Interactions in Causal 4× Faster

19 min read · Author:

Causal is a React spreadsheet app for creating complex forecasting models:

Sometimes, Causal models get huge, which raises challenges in keeping them fast. One of these challenges is UI interactions. For example, in a huge model, if you opened the Categories pane and tried to fill several values in a row, you’d notice how the UI gets pretty laggy:

It might be hard to see when the app is laggy in this video. To make it easier, keep an eye on the “Frame Rate” popup in the top left corner – when it’s red or yellow, the main thread is frozen. (By the way, this is a built-in Chrome DevTools feature! You could enable it in DevTools → More Tools → Rendering → Frame Rendering Stats.)

Here’s how PerfPerfPerf helped Causal to speed up this interaction by almost 4× – with nearly every optimization changing just a few lines.

Profiling The Interaction

To optimize the interaction, we need to figure out what makes it slow. My go-to tool for that is Chrome DevTools:

Open Chrome DevTools → Performance. Click record. Update one value. Wait a bit, and stop the recording.

There’s a lot of stuff in the recording, so it might be confusing if you’re seeing it for the first time. But that’s okay! What we need to pay attention to is just two areas:

The top area (the CPU row) shows when the page was busy. The bottom area (the Main pane) shows why the page was busy.

So what’s going on here? If you go through the recording and click through a bunch of rectangles, you’ll notice some patterns:

  • There are a lot of React renders. Specifically, every rectangle called performSyncWorkOnRoot is (roughly) a function that starts a React render cycle. And there are lots of them (precisely, 1325):

    To search for a function name, press ⌘+F (or, if not using macOS, Ctrl+F). DevTools will highlight the first found match, and you can jump to the next ones.
  • Most of these React renders are caused by AG Grid. AG Grid is a library that Causal uses to render tables/grids:

    If you find any performSyncWorkOnRoot rectangle and then scroll up, you’ll see what caused that function to run (meaning, caused that React render). Most of the time, that will be some AG Grid code:

  • Some of the code runs several times. E.g., at the beginning of the recording, we call GridApi.refreshServerSide and GridApi.refreshCells two times in a row:

    Later, some code seems to call getRows over and over and over again:

    This is good! When we have some code that runs ten times in a row, we can improve a single run – and get a 10× improvement. And if some of these runs end up being unnecessary, we’ll be able to remove them altogether.

Let’s dive in.

AG Grid: Fixing An Extra Render

Across the recording, four chunks of JavaScript start with GridApi.refreshServerSide:

Down the flame chart, these chunks of JavaScript cause many React rerenders. To figure out which components are being rendered, let’s scroll down and hunt for component names.

Sidenote: Hunting for component names works because to render a functional component, React just calls it. (For class components, React calls the .render() method.)

Why not use React Profiler? Another way to see which components are rendered is to record a trace in React Profiler. However, when you have a lot of rerenders, matching that trace with the DevTools Performance trace is hard – and if you make a mistake, you’ll end up optimizing the wrong rerenders.

If you click through the component names in the recording, you’ll realize every component is RowContainerComp. This is a component from AG Grid:

Why do these components render? To answer that, let’s switch to React Profiler and find these components there:

{caption:"Open the React Profiler and <a href="https://share.cleanshot.com/ICgcZg">enable “Record why each component rendered while profiling.”</a> Then, click “Record” → update a variable → wait a bit → stop the recording → find any <code>RowContainerComp</code> in the recording. This isn’t perfect (you might pick up a wrong rerender) but is mostly precise."}

Why use React Profiler this time? This time, we are using React Profiler. We’ve learned the component names, so we don’t need to match the trace with the DevTools performance pane anymore.

Other ways to learn why a component rerenders are why-did-you-render and useWhyDidYouUpdate. They work great with first-party code but are harder to configure for third-party one (like AG Grid components).

As we see, RowContainerComp components rerender because their hook 2 changed. To find that hook, let’s switch from the Profiler to the Components pane – and match component hooks with the source code:

Hook no. 2 is this:

const [rowCtrlsOrdered, setRowCtrlsOrdered] = useState<RowCtrl[]>([]);

Why won’t we just count hooks in the source code? That’s the most obvious approach, but it rarely works. That’s because:

  • React skips useContext when counting hooks. (This is probably because useContext is implemented differently from other hooks.)

  • React doesn’t keep track of custom hooks. Instead, it counts every built-in hook inside a custom hook (except useContext). For example, if a component calls useSelector from Redux, and useSelector uses four React hooks inside, React profiler might show you “Hook 3 changed.”

So, we figured out that our interaction renders a bunch of RowContainerComp components from AG Grid. These components rerender because their hooks no. 2 (the rowCtrlsOrdered state) change. Now, if you look through the component’s source code, you’ll notice that rowCtrlsOrdered is updated inside a useEffect:

And that useEffect triggers when the rowCtrls or the domOrder state changes:

This is not optimal! AG Grid is setting state from inside a useEffect. This means it schedules a new update right after another one has happened. Here’s the entire order of events:

  1. When the component mounts, it exposes several functions to the AG Grid core

  2. Later, AG Grid calls compProxy.setRowCtrls

  3. compProxy.setRowCtrls updates the rowCtrls state

  4. Because the state changed, the component rerenders 💥

  5. The rowCtrls state got updated, so React runs useEffect

  6. Inside useEffect, React updates the rowCtrlsOrdered state

  7. Because the state changed, the component rerenders again 💥

We’re rerendering (💥) the component twice just to update hook no. 2! This isn’t great. If AG Grid updated rowCtrlsOrdered immediately at step 2 instead of 5, we’d be able to avoid an extra render.

So why don’t we make AG Grid do this? Using yarn patch, let’s patch the @ag-grid-community/react package to eliminate the extra render:

Sidenote: With npm, patch-package works just as well.
Full patch. We’ve notified the AG Grid team – unfortunately, they’re not accepting PRs from the community.

This alone cuts the number of rerenders in half – and, because RowContainerComp is rendered outside GridApi.refreshServerSide() calls too, shaves off around 15-20% of the execution time.

But we’re not done with AG Grid yet.

AG Grid: Removing The Renders

The RowContainerComp components are containers for different parts of the grid:

These components render every time we type into the editor. We just removed half of these renders. But there’s still another half, and it’s probably unnecessary – as nothing in these components changes visually.

What’s causing these renders? As we learned in the previous section, RowContainerComps rerender when AG Grid calls compProxy.setRowCtrls. In every call, AG Grid passes a new rowCtrls array. Let’s add a logpoint to see how the array looks:

and check the console output:

Woah, doesn’t every log look the same?

And indeed. If you debug this a bit, you’ll realize that:

  • the array that’s logged is always different (this is because AG Grid is re-creating it with .filter() before passing it in)
  • however, all items in that array are identical (===) across rerenders

Inside the component, AG Grid never touches the array – it only maps its items. So, if the array items don’t change, why should the component rerender?

We can prevent this extra render by adding a shallow equality check:

This saves a lot of time. Because on every cell update, RowContainerComp components rerender 1568 times (!), eliminating all renders cuts off another 15-30% of the total JS cost.

Running useEffect Less Often

Here are a few other parts of the recording:

In these parts, we call a function called gridApi.refreshCells(). This function gets called four times and, in total, takes around 5-10% of the JavaScript cost.

Here’s the Causal code that calls gridApi.refreshCells():

// ⚠️ Hacky:
// Hard refresh if autocomplete changes.
// This works around issue #XXX in the ShowFormulas view
useEffect(() => {
  setTimeout(() => {
    // Note: we're scheduling refreshCells() in a new task.
    // This ensures all previous AG Grid updates have time to propagate
    gridApi.refreshCells({ force: true });
  }, 0);
}, [gridApi, autocompleteVariables]);

This is an unfortunate hack (one of the few which every codebase has) that works around an issue with code editor autocomplete occasionally not picking up new variables.

The workaround is supposed to run every time a new variable gets added or removed. However, currently, it runs way more frequently. That’s because autocompleteVariables is a deeply nested object with a bunch of other information about variables, including their values:

// The `autocompleteVariables` object (simplified)
{
  "variable-id": {
    name: "myVariable",
    type: "Variable",
    dimensions: [...],
    model: ...,
  },
  ...
}

When you type in the cell, a few variables update their values. That causes autocompleteVariables to update – and triggers a gridApi.refreshCells() call. These calls are unnecessary – gridApi.refreshCells() only needs to run when a new variable is added or removed. How can we achieve this?

  • A naive way to do that would be to rewrite useEffect dependencies like this:

    useEffect(() => {
      // ...
    }, [gridApi, autocompleteVariables]);

    useEffect(() => {
      // ...
    }, [gridApi, autocompleteVariables.length]);

    This will work in most cases. However, if we add one variable and remove another one simultaneously, the workaround won’t run.

  • A proper way to do that would be to move gridApi.refreshCells() to the code that adds or removes a variable – e.g., to a Redux saga that handles the corresponding action.

    However, this isn’t a simple change. The logic that uses gridApi is concentrated in a single component. Exposing gridApi to the Redux code would require us to break/change several abstractions. We’re working on this, but this will take time.

  • Instead, while Causal is working on a proper solution, why don’t we hack a bit more? 😅

    useEffect(() => {
      // ...
    }, [gridApi, autocompleteVariables]);

    useEffect(() => {
      // ...
    }, [gridApi, Object.keys(autocompleteVariables).sort().join(',')]);

    With this change, useEffect will depend only on concrete variable IDs inside autocompleteVariables. Unless any variable ids get added or removed, the useEffect shouldn’t run anymore. (This assumes none of the variable ids include a , character, which is true in Causal’s case.)

Terrible? Yes. Temporary, contained, and easy to delete, bearing the minimal technical debt? Also yes. Solves the real issue? Absolutely yes. The real world is about tradeoffs, and sometimes you have to write less-than-optimal code if it makes your users’ life better.

Just like that, we save another 5-10% of the JavaScript execution time.

Deep areEqual

There are a few bits in the performance trace that look like this:

What happens here is we have a function called areEqual. This function calls a function called areEquivalent – and then areEquivalent calls itself multiple times, over and over again. This is a deep equality comparison, and on a 2020 MacBook Pro, it takes ~90 ms.

The areEqual function comes from AG Grid. Here’s how it’s called: