The Trap of Vibe Coding useCallback
Series: How React Works Under the Hood
Part 1: Motivation Behind React Fiber: Time Slicing & Suspense
Part 2: Why React Had to Build Its Own Execution Engine
Part 3: How React Finds What Actually Changed
Part 4: The Idea That Makes Suspense Possible
Part 5: The React Lifecycle From the Inside
Part 6: How State Actually Works
Prerequisites: Read Parts 1–6 first — especially Parts 3 and 6.
Where We Left Off
In Part 6 we saw how useState works — state stored on the Fiber as a hook object, updates queued and processed on the next render, the component function running fresh from the top every time. Every render. From the top.
That last part is what this article is about. Because every render from the top means every value inside the component is recreated. And that single fact is the root cause of most React performance problems — and the reason the most common advice for fixing them often makes things worse.
You Wrapped Everything in useCallback. You Added React.memo to Every Component. Your App Is Still Slow. What Went Wrong?
This is the scene. You open the React DevTools Profiler. Components are re-rendering on every interaction. You've read the articles. You know the tools. So you do what everyone does:
function SearchPage() {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState({});
const handleSearch = useCallback(() => search(query), [query]);
const handleFilter = useCallback((key, val) => setFilters(f => ({...f, [key]: val})), []);
const handleReset = useCallback(() => setFilters({}), []);
const handleExport = useCallback(() => exportResults(query, filters), [query, filters]);
const activeFilters = useMemo(
() => Object.entries(filters).filter(([_, v]) => v),
[filters]
);
return (
<ResultList
onSearch={handleSearch}
onFilter={handleFilter}
onReset={handleReset}
onExport={handleExport}
activeFilters={activeFilters}
/>
);
}
const ResultList = React.memo(({ onSearch, onFilter, onReset, onExport, activeFilters }) => {
// ...
});
You deploy. You open the Profiler again. ResultList is still re-rendering. Some interactions are slower than before.
What went wrong?
This article answers that — from the inside out.
React's Default: Re-render Everything, Skip What It Can
React's mental model has always been simple: when state changes, re-render. Not just the component that changed — all of its children too.
But React has a built-in bailout system we covered in Part 3. When React walks down to a child, it checks one thing before running it:
Are the props the same object reference as last render?
If yes — same reference, not just same values — React skips that component entirely. The child doesn't run. Its children don't run. The entire subtree is skipped in one check. Free, automatic, no tools required.
The problem: in practice, props almost always get new references on every render — even when the values inside them are identical. And this is the root cause of most React performance problems.
Every Render Creates New References Without You Noticing
When a parent component re-renders, it runs its function from the top. Every line executes again:
function SearchPage() {
const [query, setQuery] = useState('');
// ❌ New function object on every render
const handleSearch = () => search(query);
// ❌ New object on every render
const style = { color: 'red', padding: '8px' };
// ❌ New array on every render
const results = data.filter(item => item.active);
return <ResultList onSearch={handleSearch} style={style} results={results} />;
}
Every render, handleSearch is a brand new function. style is a brand new object. results is a brand new array. They look identical — same values, same contents — but they're different objects in memory.
React's bailout uses === reference equality. Old handleSearch !== new handleSearch. So React can't bail out, and ResultList re-renders on every keystroke in the search input — even when the results haven't changed.
The Part Nobody Tells You: Children Re-render Even With No Props
Here's the insight from jser.dev's bailout article that most performance guides miss entirely.
Given this structure:
function SearchPage() {
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultList /> {/* no props at all */}
</div>
);
}
When the user types and SearchPage re-renders, ResultList re-renders too — even though it receives zero props.
Why? Because when SearchPage's function runs, it creates a new React element for <ResultList />. That element object is new. When React's reconciler compares the old pendingProps to the new pendingProps on the ResultList fiber, they're different objects. So React re-renders ResultList. The issue isn't the prop values — it's where the element is created.
This means React.memo on ResultList alone doesn't help here at all.
The Free Fix: Children as Props
This is the most underused performance pattern in React. Zero memoization. Zero dependency arrays. Just a structural change.
// ❌ Before — ResultList re-renders whenever SearchPage re-renders
function SearchPage() {
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultList />
</div>
);
}
// ✅ After — ResultList never re-renders when query changes
function App() {
return (
<SearchPage>
<ResultList />
</SearchPage>
);
}
function SearchPage({ children }) {
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
{children}
</div>
);
}
Why does this work? The <ResultList /> element is now created inside App, not inside SearchPage. When SearchPage's state changes, App doesn't re-render. The children prop that SearchPage receives is the same object reference as before. React's built-in bailout kicks in — same reference, skip ResultList.
No React.memo. No useCallback. No overhead. Just structure.
The React docs state this directly: "When a component visually wraps other components, let it accept JSX as children. This way, when the wrapper component updates its own state, React knows that its children don't need to re-render."
Always try structural fixes before reaching for memoization tools. They're free and they don't break.
When You Actually Need React.memo
Sometimes the structural fix isn't possible. The child genuinely needs to be defined inside the parent and receive real props. That's when React.memo enters.
React.memo wraps your component in a MemoComponent fiber. Instead of checking reference equality on the whole props object, React runs shallowEqual — comparing each individual prop value:
const ResultList = React.memo(({ results, onSearch }) => {
return <div>{results.map(r => <Result key={r.id} data={r} />)}</div>;
});
If results and onSearch have the same values as last time, React bails out. Even if the props object itself is new.
One internal detail worth knowing: when you wrap a simple function component with no custom compare function and no defaultProps, React silently upgrades the fiber tag from MemoComponent to SimpleMemoComponent — a faster internal optimization path. Add a custom compare function and you lose this fast path.
But React.memo alone almost never works. If any prop is a function or object created inline during render, shallowEqual fails on that prop — new reference every time. React.memo bails out on nothing.
This is why in the opening example, ResultList still re-renders even though it's wrapped in React.memo. handleSearch, handleFilter, handleReset, handleExport — all created inline, all new references every render. React.memo sees four changed props and re-renders anyway.
What useCallback and useMemo Actually Do — From the Source
useCallback and useMemo exist for one reason: to stabilize references so React.memo can do its job.
Here's the actual source code for both hooks — mountMemo on initial render and updateMemo on re-render:
// From React source — initial render
function mountMemo(nextCreate, deps) {
const hook = mountWorkInProgressHook(); // create hook in linked list
const nextDeps = deps === undefined ? null : deps;
const nextValue = nextCreate(); // run the factory fn
hook.memoizedState = [nextValue, nextDeps]; // store [value, deps]
return nextValue;
}
// From React source — every re-render
function updateMemo(nextCreate, deps) {
const hook = updateWorkInProgressHook(); // walk to this hook in linked list
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState; // [prevValue, prevDeps]
if (prevState !== null && nextDeps !== null) {
const prevDeps = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0]; // deps unchanged → return cached value
}
}
const nextValue = nextCreate(); // deps changed → recompute
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
updateCallback is identical — the only difference is it stores the function itself instead of calling it.
The dependency comparison runs through areHookInputsEqual:
// From React source — runs on every render for every hook
function areHookInputsEqual(nextDeps, prevDeps) {
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue;
}
return false; // one mismatch → recompute
}
return true;
}
This loop runs on every render, for every hook, for every dependency. It doesn't matter whether deps changed or not — the loop always runs.
The Hidden Cost: What Happens on Every Render
This is what the articles don't tell you.
Even when dependencies haven't changed and useCallback/useMemo return the cached value, React still does this on every render:
-
Walks to this hook in the linked list —
updateWorkInProgressHook()traverses the hook chain every time -
Reads
hook.memoizedState— accesses[prevValue, prevDeps]from memory -
Runs
areHookInputsEqual— loops through every dependency withObject.is -
Allocates a new function (for
useCallback) — React receives the new() => fnyou wrote, then decides whether to discard it
That last point surprises most developers. When you write:
const handleSearch = useCallback(() => search(query), [query]);
JavaScript creates the arrow function () => search(query) before calling useCallback. React receives it, runs areHookInputsEqual, and if deps haven't changed, discards the new function and returns the old one. The allocation happened regardless.
So useCallback(fn, [a, b, c]) with unchanged deps on every render means: one function allocation (discarded), three Object.is comparisons, one linked list traversal. For a component that re-renders 50 times per second during a scroll, that's 150 comparisons per second — for a component that might have been fast to render in 0.1ms anyway.
The Vibe Coding Trap
Here's what actually happens in most codebases.
Someone reads that useCallback prevents re-renders. They add it to every function. They add React.memo to every component. They add useMemo to every derived value. "Just to be safe." Copy-paste from the last project. Vibe coding.
// Real pattern from real codebases
function ProfileCard({ userId }) {
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
const handleFocus = useCallback(() => setFocused(true), []);
const handleBlur = useCallback(() => setFocused(false), []);
const containerClass = useMemo(
() => `card ${hovered ? 'card--hovered' : ''} ${focused ? 'card--focused' : ''}`,
[hovered, focused]
);
return (
<div
className={containerClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
<UserAvatar userId={userId} />
</div>
);
}
This is a card component. It renders in under 0.1ms. Nothing inside it is expensive. UserAvatar doesn't receive any of the memoized values. The containerClass string is trivially fast to compute.
Every single useCallback and useMemo here adds overhead — dependency comparisons, hook traversals, function allocations — for zero performance benefit. The component was never the bottleneck.
And the real cost shows up in debugging. When containerClass produces the wrong value, you now have to trace through useMemo and its [hovered, focused] deps to understand why. When handleFocus behaves unexpectedly, you check the useCallback closure. What was a simple state bug becomes a memoization debugging session.
The React docs are direct about this: "There is no significant harm to doing that either, so some teams choose to not think about individual cases, and memoize as much as possible. The downside of this approach is that code becomes less readable. Also, not all memoization is effective: a single value that's 'always new' is enough to break memoization for an entire component."
The Right Order
Here's the order that actually fixes performance problems:
1. Measure first. Open React DevTools Profiler. Record a session. Find the component that's actually slow — its render time, how often it renders, and why. Don't optimize what you haven't measured. Most of the time the bottleneck is not where you think it is.
2. Fix the structure. Can the slow child's element be created in a parent that doesn't re-render? Can state be moved down closer to where it's used? Can you use the children-as-props pattern? These fixes are free — no memoization overhead, no stale closure risk, no dependency arrays to maintain.
3. Memoize if structure can't fix it. If structural fixes aren't possible and the component is genuinely expensive to render and receives props that could be stable — then reach for React.memo + useCallback/useMemo together. All three are needed. React.memo without stable references does nothing. useCallback without React.memo on the child does nothing.
4. Verify it worked. Re-run the Profiler. Confirm the component no longer re-renders unnecessarily. If it still does, find what reference is still unstable and trace it back.
The rule, in one sentence: don't add memoization until you have a specific, measured component that re-renders too often, with expensive renders, and props that can be stabilized.
A Note on the React Compiler
The React Compiler (previously React Forget) is now stable. It's a build-time Babel plugin that automatically applies memoization where it's safe — analyzing your components, inferring dependencies, and inserting optimizations without you writing any of it.
But it only works on components that follow the Rules of React: pure renders, no prop mutation, no non-deterministic values during render. Code that follows these rules gets automatic optimization. Code that doesn't gets de-opted — the Compiler falls back and does nothing.
Here's the important point: understanding everything in this article — why references matter, why structural fixes come first, why useCallback has a cost — is exactly what you need to write code the Compiler can optimize. The Compiler doesn't replace understanding React's rendering model. It rewards you for having it.
What's Coming in Part 8
In Part 8 we go into Server Components and Hydration — how React renders on the server, streams the result to the client, and hands off interactivity without re-doing all the work. The rendering model we've built throughout this series extends to the server in some surprising ways.
🎬 Watch These
JSer (jser.dev) — How does React bailout work in reconciliation?
The source for the "children re-render even with no props" insight and the children-as-props bailout pattern. The demo shows exactly which components re-render and why.
JSer (jser.dev) — How does React.memo() work internally?
The MemoComponent vs SimpleMemoComponent internal distinction and shallowEqual — the source for the React.memo internals section.
🙏 Sources & Thanks
jser.dev — the "children re-render even with no props" insight and the children-as-props bailout technique come directly from How does React bailout work?. The
MemoComponent/SimpleMemoComponentdistinction from How does React.memo() work internally?React source —
mountMemo,updateMemo,mountCallback,updateCallback, andareHookInputsEqualare taken directly frompackages/react-reconciler/src/ReactFiberHooks.jsin the facebook/react repo. Verified via multiple source walkthroughs.React docs — the children-as-props principle and the "not all memoization is effective" quote come directly from react.dev/reference/react/memo. The React Compiler section draws from react.dev/learn/react-compiler.
Lydia Hallie — for JavaScript visualizations that shaped this series' style.
Part 8 is next — Server Components and Hydration: how React moved rendering to the server without breaking the client model. 🔧
Tags: #react #javascript #webdev #performance #tutorial




Top comments (0)