React components re-render when their props or state change, but sometimes they update even when the new props/state are identical to the old ones, leading to performance issues.
Let’s see this in action. Imagine a parent component App that renders a UserProfile component. UserProfile displays the user’s name and an avatar.
// App.js
import React, { useState } from 'react';
import UserProfile from './UserProfile';
function App() {
const [theme, setTheme] = useState('light');
const user = { name: 'Alice', avatarUrl: 'alice.png' };
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<div>
<button onClick={toggleTheme}>Toggle Theme</button>
<UserProfile user={user} />
</div>
);
}
export default App;
// UserProfile.js
import React from 'react';
function UserProfile({ user }) {
console.log('UserProfile rendered');
return (
<div>
<h2>{user.name}</h2>
<img src={user.avatarUrl} alt="User Avatar" />
</div>
);
}
export default UserProfile;
When you click the "Toggle Theme" button in App, the theme state changes. This causes App to re-render. Because user is defined inside App, a new user object is created on every render of App, even though its contents (name and avatarUrl) are the same. UserProfile receives this new object as a prop, so React thinks the prop has changed and re-renders UserProfile. You’ll see "UserProfile rendered" in the console every time you click the button, even though Alice’s name and avatar haven’t changed.
The core problem is that React’s default re-rendering mechanism doesn’t deeply compare object or array props. It uses shallow comparison: if the reference to the prop changes, it assumes the prop has changed and triggers a re-render.
To fix this, we can tell React to only re-render UserProfile if its props actually change in value. The simplest way is using React.memo. React.memo is a higher-order component that memoizes your component. It performs a shallow comparison of the previous props and the next props. If they are the same, React skips re-rendering the component.
Let’s wrap UserProfile with React.memo:
// UserProfile.js
import React from 'react';
function UserProfile({ user }) {
console.log('UserProfile rendered');
return (
<div>
<h2>{user.name}</h2>
<img src={user.avatarUrl} alt="User Avatar" />
</div>
);
}
export default React.memo(UserProfile);
Now, when you click "Toggle Theme," App re-renders, a new user object is created, and UserProfile receives it. However, React.memo will compare the new user object with the old user object. Since the name and avatarUrl properties are identical, React.memo will determine that the props haven’t effectively changed and will prevent UserProfile from re-rendering. You will no longer see "UserProfile rendered" in the console on theme toggles.
But what if user was an array, or a more complex object, and we needed to control the comparison? React.memo accepts an optional second argument: a custom comparison function. This function receives the prevProps and nextProps and should return true if the props are equal (meaning no re-render is needed), and false otherwise.
Consider this scenario where user is an array of user IDs:
// App.js
import React, { useState } from 'react';
import UserList from './UserList';
function App() {
const [count, setCount] = useState(0);
const userIds = [1, 2, 3]; // This array is recreated on every App render
const incrementCount = () => {
setCount(prevCount => prevCount + 1);
};
return (
<div>
<button onClick={incrementCount}>Increment Count: {count}</button>
<UserList ids={userIds} />
</div>
);
}
export default App;
// UserList.js
import React from 'react';
function UserList({ ids }) {
console.log('UserList rendered');
return (
<ul>
{ids.map(id => <li key={id}>User {id}</li>)}
</ul>
);
}
export default UserList;
Clicking "Increment Count" causes App to re-render, and a new userIds array is created. UserList will re-render unnecessarily. We can use React.memo with a custom comparison:
// UserList.js
import React from 'react';
function UserList({ ids }) {
console.log('UserList rendered');
return (
<ul>
{ids.map(id => <li key={id}>User {id}</li>)}
</ul>
);
}
const areEqual = (prevProps, nextProps) => {
// Custom comparison: check if the arrays are deeply equal
if (prevProps.ids.length !== nextProps.ids.length) {
return false; // Different lengths mean different props
}
for (let i = 0; i < prevProps.ids.length; i++) {
if (prevProps.ids[i] !== nextProps.ids[i]) {
return false; // Found a difference
}
}
return true; // Arrays are equal
};
export default React.memo(UserList, areEqual);
With this custom comparison, UserList will only re-render if the ids array’s length changes or if any of its elements change. Clicking "Increment Count" will no longer trigger a UserList re-render.
For functions passed as props, React.memo’s default shallow comparison won’t work if the function is re-created on every parent render. This is where useCallback comes in. useCallback memoizes a function itself. It returns the same function reference as long as its dependencies haven’t changed.
Let’s revisit the UserProfile example and add a handler function:
// App.js
import React, { useState, useCallback } from 'react';
import UserProfile from './UserProfile';
function App() {
const [theme, setTheme] = useState('light');
const user = { name: 'Alice', avatarUrl: 'alice.png' };
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
// This function is recreated on every render of App
const logUserProfileClick = () => {
console.log('User profile clicked');
};
return (
<div>
<button onClick={toggleTheme}>Toggle Theme</button>
<UserProfile user={user} onClickHandler={logUserProfileClick} />
</div>
);
}
export default App;
// UserProfile.js
import React from 'react';
function UserProfile({ user, onClickHandler }) {
console.log('UserProfile rendered');
return (
<div onClick={onClickHandler}>
<h2>{user.name}</h2>
<img src={user.avatarUrl} alt="User Avatar" />
</div>
);
}
export default React.memo(UserProfile);
Even with React.memo, clicking "Toggle Theme" causes App to re-render, logUserProfileClick is re-created, and UserProfile re-renders because its onClickHandler prop is a new function reference.
To fix this, we memoize logUserProfileClick using useCallback:
// App.js
import React, { useState, useCallback } from 'react';
import UserProfile from './UserProfile';
function App() {
const [theme, setTheme] = useState('light');
const user = { name: 'Alice', avatarUrl: 'alice.png' };
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
// Memoize the function using useCallback
const logUserProfileClick = useCallback(() => {
console.log('User profile clicked');
}, []); // Empty dependency array means it's created once
return (
<div>
<button onClick={toggleTheme}>Toggle Theme</button>
<UserProfile user={user} onClickHandler={logUserProfileClick} />
</div>
);
}
export default App;
Now, logUserProfileClick will have the same reference across re-renders of App (because its dependencies are empty). React.memo on UserProfile will see that the onClickHandler prop hasn’t changed, and UserProfile will not re-render when only the theme changes.
Finally, useMemo is used to memoize the result of a computation. If you have expensive calculations within your component that don’t need to be re-run on every render, useMemo can be beneficial.
// ExpensiveCalculationComponent.js
import React, { useState, useMemo } from 'react';
function ExpensiveCalculationComponent({ data }) {
const [filter, setFilter] = useState('');
// This calculation can be expensive
const filteredData = useMemo(() => {
console.log('Performing expensive filtering...');
return data.filter(item => item.includes(filter));
}, [data, filter]); // Re-run only if data or filter changes
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter data"
/>
<ul>
{filteredData.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
export default ExpensiveCalculationComponent;
Here, the filtering logic will only execute when data or filter changes. If ExpensiveCalculationComponent’s parent re-renders but data and filter remain the same, the "Performing expensive filtering…" log won’t appear, and the filtering won’t be re-done.
The most common pitfall with React.memo and custom comparators is forgetting that React’s built-in Object.is comparison (used by default React.memo and useCallback/useMemo dependencies) is strict. If you pass down objects or arrays that are generated inline in the parent, they will always be new references, defeating memoization. The solution is to either lift the state up and ensure stable references (like using useMemo for objects/arrays passed as props) or to use React.memo with a robust custom comparison function that checks the contents of the objects/arrays.
The next logical step is understanding how to manage complex state and its impact on re-renders, often leading to libraries like Redux or Zustand.