-- 10 min read --
This post describes some best practices to avoid the most common performance bottlenecks that occur in React applications.
First of all: Component updates and referential equality
Understanding why a React component updates (re-renders) is crucial for performance optimization, as unnecessary updates are one of the most common sources of poor performance.
By default, whether a component update is necessary is decided using a referential equality check (see explanations below).
Primitives (e.g. string, number, boolean, undefined, null) get compared by value 1:
const str1 = "A";
const str2 = "A";
const str3 = "B";
console.log(str1 === str1); // true
console.log(str1 === str2); // true
console.log(str1 === str3); // false
Objects, arrays and functions by reference 1:
const obj1 = { a: "A", b: "B" };
const obj2 = { a: "A", b: "B" };
const obj3 = { a: "A", c: "C" };
console.log(obj1 === obj1); // true
console.log(obj1 === obj2); // false
console.log(obj1 === obj3); // false
Memoization of props
One reason for a component update is a change in props (comparing two consecutive updates).
By default, each prop is compared using a referential equality check (see above).
Therefore, you should, in most cases, avoid passing unmemoized references as props.
If you pass a new reference (inline or otherwise), as shown below, ChildComponent will update on every parent update.
⛔ Don't do this
// objProp is a new reference when this code runs
const objProp = { someKey: 'someValue' };
// funcProp is a new reference when this code runs
const funcProp = () => ({ someKey: 'someValue' });
// arrProp is a new reference when this code runs
return <ChildComponent objProp={objProp} arrProp={[]} funcProp={funcProp} />;
✅ Instead, memoize your props
const objProp = React.useMemo(() => ({ someKey: 'someValue' }), []);
const arrProp = React.useMemo(() => [], []);
const funcProp = React.useCallback(() => ({ someKey: 'someValue' }), []);
return <ChildComponent objProp={objProp} arrProp={arrProp} funcProp={funcProp} />;
Likewise, avoid passing props that the component doesn’t use (e.g., by spreading objects). This can also lead to wasteful updates since React does not know which of the props are used and which are not.
⛔ Don't do this
<ChildComponent {...someUnnecessaryProps} />;
Selective subscription to the Redux state
Another reason for a component update is a subscription to the Redux state.
The useSelector hook runs the provided selector function on every Redux state change and triggers a component update if the returned value has changed (referentially, see above).
⛔ Don't do this
// component containing this useSelector hook updates every time the returned reference changes
const todoState = useSelector(selectTodoState);
const value: string = todoState.todos.find(t => t.id === idToFind).someValue;
Instead, select only the minimal piece of state your component needs, to increase the chances that the selected value remains referentially equal between updates.
✅ Do this instead
// component containing this useSelector hook updates only when the returned string value changes
const value = useSelector((state) => {
const todoState = selectTodoState(state);
const value: string = todoState.todos.find(t => t.id === idToFind).someValue;
return value;
});
If your selector must return a new reference (e.g., an array or object) on each call, consider using 'reselect' to memoize the result.
⛔ Don't do this
const selectTransformedTodos = (state, todoType) => {
const todos = createTodoSelector(todoType)(state);
// returns a new array reference on every call of the selector
return todos.map(todoTransformer);
}
✅ Use a memoized selector
import { createSelector } from "reselect";
// call of createSelector creates a new memoized selector instance
const selectTransformedTodos = createSelector(
// 'input selectors' => get called on every Redux state change
[(state, todoType) => createTodoSelector(todoType)(state)],
// 'output selector' => only gets called if the returned value
// by at least one 'input selector' changes (referentially)
// compared to the previous run
(todos) => {
// returns a cached stable value if the results of the 'input selectors'
// stay referentially equal
return todos.map(todoTransformer);
});
Memoize components
When a component updates, React will by default also update all of its children — unless you prevent it explicitly.
If a component only updates because its parent did (can be seen when profiling, see below), you can prevent this by wrapping it in React.memo.
⛔ Important!
Don't wrap all your components with React.memo, as it also has a certain performance cost.
Position of hooks matters
Hooks such as useState, useReducer, useContext, and useEffect can all trigger component updates.
The position of these hooks in the component tree matters, like in the example below:
Clicking the toggle button will update the entire View component, including Contentbox, Footer, and BigExpensiveComponent — even though only part of the UI actually changes.
⛔ Don't do this
const View = () => {
const [showElements, setShowElements] = React.useState(false);
const toggleElements = React.useCallback(
() => setShowElements((previousValue) => !previousValue),
[],
);
return (
<Contentbox
heading={
<>
<Button onClick={toggleElements} />
{showElements && <AdditionalElements />}
</>
}
footer={<Footer />}
>
<BigExpensiveComponent />
</Contentbox>
);
};
A better approach is to extract a separate Heading component, since the local state is only relevant there.
✅ Do this instead
const Heading = () => {
const [showElements, setShowElements] = React.useState(false);
const toggleElements = React.useCallback(
() => setShowElements((previousValue) => !previousValue),
[],
);
return (
<>
<Button onClick={toggleElements} />
{showElements && <AdditionalElements />}
</>
);
};
Addition: Instructions for profiling
- Install the React Developer Tools in your browser
- In the settings, check the flag to record why each component rendered while profiling
- Start the profiler
- Perform a certain action in your app that has poor performance
- Stop the profiler
- Inspect the recorded frames and analyze the involved component updates
- If you think that an update is unnecessary, try to prevent it in your code
- Repeat steps 3–7 until you've eliminated all unnecessary updates
Bottom Line
The goal of this content is to make you aware of how React works and how implementation affects performance.
Writing an unperformant app happens easily, as small details can have significant impacts.
Preventing a few unnecessary update cycles might not drastically change your application's performance immediately.
However, consistently applying these best practices can cumulatively have a huge impact, transforming an unusable app into a highly efficient one.
Thank you for reading the article ❤️!
Feel free to share your feedback in the comments.
-
React internally uses Object.is(), which behaves identical to === for most practical purposes. For simplicity, this post refers to ===. ↩
Top comments (1)
Memoizing props changes everything.