Your useMemo Probably Isn't Doing Anything
One rule explains every React re-render. Get it right and memoization becomes the last fix, not the first.
I’ve reviewed React PRs where useMemo shows up without any concrete reason, just in case. I understand the instinct. Re-renders feel scary when you can’t predict them.
But once you add code that doesn’t bring any value, it’s just fluff. However, someone else will still need to read it, understand it, and reason about it. So it’s not actually free.
The “memoize everything” default isn’t always the answer.
To understand when it actually helps, you need to understand one rule about when React re-renders, and once you have it, the right fix usually picks itself. It’s rarely memoization.
Share this post & I’ll send you some rewards for the referrals.
📌 TL;DR
A render is a function call, not a DOM update. React calls your component, compares the result, usually touches nothing.
Three things trigger a render: its own state, a context it reads, or its parent handing it a new element.
Props aren’t on that list.
React.memois what makes props matter.memocan’t stop two of the three. Own state and context go straight through it.Dev mode lies. StrictMode renders twice on purpose. Measure a production build.
Fix in order: move state down → children as children → stabilize refs →
useMemo→memo. Most problems die on the first two.The compiler does the memo half. It can’t move your state.
A render is a function call, not a DOM update
The word “render” does two jobs, and that’s where half the confusion lives.
When React renders your component, it calls your function. Nothing more. Your function returns a description of UI, made of plain JavaScript objects. React compares that description to the previous one, and only the differences touch the DOM. That second part, the commit, is the expensive one.
A component can therefore render fifty times and update the DOM zero times. Building a few objects fifty times is nothing; browsers eat that for breakfast.
So “my component re-renders too much” is usually a non-problem. The question worth asking is: Does any render do expensive work?
Three things trigger a render. Props aren’t one of them
Its own state changed (
setState, a dispatch, a store it subscribes to).A context it reads changed value.
Its parent rendered and gave it a new element.
The third one carries the most weight, so it’s worth slowing down on. Every time your JSX runs, it creates fresh element objects. Hand React a new element for a child and the child re-renders. Hand it the exact same object and React skips that whole subtree, with no props comparison and no memo involved. Anything inside that subtree with its own state update or context change still renders on its own, though. The skip stops the parent’s ripple; it doesn’t freeze the branch.
Notice what’s missing from that list. Changing props isn’t on it, because props don’t trigger re-renders. When a parent re-renders, its children re-render whether their props changed or not:
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{/* Same props every time. Still re-renders on every click. */}
<Child name="static" />
</>
);
}Fair pushback: a prop can only change if the parent rendered, so in practice the two travel together. That’s true, and the causality still tells you where to fix. You don’t stop a re-render by fiddling with props. You change who renders, or you hand React the same element back.
Props only matter once you wrap a component in React.memo. Then React compares them to decide whether to skip. Memo is the exception that makes people think props were the rule.
Three things that look like bugs but aren’t:
memodoesn’t stop triggers 1 and 2. A memoized component still re-renders on its own state and on a context it reads. Memo only compares props from the parent. When someone says “my memo isn’t working,” that’s the first thing I check.Setting state to the value it already has isn’t an update. React compares with
Object.isand bails. It may still call your component once before it figures that out.A changed
keyisn’t a re-render. It throws the old component away and mounts a fresh one, state and all.
One more fact about direction. Renders only travel down. A child re-rendering never re-renders its parent.
Most renders are cheap, so measure before you fix
Three renders that actually cost something:
Big lists. 5,000 rows re-rendering because one changed.
Heavy work in the render. Sorting or transforming a large array on every call.
An expensive subtree re-rendering for unrelated state. A text input’s state living high in the tree, dragging a heavy sibling along on every keystroke.
The fix for the first one isn’t memoization, it’s rendering fewer rows. Virtualize the list, or paginate it. No amount of React.memo makes 5,000 rows cheap.
Don’t fix what you haven’t measured. Open the DevTools Profiler, record the slow interaction, read the flame graph. Width is time, yellow bars are slow, and grey means the component never rendered. React Scan is faster for a first pass, since it outlines components as they render, right on the page.
Don’t trust dev-mode numbers either. StrictMode renders twice on purpose, and dev builds carry overhead React strips in production. Take real timings from a production build with profiling turned on. Plenty of “performance problems” are just dev mode being dev mode.
If the profiler doesn’t show a problem, there is no problem. Close the tab and ship.
The ladder: structure first, memoization last
Once the profiler does point at something, fix it in this order. Most real problems die on steps 1 and 2.
1. Move state down
State should live as close as possible to where it's used. If only the header cares about scroll position, the page shouldn't hold it.
// ❌ Page owns scroll state → every scroll event re-renders the product list too
function Page() {
const scrollY = useScrollY(); // custom hook: subscribes to scroll, calls setState
return (
<>
<Header isCompact={scrollY > 100} />
<ExpensiveProductList />
</>
);
}Move useScrollY() into a ScrollAwareHeader and Page renders once. Same feature, no memoization, and the expensive sibling is out of the blast radius. This one move fixes more React performance problems than every hook in the memo family combined.
2. Pass children as children
Sometimes the component genuinely needs the state and wraps something expensive. Take the expensive part as children:
function ScrollContainer({ children }: { children: ReactNode }) {
const scrollY = useScrollY(); // re-renders this component on every scroll...
return <div className={scrollY > 100 ? 'compact' : ''}>{children}</div>;
}
function Page() {
return (
<ScrollContainer>
{/* ...but Page isn't re-rendering, so this element never changes */}
<ExpensiveProductList />
</ScrollContainer>
);
}This is trigger #3 in reverse. Page created the element and Page isn’t re-rendering, so ScrollContainer hands React the same object back and the subtree gets skipped. Composition does the work here, with no memo involved.
There’s one way to lose it by accident. Turn children into a render prop, and the function runs during the wrapper’s render, builds a fresh element, and the bailout is gone.
3. Stabilize the references you create
Every render creates fresh objects: style={{ margin: 8 }}, onSelect={() => ...}, options={[...]}. Harmless — unless they feed a memoized child, a provider’s value, or a dependency array. Then a new object every render quietly defeats all three.
Watch provider values especially. <ThemeContext value={{ user, theme }}> written inline re-renders every consumer every time the provider renders. That one’s bitten every team I’ve worked with.
4. useMemo and useCallback
Now, and only now, the hooks everyone starts with. They’re right when the profiler shows expensive work, or when a memoized child needs a stable prop. They’re wrong as seasoning, because each one is a dependency array to maintain and a bug surface when it drifts.
useMemo is a hint, not a guarantee. React can throw the cached value away, so never lean on it for correctness.
5. React.memo
This is the genuine last resort. It earns its slot on expensive components that re-render often with unchanged props, like a heavy chart or a big table row.
It’s a contract, though, and children is the most common way to break it. Wrap a component in memo while its parent hands it JSX and the shallow compare fails every single render. Memoizing the wrapper does nothing; memo the expensive child itself.
When the render really is heavy
Sometimes the work is genuinely expensive and genuinely necessary. Filtering 50,000 rows as someone types. You can’t structure that away.
useDeferredValue and startTransition exist for exactly this. They don’t make the render faster; they stop it blocking the keystroke. The input updates now, the heavy list catches up a beat later.
There’s a twist worth knowing. useDeferredValue only works if the slow child can skip a render, and React’s docs are explicit about it: the child has to be wrapped in memo, or there’s nothing to skip and you’ve gained nothing.
So memo isn’t useless. It has one real job, on a component you actually measured.
The compiler does the memo half, not the structural half
The React Compiler went stable in October 2025 and auto-memoizes at build time. Doesn’t that make all this moot?
Half of it. What the compiler does is the same rule over again. It doesn’t wrap components in React.memo, it memoizes values and the JSX elements inside them. The child element stays identical, so React bails out. Trigger #3, automated, and more precisely than you’d manage by hand. It even memoizes across early returns, where useMemo can’t reach.
Two things it can’t do. It can’t restructure your tree, so it won’t move state down or rewrite a component to take children. It also assumes your code follows the Rules of React; a component that mutates during render gets its bug surfaced rather than fixed.
The structural half of the ladder stays your job, which is convenient, because that’s the half that makes the code better rather than only faster.
Re-renders aren’t the enemy. They’re React doing its job, cheaply, most of the time. The enemy is the expensive render nobody measured, plus the pile of memoization nobody needed.
Learn the three triggers and climb the ladder in order. Most of the time you’ll never reach the top, and the code you end up with will be simpler than what you started with. That’s usually the sign you fixed the right thing.
Follow me on LinkedIn | Twitter(X) | Threads
Thank you for supporting this newsletter.
Consider sharing this post with your friends and get rewards.
You are the best! 🙏



