React Performance: Finding and Fixing Slow Components
I walk through the React DevTools Profiler to find slow components, explain React.memo, useCallback, and useMemo, and show when to skip memoisation entirely.

React re-renders look wasteful on paper. A parent updates, and every child function runs again — even the ones whose output didn’t change. Most tutorials treat this as a problem and point straight to React.memo, useMemo, and useCallback. The real picture is different.
Most re-renders are so fast they’re invisible. The ones that actually hurt — the ones that make typing feel laggy or a click feel delayed — are usually a handful of specific components doing expensive work. Those are rarely the ones you’d guess without measuring.
I’ll walk through how to find the real bottlenecks using the React DevTools Profiler, explain what each memoisation API actually does, and show a component composition pattern that often makes memo unnecessary.
On this page
- How React decides to re-render
- Find the slow component before you fix it
- React.memo and the inline-function trap
- useCallback and useMemo: the missing half of memo
- The children pattern that skips memo entirely
- When memoisation makes things worse
- Profile first, then fix
How React decides to re-render
A component re-renders when one of three things happens: its own state changes, a context it reads changes, or its parent re-renders. That third trigger is what catches people off guard.
When a parent re-renders, React calls every child component function again. This is the default behaviour, and it exists for a reason — React can’t know in advance which children depend on what changed, so it runs them all and then compares the output. During the final “commit” step, React only updates the DOM nodes that actually differ. A child that re-renders but produces the same JSX doesn’t cause visible DOM work.
That distinction matters. The question isn’t “did this component re-render?” — it’s “was that re-render expensive?” For most components, re-running a function that returns a bit of JSX takes microseconds. The ones that hurt are those doing heavy computations, rendering large lists, or triggering layout work on every pass.
This is covered in more depth in React Fundamentals: Components, Props, and State and React Hooks You’ll Actually Use Every Day. This post picks up where those leave off.
Find the slow component before you fix it
The React DevTools Profiler is the right place to start. It shows you which components are slow, how long they took, and why React re-rendered them. Without it, you’re guessing.
To use it:
- Install React DevTools (available as a browser extension for Chrome and Firefox).
- Open DevTools and click the Profiler tab.
- Hit the circular record button.
- Perform the interaction that feels slow — type in an input, open a dropdown, click a button.
- Hit Stop.
The Profiler shows a Flame chart by default. Each horizontal bar is a component. Width represents how long it took to render its full subtree. Yellow bars are the slowest, blue bars are fast, and gray bars didn’t re-render at all during this commit.
Click the Ranked chart tab to see the same data sorted: slowest component at the top, fastest at the bottom. That’s usually where I start when I don’t have a clear suspect.
Click on any component and you’ll see a detail panel on the right with the most useful piece of information: why did this render? It’ll tell you if props changed, state changed, or if the parent just re-rendered. That last case — “this component rendered because its parent rendered” — is the signal that memoisation might help.
Try this
- Open a React project in your browser with React DevTools installed.
- Click the Profiler tab and start recording.
- Interact with whatever feels slow — type a few characters, click a few things.
- Stop recording and click the tallest yellow bar.
Expected result: You’ll see the component’s name, how long it took to render, and the reason React re-rendered it. If it says “this component rendered because its parent rendered,” you now know where to look.
React.memo and the inline-function trap
React.memo wraps a component and tells React: only re-render this if its props changed. It uses shallow equality — specifically Object.is — to compare each prop with its previous value.
const PriceTag = memo(function PriceTag({ amount, currency }) {
return (
<span>
{currency}{amount.toFixed(2)}
</span>
);
});
Now PriceTag skips re-rendering when amount and currency haven’t changed, even if its parent renders constantly for unrelated reasons.
The catch — and it catches a lot of people — is inline functions and objects in props:
// Every render creates a brand-new function reference
function ProductCard({ id, title, price }) {
return (
<PriceTag
amount={price}
currency="$"
onDisplay={() => trackView(id)} // new function on every render
/>
);
}
JavaScript creates a new () => {} on every render, the same way {} always creates a new object. From React’s perspective, onDisplay is always a different prop — the shallow equality check fails, and PriceTag re-renders on every parent render. memo is completely useless here.
The React docs make this explicit: memoisation is only effective if the props passed to the memoised component are stable between renders. If any prop is recreated on every render, the comparison always fails.
useCallback and useMemo: the missing half of memo
useCallback solves the inline-function problem. It caches the function definition and only creates a new one when its dependencies change:
function ProductCard({ id, title, price }) {
const handleDisplay = useCallback(() => {
trackView(id);
}, [id]); // creates a new function only when id changes
return (
<PriceTag
amount={price}
currency="$"
onDisplay={handleDisplay}
/>
);
}
Now handleDisplay is the same reference across renders as long as id doesn’t change. The memo comparison on PriceTag passes, and the re-render is skipped.
useMemo does the same for computed values. If you’re passing an object or array as a prop to a memoised component, the reference changes on every render unless you stabilise it:
function SearchPage({ query, filters }) {
const searchOptions = useMemo(
() => ({ query, caseSensitive: false, filters }),
[query, filters] // new object only when query or filters change
);
return <ResultList options={searchOptions} />;
}
Without useMemo, searchOptions is a new object on every render. Even if ResultList is wrapped in memo, it still re-renders every time because options always fails the shallow equality check.
The pattern is: memo on the component, useCallback for function props, useMemo for object and array props. They work together or not at all.
Check this before moving on
- The child component is wrapped in
memo - Every function prop passed to it is wrapped in
useCallback - Every object or array prop is wrapped in
useMemo - Dependency arrays include every value the function or calculation uses
The children pattern that skips memo entirely
Here’s something that memoisation discussions often bury: if state only affects one part of a component, extracting that part into its own component and passing everything else as children keeps those children completely outside the re-render cycle — no memo required.
A common situation: a search input holds state, and an expensive list sits nearby in the same component tree.
// SearchPage re-renders on every keystroke,
// which causes UserList to re-render too
function SearchPage() {
const [query, setQuery] = useState('');
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search users..."
/>
<UserList />
</div>
);
}
The instinct is to wrap UserList in memo. But there’s a simpler fix: move the state into a smaller component and pass UserList in from outside as children:
function SearchInput({ children }) {
const [query, setQuery] = useState('');
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search users..."
/>
{children}
</div>
);
}
function SearchPage() {
return (
<SearchInput>
<UserList />
</SearchInput>
);
}
When SearchInput’s state updates, React re-renders SearchInput. But children — the <UserList /> JSX node — was created by SearchPage, not by SearchInput. React already has it from the previous render and doesn’t need to call UserList again to know it hasn’t changed.
This is the composition pattern described in Building Reusable React Components That Last. It works because the children JSX is owned by the parent, which didn’t re-render. No comparison overhead, no dependency arrays to maintain.
The React docs actually list this approach first in their guidance on avoiding unnecessary memoisation, before memo and useCallback even enter the picture. It’s worth trying before reaching for the APIs.
When memoisation makes things worse
memo, useCallback, and useMemo all have overhead. memo runs a comparison on every parent render. useCallback and useMemo maintain cached values and dependency arrays. If the component was cheap to re-render, or if the props change on nearly every render anyway, you’ve added cost for no benefit.
The React docs put this clearly: memoisation is only valuable when a component re-renders often with the same props and the re-render is genuinely expensive. Most components don’t satisfy both conditions simultaneously.
There’s also a case where memo provides no help at all: re-renders driven by context changes. memo only prevents re-renders caused by parent prop changes. If a component reads from a context that updates frequently, it’ll re-render every time that context value changes — memo won’t stop that. The fix there is architectural: either split the context into smaller, more targeted pieces, or read only the specific value your component needs rather than the whole context object. The dependency-management patterns in useEffect Explained are useful background for thinking through this.
| Situation | Recommended approach |
|---|---|
| Child re-renders because parent re-renders | React.memo + stable props |
| Function prop breaks memo | useCallback |
| Object or array prop breaks memo | useMemo |
| Unrelated state causes children to re-render | Children composition pattern |
| Context updates cause frequent re-renders | Split context or derive minimal values |
Profile first, then fix
The mental shift that makes performance work productive is the same one debugging requires: measure before you act. Adding React.memo without checking the Profiler is the React equivalent of adding indexes to every database column before looking at slow query logs — it might help by accident, but it’s more likely to add noise.
The practical order:
- Open the Profiler and record the interaction that feels slow.
- Find the slowest component — the Ranked chart sorts them for you.
- Check why it re-rendered — the component detail panel shows the reason.
- Then choose:
memowith stable props, the children composition pattern, or move state closer to where it’s used.
One note about where things are heading: React 19 includes the React Compiler, which automatically applies memoisation across component trees. For projects on React 18, the manual approach here still applies in full. Either way, the Profiler remains the right entry point — it tells you where to look, whether you’re applying optimisations by hand or relying on the compiler to do it.