When asked, “Why is React so fast?” you often hear, “Because of the Virtual DOM.” But what does that really mean? How does rendering and re-rendering work in React? And when should you actually use memo, useMemo, and useCallback?
Let’s explore these concepts in today’s article.
Virtual DOM and the rendering process
Operations involving the real DOM are considered slow because every time the browser updates the DOM, it needs to repaint the page, recalculate styles, perform layout computations, and handle other resource-heavy tasks.
Virtual DOM allows React to significantly reduce the number of these operations. All changes first happen in memory, and only the minimal necessary updates are pushed to the real DOM.
The process of how the Virtual DOM works can be broken down into several steps:
-
Creating the Virtual Tree (VDOM)
When a React component is rendered, a tree of elements is created — a virtual representation of the UI. This tree is a set of JavaScript objects that mimic the structure of the real DOM but are faster to process.For example, the following code:
<div className="container"> <img src="https://placehold.co/600x400" alt="placeholder" width={600} height={400} /> <h1 className="title">Here we go!</h1> </div>
Turns into this object:
{ type: "div", props: { className: "container", children: [ { type: "img", props: { alt: "placeholder", height: 400, src: "https://placehold.co/600x400", width: 600, }, }, { type: "h1", props: { className: "title", children: 'Here we go!' } } ], }, }
Updating the Virtual DOM (Render phase)
When a component’s state changes, React creates a new virtual tree. However, at this stage, no changes are made to the real DOM. React only updates the Virtual DOM.-
Comparing the old and new Virtual DOM (Reconciliation)
To minimize updates to the real DOM, React uses a reconciliation process that compares the old and new virtual trees to compute which elements have changed. This process is known as “diffing.”The algorithm is based on two key rules:
Recursive comparison: React starts comparison from the root node and moves downwards.
Comparison by node type: If the node type hasn’t changed, React updates its properties. If the type has changed, the entire subtree is replaced. Updating the real DOM (Commit phase)
After React determines which parts of the tree have changed, it updates only the modified nodes in the real DOM. This makes the process fast since React minimizes interactions with the real DOM, which are typically time-consuming.
Thus, the Virtual DOM is the key component that makes React so fast. It avoids full interface re-rendering by updating only the parts that have changed.
Re-rendering and how to avoid it
According to the React documentation, a component re-renders in two cases:
- It’s being rendered for the first time.
- The component’s state or one of its ancestors’ state has changed (including changes in hooks and context).
This is evident in the following example (the Card
component blinks on every re-render). Although the second Card
has no connection to the counter state, the App
component re-renders, causing both Card
components to update.
function App() {
const [count, setCount] = useState(0);
const increment = () => setCount((c) => c + 1);
return (
<Container>
<Card onClick={increment}>Counter: {count}</Card>
<Card>I have no state</Card>
</Container>
);
}
How to avoid unnecessary re-renders?
At first glance, the solution seems to be memoization. However, it’s important to remember that the cost of memoization can outweigh its benefits, as it requires resources for caching and additional computations. Using useMemo
and useCallback
for everything is an example of premature optimization. React is already highly optimized, so if your application is running smoothly, avoid adding memoization without reason. First, identify performance bottlenecks.
When memoization is unnecessary?
When decomposition is possible
For example, you can move the counter logic into a separate component:
import React, { useCallback, useState } from "react";
interface ICounterProps {
children: (count: number, increment: VoidFunction) => React.ReactNode;
}
const Counter: React.FC<ICounterProps> = ({ children }) => {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((c) => c + 1), []);
return children(count, increment);
};
export default Counter;
So the App
component looks like this:
import Card from "./Card";
import Counter from "./Counter";
function App() {
return (
<>
<Counter>
{(count, increment) => (
<Card onClick={increment}>Counter: {count}</Card>
)}
</Counter>
<Card>I have no state</Card>
</>
);
}
export default App;
Now, the second Card
component will no longer re-render when the state changes.
When the component is simple and does not contain complex computations
For example, if your component only displays text, memoization may be unnecessary.
When memoization is used in the wrong way
Let’s return to our example. Imagine you have a list of fruits passed to the Card
component. (While it would be logical to move this list outside of the App
component, let’s assume we get it directly from an API):
import { useCallback, useState } from "react";
import Card from "./Card";
function App() {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((c) => c + 1), []);
const fruits = ["Apple", "Orange", "Guava", "Mango", "Banana"];
return (
<>
<Card onClick={increment}>Counter: {count}</Card>
<Card>
{fruits.join(", ")}
</Card>
</>
);
}
export default App;
When you pass objects, arrays, or functions (so-called non-primitive values) as props, they are recreated every time the parent component renders. This causes the child component to consider these props as changed and re-render.
So, if you simply wrap the component in React.memo
, it won’t solve the problem:
export default memo(Card);
import { useCallback, useState } from "react";
import Card from "./Card";
function App() {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((c) => c + 1), []);
const fruits = ["Apple", "Orange", "Guava", "Mango", "Banana"];
return (
<>
<Card onClick={increment}>Counter: {count}</Card>
<Card items={fruits} />
</>
);
}
export default App;
A correct approach is also to memoize the fruits array using useMemo
:
const fruits = useMemo(() => {
return ["Apple", "Orange", "Guava", "Mango", "Banana"]
}, []);
It’s also important to remember the reverse situation: if the props are memoized but the component itself is not wrapped in memo
, it will still re-render when the parent component updates.
When memoization is useful:
When the component performs complex or resource-intensive calculations that don’t need to be repeated with every render.
When using hooks:
import { useMemo } from "react";
export const useFruits = () => {
return useMemo(
() => ["Apple", "Orange", "Guava", "Mango", "Banana"],
[]
);
};
When using Context:
import {
createContext,
ReactNode,
useMemo,
useContext,
useState,
} from "react";
type FruitsState = {
fruits: string[];
};
type FruitsAPI = {
setFruits: (fruits: string[]) => void;
}
const FruitsContext = createContext<FruitsState>({} as FruitsState);
const FruitsAPIContext = createContext<FruitsAPI>({} as FruitsAPI);
export const FruitsProvider = ({ children }: { children: ReactNode }) => {
const [fruits, setFruits] = useState<FruitsState["fruits"]>([
"Apple",
"Orange",
"Guava",
"Mango",
"Banana",
]);
const state = useMemo(() => ({
fruits,
}), [fruits]);
const api = useMemo(() => ({
setFruits
}), [])
return (
<FruitsAPIContext.Provider value={api}>
<FruitsContext.Provider value={state}>
{children}
</FruitsContext.Provider>
</FruitsAPIContext.Provider>
);
};
export const useFruitsAPI = () => useContext(FruitsAPIContext);
export const useFruits = () => useContext(FruitsContext);
Note: If your state consists of multiple parts used in different components, this can cause unnecessary re-renders.
For example, we have the following structure:
const state = useMemo(() => ({
fruits,
favoriteFruit,
caribbeanFruits
// and so on
}), [fruits, favoriteFruit, caribbeanFruit])
const FruitsList = () => {
const { fruits } = useFruits();
// ...
}
const FavoriteFruit = () => {
const { favoriteFruit } = useFruits();
// ...
}
const СaribbeanFruitsList = () => {
const { caribbeanFruits } = useFruits();
// ...
}
If one part of the state (like favoriteFruit
) changes, all components using other parts of the state (like fruits
or caribbeanFruits
) will also re-render.
To avoid this, you can the large provider into multiple providers:
<FruitsContext.Provider value={fruits}>
<FavoriteFruitContext.Provider value={favoriteFruit}>
<CaribbeanFruitsContext.Provider value={caribbeanFruits}>
{children}
</CaribbeanFruitsContext.Provider>
</FavoriteFruitContext.Provider>
</FruitsContext.Provider>
Now, only the consumers of the updated part of the state will re-render. By splitting the context into smaller, isolated parts, you can significantly reduce unnecessary re-renders and enhance overall performance.
React indeed provides high performance due to the Virtual DOM and its optimized rendering system. However, memoization is not always the solution to all problems. Before using memo
, useMemo
, or useCallback
, ensure that it’s really necessary in your case. Thoughtful use of these tools can help avoid unnecessary re-renders and maintain high performance.
Top comments (0)