Performance: When React Gets Slow
Fix slow React apps with this performance optimization guide. Learn why components re-render, how to use React.memo to prevent unnecessary renders, when to apply useMemo and useCallback, and how to profile with React DevTools. Includes virtualization, code splitting, and real-world optimization patterns.
The Moment I Knew Something Was Wrong
After learning Context and building a decently complex dashboard, I noticed something: typing in a search box felt sluggish.
Every keystroke had a noticeable delay. The cursor would lag behind my fingers.
I opened React DevTools, turned on “Highlight updates when components render,” and typed a single character.
The entire page flashed.
Not just the search box. Not just the results. The navigation. The sidebar. The footer. The user avatar. Everything was re-rendering on every single keystroke.
Oh no.
Why React Re-renders
To fix this, I first had to understand the problem. Here’s how React decides to re-render:
- State changes → The component re-renders
- Props change → The component re-renders
- Parent re-renders → All children re-render (even if their props didn’t change!)
That third one is the killer.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child /> {/* Re-renders every time Parent re-renders! */}
</div>
);
}
function Child() {
console.log('Child rendered'); // Logs on EVERY parent re-render
return <div>I'm just chilling here</div>;
}
Child doesn’t use count. It doesn’t receive any props. But it still re-renders because its parent re-rendered.
For small apps? No big deal. For complex apps with heavy components? Death by a thousand re-renders.
Tool #1: React.memo
React.memo is a higher-order component that memoizes your component. It only re-renders if the props actually changed.
const Child = React.memo(function Child() {
console.log('Child rendered'); // Only logs when props change
return <div>I'm just chilling here</div>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child /> {/* No longer re-renders on parent state change! */}
</div>
);
}
Magic? No. React compares the previous props with the next props. If they’re the same (shallow comparison), skip the re-render.
The Problem With Object and Function Props
Here’s where it gets tricky:
const Child = React.memo(function Child({ style, onClick }) {
console.log('Child rendered');
return <button style={style} onClick={onClick}>Click me</button>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child
style={{ color: 'blue' }} // 🚨 New object every render!
onClick={() => console.log('clicked')} // 🚨 New function every render!
/>
</div>
);
}
React.memo compares props with ===. And here’s the thing:
{ color: 'blue' } === { color: 'blue' } // false!
(() => {}) === (() => {}) // false!
Even though the values are the same, they’re new objects/functions in memory. So React.memo thinks the props changed.
This is where useMemo and useCallback come in.
Tool #2: useMemo
useMemo memoizes a computed value. It only recalculates when its dependencies change.
function Parent() {
const [count, setCount] = useState(0);
// ✅ Same object reference unless something changes
const style = useMemo(() => ({ color: 'blue' }), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child style={style} /> {/* Won't re-render! */}
</div>
);
}
The [] dependency array means “only compute this once.” If you had dynamic values:
const style = useMemo(() => ({
color: isActive ? 'blue' : 'gray'
}), [isActive]); // Recalculates when isActive changes
Tool #3: useCallback
useCallback memoizes a function. It’s basically useMemo for functions.
function Parent() {
const [count, setCount] = useState(0);
// ✅ Same function reference unless dependencies change
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child onClick={handleClick} /> {/* Won't re-render! */}
</div>
);
}
Putting It All Together
Here’s my dashboard search component, before and after optimization:
Before (Everything Re-renders)
function Dashboard() {
const [searchQuery, setSearchQuery] = useState('');
const [data, setData] = useState([]);
return (
<div>
<SearchBox
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Sidebar /> {/* Re-renders on every keystroke! */}
<UserStats /> {/* Re-renders on every keystroke! */}
<DataGrid data={data} /> {/* Re-renders on every keystroke! */}
</div>
);
}
After (Only Search-related Components Re-render)
// Memoize expensive components
const Sidebar = React.memo(function Sidebar() {
console.log('Sidebar render');
return <aside>...</aside>;
});
const UserStats = React.memo(function UserStats() {
console.log('UserStats render');
return <div>...</div>;
});
const DataGrid = React.memo(function DataGrid({ data }) {
console.log('DataGrid render');
return <table>...</table>;
});
function Dashboard() {
const [searchQuery, setSearchQuery] = useState('');
const [data, setData] = useState([]);
// Memoize the change handler
const handleSearchChange = useCallback((e) => {
setSearchQuery(e.target.value);
}, []);
return (
<div>
<SearchBox value={searchQuery} onChange={handleSearchChange} />
<Sidebar /> {/* Doesn't re-render! */}
<UserStats /> {/* Doesn't re-render! */}
<DataGrid data={data} /> {/* Only re-renders when data changes! */}
</div>
);
}
Typing in the search box now only re-renders SearchBox. Everything else stays frozen.
The useMemo for Expensive Calculations
useMemo isn’t just for preventing re-renders. It’s also for avoiding expensive recalculations:
function DataAnalytics({ data }) {
// 🚨 Recalculates on EVERY render, even if data didn't change
const stats = calculateComplexStats(data); // Takes 500ms
return <div>Average: {stats.average}</div>;
}
function DataAnalytics({ data }) {
// ✅ Only recalculates when data changes
const stats = useMemo(() => calculateComplexStats(data), [data]);
return <div>Average: {stats.average}</div>;
}
When NOT to Optimize
Here’s a trap I fell into: premature optimization.
I started wrapping EVERYTHING in React.memo, useMemo, and useCallback. Every component, every value, every function.
Don’t do this.
Memoization has a cost:
- Memory to store the cached values
- Comparison logic on every render
- More complex code to read and maintain
For simple components that render fast, the overhead of memoization might be MORE than just re-rendering.
Rules of thumb:
-
Don’t optimize until you have a problem. Use React DevTools Profiler to find actual bottlenecks.
-
Start with React.memo on expensive components. If a component takes > 16ms to render, it’s worth memoizing.
-
Only use useCallback/useMemo for:
- Props passed to memoized components
- Expensive calculations
- Values used in dependency arrays of other hooks
-
Primitives don’t need memoization. Strings, numbers, booleans are compared by value, not reference.
// ❌ Useless - primitives are already stable
const name = useMemo(() => 'John', []);
// ❌ Useless - simple objects that aren't passed to memoized children
const config = useMemo(() => ({ debug: true }), []);
// ✅ Useful - passed to React.memo component
const style = useMemo(() => ({ color: 'blue' }), []);
<MemoizedChild style={style} />
// ✅ Useful - expensive calculation
const sorted = useMemo(() => hugeArray.sort(complexSortFn), [hugeArray]);
The Profiler Is Your Friend
React DevTools has a Profiler tab. Use it.
- Start recording
- Interact with your app
- Stop recording
- Look at the flame graph
You’ll see:
- Which components rendered
- How long each render took
- Why they rendered (props changed? parent re-rendered?)
This tells you where to focus optimization efforts.
Other Performance Wins
Beyond memoization, here are other things that helped:
Virtualization for Long Lists
// 🚨 Rendering 10,000 items
{items.map(item => <Item key={item.id} {...item} />)}
// ✅ Only render visible items
import { FixedSizeList } from 'react-window';
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={50}
>
{({ index, style }) => (
<Item style={style} {...items[index]} />
)}
</FixedSizeList>
Debouncing Input
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery); // Only fetches after typing stops
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Lazy Loading Components
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<Loading />}>
<HeavyChart /> {/* Only loads when needed */}
</Suspense>
);
}
Moving State Down
Instead of lifting state up (which causes parent re-renders), sometimes you can push it down:
// 🚨 Parent re-renders on every input change
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
return (
<div>
<NameInput value={name} onChange={setName} />
<EmailInput value={email} onChange={setEmail} />
<ExpensiveComponent /> {/* Re-renders on every keystroke! */}
</div>
);
}
// ✅ Isolate the changing state
function Form() {
return (
<div>
<NameInput /> {/* Manages its own state */}
<EmailInput /> {/* Manages its own state */}
<ExpensiveComponent /> {/* Never re-renders! */}
</div>
);
}
function NameInput() {
const [name, setName] = useState('');
return <input value={name} onChange={e => setName(e.target.value)} />;
}
What I Wish I’d Known Earlier
-
Profile first, optimize second. Don’t guess where the problem is. Measure it.
-
React.memo is the big win.
useMemoanduseCallbackonly matter when passing props to memoized components. -
Not all re-renders are bad. Re-rendering is what React does. It’s usually fast. Only optimize when it’s actually slow.
-
Composition beats memoization. Sometimes restructuring your components eliminates the need for optimization entirely.
-
The dependency array is critical. Get it wrong and you’ll either have stale values or infinite loops. Trust the linter.
The Journey Continues
Performance optimization was humbling. I thought I understood React, then I watched my app crawl because I didn’t understand how re-rendering worked.
But with React.memo, useMemo, useCallback, and the Profiler, I had the tools to build apps that stayed fast as they grew.
These posts have covered my journey from HTML files to understanding the core of modern React. Components, state, lifecycles, hooks, custom hooks, context, and performance. It’s been a wild ride.
What’s next? Server components, suspense for data fetching, concurrent rendering… React keeps evolving. But the fundamentals? Those stay the same.
P.S. — My biggest performance win wasn’t any of these tools. It was realizing I was fetching the same data 5 times because I didn’t understand when components mounted. Understanding the render cycle > memorizing optimization tricks. Always.
Saurav Sitaula
Software Architect • Nepal