Table of Contents
Overview
In a previous blog post, we took a deep dive into when React rendering happens and how the rendering process is structured. If you’d like to learn more about React rendering itself, check out the link below.
In this post, we’ll focus on rendering optimization.
Why Optimize Rendering Performance?
Rendering is a perfectly normal part of how React works. The real issue is “unnecessary” renders — and those are what we want to optimize away.
An unnecessary render is one where the output hasn’t actually changed — meaning the resulting Fiber object is identical to what the previous render produced.
In software, there are generally two ways to optimize:
- Make the same work run faster (speed up)
- Skip the work entirely
React takes the second approach: it optimizes by skipping unnecessary renders to reduce the total amount of rendering work.
- The fundamental principle behind React’s rendering optimization:
A component’s render output should be entirely based on its current props and state. If props and state haven’t changed, the output should be the same. — Component purity in React
Based on this principle, if we can determine that props and state haven’t changed, we can predict that the render output will be the same as before. This means it’s safe to skip the render — and that’s the foundation of React’s rendering optimization.
How to Optimize Rendering
React provides three main APIs for skipping renders: one for function components and two for class components.
Function Components
1. React.memo()
A Higher-Order Component (HOC) that checks whether props have changed.
const MemoizedComponent = React.memo(MyComponent);
If props haven’t changed, the render is skipped.
Class Components
2. shouldComponentUpdate
shouldComponentUpdate(nextProps, nextState) {
return !shallowEqual(this.props, nextProps) ||
!shallowEqual(this.state, nextState);
}
If this method returns false, the render is skipped.
3. PureComponent
A base class that automatically implements shouldComponentUpdate for you.
class MyComponent extends React.PureComponent {
// ...
}
Shallow Equality
All of these methods use “shallow equality” to compare values. This can lead to some tricky situations:
- If a field inside an object changes but the reference stays the same, it’s treated as unchanged (the reference doesn’t change)
- If the values are the same but it’s a new object, the different reference makes it look like it changed (the reference changes)
The Reference Problem in Optimization
By default in React, when a parent renders, all its children re-render too, so new prop references aren’t really an issue.
function ParentComponent() {
const handleClick = () => {
console.log('Button clicked');
};
const data = { a: 1, b: 2 };
return <NormalChildComponent onClick={handleClick} data={data} />;
}
- New function and object references are created on every render
NormalChildComponentis going to re-render anyway, so this is fine
But when you use memo to optimize a child component’s rendering, the parent’s render creates new references that trigger unnecessary re-renders in the child.
const MemoizedChildComponent = React.memo(ChildComponent);
function ParentComponent() {
const handleClick = () => {
console.log('Button clicked');
};
const data = { a: 1, b: 2 };
return <MemoizedChildComponent onClick={handleClick} data={data} />;
}
The problem:
- New
onClickanddatareferences are created on every render - These new references count as prop changes
MemoizedChildComponentends up re-rendering every time anywayReact.memo()becomes completely useless!
Optimizing Prop References
Function components provide two hooks to solve this reference problem:
useCallback: Memoizes a function
function ParentComponent() {
const memoizedCallback = useCallback(() => {
console.log('Clicked');
}, []); // Empty dependency array means it never changes
return <MemoizedChild onClick={memoizedCallback} />;
}
useMemo: Memoizes data
function ParentComponent() {
const memoizedData = useMemo(() => ({ a: 1, b: 2 }), []);
return <MemoizedChild data={memoizedData} />;
}
By using useCallback and useMemo, you can keep prop references stable across renders, allowing the child component’s render optimization to actually work as intended.
function ParentComponent() {
const memoizedCallback = useCallback(() => {
console.log('Clicked');
}, []);
const memoizedData = useMemo(() => ({ a: 1, b: 2 }), []);
return <MemoizedChild onClick={memoizedCallback} data={memoizedData} />;
}
Should You Memoize Everything?
This is probably the most frequently asked question when it comes to rendering optimization in React. And the answer is “no.”
That’s because memoization itself comes with overhead:
- Comparing props takes time
- It uses memory
- It adds complexity to your code
When Memoization Is Worth It
The official React docs explain when memoization is actually worthwhile:
“Optimizing with memo is only valuable when your component re-renders often with the same exact props, and its re-rendering logic is expensive.”
Rendering Optimization Guidelines
Taking into account the overhead of memoization and the official docs’ guidance on when it’s worthwhile, we can come up with these rendering optimization guidelines:
- The component frequently re-renders with the same props
- The re-rendering logic is expensive (complex computations, many child components)
- You’ve actually measured a performance issue
Most performance issues in React apps aren’t related to re-renders at all. Before optimizing rendering, it’s better to check whether there’s a logic bug or if you’re triggering unnecessary state updates.
Immutability in React
Since React uses shallow equality checks by default, there’s a problem: if the content of your data changes but the reference stays the same, the re-render won’t fire.
This means shallow equality can fail to detect changes like this:
// ❌ Bad: React can't detect the change
const [todos, setTodos] = useState(someTodosArray);
const handleClick = () => {
todos[3].completed = true;
setTodos(todos); // Same reference, so React doesn't see a change
};
To avoid this, you need to create a new reference and then make your changes, so React can clearly see that the reference has changed.
// ✅ Good: Create a new reference
const handleClick = () => {
const newTodos = todos.slice();
newTodos[3].completed = true;
setTodos(newTodos); // New reference, so React detects the change
};
React and its ecosystem are built on the assumption of immutable updates. If you don’t follow immutability, you’re opening the door to hard-to-track bugs.
Measuring React Component Rendering Performance
Earlier in the rendering optimization guidelines, we mentioned that you should only optimize rendering when you’ve actually measured a performance issue. React provides the React DevTools Profiler for measuring render counts.
- https://react.dev/learn/react-developer-tools
- https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en&pli=1
Before you start optimizing rendering in React, make sure to use this tool to analyze the current rendering behavior, then compare it with the results after optimization to verify that your changes actually make a difference.
<StrictMode>
During development, React runs <StrictMode> by default, which double-renders components inside it.
The reason for double-rendering is so React can detect side effects during rendering.
Because of this, if you’re using console.log to check how many times a component renders, the count may not match what’s happening in production. That’s why you should always use the React DevTools Profiler to get accurate render counts when optimizing.
React Compiler in React 19
React 19 introduced the React Compiler — a new build-time tool that automatically optimizes your React apps.
- React Compiler: https://react.dev/learn/react-compiler
Without the React Compiler, you’d manually optimize rendering with memoization like this:
import { useMemo, useCallback, memo } from 'react';
const ExpensiveComponent = memo(function ExpensiveComponent({ data, onClick }) {
const processedData = useMemo(() => {
return expensiveProcessing(data);
}, [data]);
const handleClick = useCallback((item) => {
onClick(item.id);
}, [onClick]);
return (
<div>
{processedData.map(item => (
<Item key={item.id} onClick={() => handleClick(item)} />
))}
</div>
);
});
But here’s the thing — this memoization isn’t actually working properly. A new reference is created on every render because of this part:
<Item key={item.id} onClick={() => handleClick(item)} />
This is exactly the kind of thing that makes manual rendering optimization so tricky. One small oversight and the whole optimization falls apart.
To solve this, the React team built the React Compiler. With it, you don’t need to do anything special — rendering is automatically optimized for you:
function ExpensiveComponent({ data, onClick }) {
const processedData = expensiveProcessing(data);
const handleClick = (item) => {
onClick(item.id);
};
return (
<div>
{processedData.map(item => (
<Item key={item.id} onClick={() => handleClick(item)} />
))}
</div>
);
}
The React Compiler supports React 17, 18, and 19.
If you’ve been struggling with rendering optimization, it might be worth giving the React Compiler a try.
Wrapping Up
In this post, we explored how to optimize rendering performance in React. We also looked at the React Compiler and how it represents the future of rendering optimization in React.
Give the React Compiler a shot and let it handle the heavy lifting for you. And if you can’t adopt the React Compiler just yet, I hope this post helped you build a solid understanding of how React rendering optimization works so you can tackle it with confidence.
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.