How I made our React app more performant (includes INP)

How I made our React app more performant (includes INP)

Debug and optimize React re-renders.

Introduction

I've been trying to reduce the number of re-renders in one of our features at work. It's a bit heavy on low-end phones. I ended up going down a rabbit hole of trying to understand re-renders in React.

It's been quite fun. If you're someone who wants to understand re-renders in React and make your app more performant, this post is for you.

Chat layout

Starting off, we have a chat layout. It consists of three parts:

  • Header.

  • Message container with messages.

  • Chat bar.

They're all in a single component.

Let's profile with React DevTools

See what re-renders

To see what gets re-rendered, you can enable this option in React Devtools. It's gonna highlight the re-rendered components for you. This happens as you interact with the app.

Proper profiling

To gain some more insights, let's properly profile the chat layout using React DevTools. We hit record and then type 4 letters into the chat bar.

You want to enable the setting that shows why a component re-renders. It's honestly not as detailed as you want it to be, but can give you more clues.

I can't share the entire thing. But looking at the results, every time we type in the chat bar, the entire chat layout re-renders.

Typing 4 letters causes 4 re-renders for every single component in the chat layout.

Okay, what's the issue? Only the chat bar itself should re-render when we type in the chat bar. No other components in the chat layout need the input state.

The problem

I already knew the problem when seeing the re-rendered components highlighted before I dove into profiling.

Whenever a component's state changes, the component re-renders. In React, if a parent component re-renders, all child components will re-render too.

The input state is inside of the chat layout component which contains the chat bar and the other components.

What's important here: The location of the state is what matters.

We call the input's useState inside of the chat layout component.

JSX variables not visible in DevTools

One thing I found strange as I was profiling was that other components' names were showing but not the Chat Bar's name.

I have the JSX of Chat Bar as a variable in the chat layout component.

// JSX variable
const ChatBarUI = (
   // ...
)

// A component
const ChatBar = () => {
   // ...
}

I did this (JSX variable) to make the code slightly cleaner.

When you store JSX like this, it's not a react component. It's just a variable. A variable that holds JSX.

Babel compiles JSX down to React.createElement. Which simply returns an object. ChatBarUI isn't recognized as an actual function component by React. A component has e.g. a lifecycle.

When to use JSX variables

You may have used JSX variables yourself to tidy up the code.

Only use them for static content. As soon as you start dealing with state of any kind, you should use a component.

Be careful though. In my case, I started storing a bit of JSX which quickly became a bottleneck.

How to solve this issue

Going back to the problem, one idea you might have is to memoize the container of the messages. You can use React.memo to achieve this. So it would only re-render if any of its props change.

However, that's not the optimal approach here!

We should only reach for React.memo when it's absolutely necessary.

The solution here would be to turn Chat Bar into a component and colocate the state. By that, I mean moving the state for the Chat Bar component into the component itself, instead of keeping it in the chat layout component and passing it down as props.

So, the input state would be inside the Chat Bar component.

How to think in React

Going back to our layout, we could have avoided this issue by thinking in the React way.

When you look at a layout in React, you can identify the components and the state each one needs.

This helps you see which state belongs to which component. This way, you know how to place the state correctly and understand what state changes will cause re-renders for specific components.

INP (bonus)

I decided to measure the interaction with Chrome Devtools. The metric here is called Interaction to Next Paint (INP). It's used to measure the performance of responding to user interactions such a key press.

You want to slow down the CPU by 6x for a more realistic interaction.

Essentially, hit record, interact and stop recording.

Overview

An overview of the result.

It's divided into three main things:

  • Input Delay: Time before onChange can start.

  • Processing Time: Executing onChange and any resulting React updates.

  • Presentation Delay: Browser re-painting.

Now, the onChange function could be something else. This isn't specific to React. You can measure the INP of any page to see what's taking time. The red bar on the task shows it takes a while to process this.

Input delay

To show you the input delay more in-depth:

It's interesting how the browser works. Everything is very detailed. In a simple way of thinking, you might imagine the key press happens and then the onChange function runs.

Presentation delay

If we dive deeper into the presentation delay which happens after the main processing time, we see that the browser is doing work to update the DOM:

The purple thingy before Paint is the work to Pre Paint.

These are the 4 stages of the browser's rendering process:

  1. Pre-paint:

    • This stage calculates property trees, showing visual properties of elements (like position, size, opacity, etc.).

    • It prepares for painting by identifying which parts of the page need updates.

  2. Paint:

    • In this stage, the browser creates a display list with drawing commands.

    • These commands describe how to show the updated content, including text, images, and shapes.

    • The display list outlines what needs to be painted without actually painting it yet.

  3. Layerize:

    • This stage breaks up the display list into composited layers.

    • Each layer can be processed independently, allowing for more efficient updates and animations.

    • Layerization is important for performance, as it lets the browser update only the parts of the page that have changed.

  4. Commit:

    • In this final stage, the updated information (property trees, display lists, and layer info) is copied to the compositor thread.

    • The compositor thread, separate from the main thread, ensures smoother performance, especially during animations or scrolling.

React inner workings

We saw a couple of functions such as beginWork and you may wonder what they're for. These are actually React's internal functions.

I was thinking of explaining a couple of them like in my tweet. But honestly, we'd end up in a deep rabbit hole if I were to explain them properly.

Conclusion

  • Colocate state.

  • Measure before you optimize anything.

  • Performance optimization isn't magic. Understand what's happening and you'll know what to do.

  • Use React DevTools to figure out when and what re-renders happen.