A story about chasing unnecessary renders, the tools that exposed them, and the patterns that finally made them stop.
Our app had a search bar. It worked fine. It felt fine. Until a designer on the team — curious, with DevTools open — asked why the sidebar, the header, the notification badge, and a completely unrelated recommendations panel all lit up in the React DevTools highlighter every time she typed a single character into it.
She wasn't filing a bug. She was just confused. I was too, until I sat down and traced it.
That was the beginning of three weeks I now think of as re-render hell. Not because the app was broken — it wasn't. Performance was "fine." But once you see it, you can't unsee it: a 400-component tree thrashing on every keystroke, most of it doing absolutely nothing meaningful, all of it eating time on lower-end devices that our users in Tier-2 cities actually used.
What follows is the short version of what we found, what caused it, and — more importantly — what actually fixed it.
First: how to see what's actually happening
You can't fix what you can't see. Before you change a single line of code, turn on the React DevTools profiler and enable "Highlight updates when components render." It's the flame icon in the Components panel. Every component that re-renders flashes a colored border. Watch it while you interact with your app.
What you're looking for: components lighting up that have no business lighting up. A sidebar re-rendering when you type in a search box. A header re-rendering when you toggle a modal. A card component re-rendering when a completely different card updates.
In our case, the React DevTools highlighter looked like a Christmas tree. Almost everything was re-rendering on almost every interaction.
The next tool in the chain is why-did-you-render — a small library that patches React and logs to the console exactly why a component re-rendered: which props changed, which state changed, whether it was a context update. Install it once in development, point it at the components you're suspicious of, and let it tell you the truth.
// wdyr.js — import this at the top of your index.js
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true,
});
}
What the console showed us was humbling. Components re-rendering with prevProps === nextProps. Components re-rendering because a context value object was recreated every render, even though the underlying data hadn't changed. Components re-rendering because a callback was being defined inline and failing referential equality on every pass.
The problem wasn't one thing. It was a category of thing: we were creating new references constantly, and React was treating them as new values.
The root cause nobody talks about plainly
React re-renders a component when its state changes, its parent re-renders, or a context it subscribes to updates. That last one is the silent killer, and it's almost always the same underlying mistake.
Here's the pattern we had everywhere:
// AuthContext.jsx
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState([]);
return (
<AuthContext.Provider value={{ user, permissions, setUser }}>
{children}
</AuthContext.Provider>
);
}
Looks fine. The problem: every time AuthProvider re-renders — for any reason — it creates a new value object. New object reference = React assumes the context changed = every consumer re-renders. Every. Single. One.
On our app, AuthContext was consumed in 34 components. Every top-level state change anywhere near the provider triggered 34 re-renders across the tree, most of them pointless.
The fix was two things used together: useMemo to stabilize the value object, and — more importantly — splitting the context.
The fix that helped most: split your contexts
The most impactful architectural change we made was splitting monolithic contexts into smaller, focused ones. Instead of one AuthContext that held user data, permissions, and setters, we split it:
// Separate stable data from volatile data
const UserContext = createContext(null); // rarely changes
const PermissionsContext = createContext([]); // changes on role updates
const AuthActionsContext = createContext(null); // never changes (stable callbacks)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState([]);
const actions = useMemo(() => ({ setUser }), []); // stable forever
return (
<AuthActionsContext.Provider value={actions}>
<UserContext.Provider value={user}>
<PermissionsContext.Provider value={permissions}>
{children}
</PermissionsContext.Provider>
</UserContext.Provider>
</AuthActionsContext.Provider>
);
}
Now a component that only needs setUser subscribes to AuthActionsContext and never re-renders when the user object updates. A component that reads user doesn't re-render when permissions change. Surgical updates instead of broadcast updates.
We applied this pattern to every context in the app. The re-render count on a typical interaction dropped by roughly 60% overnight. No logic changed. No UX changed. Just topology.
memo, useMemo, useCallback — and when they're actually worth it
After the context work, we started reaching for React.memo, useMemo, and useCallback more deliberately. The keyword there is deliberately. I've seen codebases where every component is wrapped in memo and every function is wrapped in useCallback as a cargo-cult reflex. That's not optimization — that's noise. Each of these has a cost (the comparison itself, the closure overhead), and if the component is cheap to render or re-renders rarely anyway, you're paying a cost for no benefit.
The mental model that helped us decide when to apply them:
React.memo is worth it when a component is expensive to render and its parent re-renders frequently and its props are often the same between those re-renders. A list item in a 200-item list, for example. A sidebar that re-renders whenever any top-level state changes but whose own props almost never change.
const ProductCard = React.memo(function ProductCard({ product, onAddToCart }) {
// Expensive render — reads from a selector, renders a complex layout
return (...);
});
useCallback is worth it specifically when you're passing a function as a prop to a memoized child. Without it, a new function reference on every parent render defeats the memo entirely.
// Without this, ProductCard's memo is useless — new function ref every render
const handleAddToCart = useCallback((productId) => {
dispatch({ type: 'ADD_TO_CART', payload: productId });
}, [dispatch]);
useMemo is worth it for expensive computations that are called during render — filtering a large list, computing derived state, building a complex data structure. It is not worth it for simple object construction unless that object is a context value or a prop passed to a memoized child.
The question I ask before adding any of these: "What re-renders am I preventing, and is that render actually expensive?" If I can't answer both parts, I don't add the wrapper.
The inline function trap
This one is small but it's everywhere and it compounds:
// This creates a new function on every render of ParentList
return items.map(item => (
<Item key={item.id} onClick={() => handleSelect(item.id)} />
));
If Item is memoized, this defeats the memo. Every render of ParentList produces a new arrow function, a new prop reference, and a re-render of every Item in the list.
The fix is either useCallback with a stable identity, or — often cleaner — passing the handler and the id separately and letting the child call onSelect(id):
const handleSelect = useCallback((id) => {
setSelected(id);
}, []);
return items.map(item => (
<Item key={item.id} id={item.id} onSelect={handleSelect} />
));
Now handleSelect is the same reference across renders. Item doesn't re-render unless its id or onSelect actually changes.
The monitoring habit that caught regressions
We learned the hard way that performance work without monitoring is just gardening — you trim things back and they grow again. After three weeks of cleanup, we added two habits:
First, we kept why-did-you-render wired up in our dev environment, permanently. Any PR that causes a suspicious re-render shows up in the console during review. It became a lightweight CI check without any tooling overhead.
Second, we added React DevTools profiling to our pre-release checklist for any feature that touched shared state or context. The rule: record a profiling session of the core interaction, look at the flame chart, flag any component that renders more than twice for the same user action. It takes five minutes. It's caught three regressions in the months since.
What I'd tell past-me at the start of this
Don't start with memo and useCallback. Start with the DevTools highlighter and why-did-you-render, because the real answer is almost always upstream — a context that's too wide, a value object that's recreated too often, a state that's placed too high in the tree. Wrapping symptoms in memo is like putting a rug over a leak. It hides the puddle. It doesn't fix the pipe.
The other thing: re-render problems are architecture problems in slow motion. They usually trace back to decisions made early — where state lives, how context is structured, what gets colocated — and they compound as the app grows. The earlier you take them seriously, the cheaper they are to fix.
We have a standing rule now: any new context gets reviewed for how many components will consume it and whether it can be split. It takes ten minutes in a PR. It's saved us weeks of profiling work.
Found this useful? A reaction helps other developers find it. I write about React, frontend architecture, and the unglamorous parts of shipping software at scale.
Top comments (0)