So You Want a Performant React App?
Note: this is a draft post 🔨. It’s a rough draft and is under active development
Meandering discussion of react
React is the most popular javascript library. The fact that it was released in 2013 and is still the [most popular framework](https://2024.stateofjs.com/en-US/libraries/) is a remarkable feat. Especially in the world where a new javascript framework comes out every week.Despite it’s popularity, React has problems. Most notorious is it’s runtime performance. It’s not that React isn’t necessarily less performant than other frameworks like Solid, Vue, or svelte. The problem with React is it requires more engineering effort to make a performant app so larger React apps often run into performance issues that aren’t as problematic in other frameworks.
React historically put the effort of optimizing performance on developers. This may be changing with the introduction of the React Compiler which is a welcome improvement. However, the react compiler doesn’t solve all of React’s performance issues so we still need to keep manual performance techniques in our toolbox.
This post is a brief overview of common techniques to creating a performant react app.
App Architecture
Optimizing performance starts with a well architected app. It’s hard to prescribe a good architecture in a broad sense because each app is different. Instead of prescribing a specific architecture, there are signs we have a good architecture:
- Fast initial loads: lazy loading components and route based code splitting
- no data loading during renders, no loading spinners as components fetch the data they need to render
- common page level interactions are fast, scrolling and general navigation are fast
- assets are easily cached
- decent core web vitals
Fast initial loads
Optimize the initial page load by lazy loading non-essential components with
lazy()
and using a
<Suspense>
boundary.
Routes should also be lazy loaded. libraries like TanStack Router make lazy loading routes trivial.
No data fetching during renders
React’s component model is lovely for devs. Putting everything in a component has a problem though. Specifically, when a component needs to render, fetch, then show a meaningful UI. This pattern causes extensive waterfalls and long loading spinners.
An app built on this pattern hae a lifecycle like this:
- user fetches initial HTML
- html is parsed, fetches JavaScript assets from the server
- app level component renders, starts fetching again (roundtrip)
- app level component finishes render, starts rendering child components
- a child needs more data based on the data loaded earlier, fetches more data from the server
- data loads and the child component can render
Instead, move all of the data requests to the top level of the page or use a full stack meta-framework that streams components from the server to the client as components load the data they need.
GraphQL + Relay is the best way to automatically move requests to the top of a page while retaining the joy of writing network requests inside a component.
Don’t overshare global state
Putting all app state in a global app context will cause unnecessary re-renders in components consuming that context. For example, if you put app preferences and app authentication in one context, components consuming that context will re-render whenever a preference or authentication changes. Logically separate context’s to minimize unnecessary re-renders.
Or better yet, use a state management library that provides fine grained updates. Zustand and Jotai are great choices.
Don’t use css-in-js solutions
Specifically, avoid creating styles at runtime. Libraries like styled-components and emotion add unnecessary overhead to rendering. See this post from an Emotion maintainer on why he’s no longer using css in js solutions.
Using css-in-js also prevents them from being cached separately meaning more network requests to the server.
Use altnertives like linaria, panda css, or vanilla extract.
Avoid unnecessary re-renders
Avoid extra calculations
Reuse computations with
useMemo()
. Note, useMemo()
only
cache’s one value so it will won’t cache more than one result at a time.
TODO: demo custom memo hook that cache’s more than one result
When to memo()
- when a component re-renders many times with the same props (memo only applies to props changine, not internal state)
- rendering is cpu expensive
- Your app has lots of fine grained interactions
When to not memo()
- wrapping a component? Accept
children
as props - push state down when you don’t need to expose/share it
Defer UI updates
React’s useDeferredValue()
allows us to defer (delay) updates to parts of the
UI. We can use it to solve the problem in the previous example where we have a
piece of state (color
) that’s used in an expensive component. None of the
previous techniques help us because the color state can’t be isolated or
memoized. Instead, useDeferredValue()
tells react we have a piece of state
whose updates will be delayed. React will immediately update the UI when state
created with useState() is changed. Instead, updates to state with
useDeferredValue() are de-prioritized so react updates them less frequently.
const ExpensiveTree = memo(function ExpensiveTree({ color }) {
const now = performance.now();
while (performance.now() - now < 100) {
// delay render by 100ms
}
return <p>slow component 🐌 {color}</p>;
});
function SlowApp() {
const [color, setColor] = useState('red');
const deferredColor = useDeferredValue(color);
return (
<form style={{ color }}>
<label htmlFor="color-input">Set color: </label>
<input
value={color}
id="color-input"
onChange={(e) => setColor(e.target.value)}
/>
<p style={{ color }}>Hello, world!</p>
<ExpensiveTree color={deferredColor} />
</form>
);
}
Now, whenever we call setColor()
, react will eventually synchronize
deferredColor
to the same value but in a way that doesn’t block the UI.