React’s default is simple: when a component re-renders, all of its children re-render too. That is usually fine — re-rendering is cheap and React only touches the real DOM where output actually changed. But for expensive subtrees you sometimes want to skip the work. These five demos build up the full mental model, with a live render counter on every component so you can see the difference instead of reading the console.
Each badge counts how many times that component has rendered. Click the buttons and compare how the App and Child counters move. (In development React may render twice; the production build counts once per commit.)
The demos
1 · The base case
When a component’s state changes, React re-renders that component and every child it returns — by default, regardless of whether the child actually depends on what changed. Click increment and watch both counters climb together.
App·1 renderChild·1 render
count0
Child re-renders on every click even though it takes no props and shows nothing dynamic. Its render count rises in lock-step with App — wasted work React did not need to do.
Wrapping a component in React.memo tells React: “only re-render this if its props change.” This child takes no props, so after the first render it never needs to run again. Click increment — App climbs, Child stays put.
App·1 renderChild·1 render
count0
Child stays at 1 render no matter how many times you click. React.memo did a cheap props comparison, saw nothing changed, and reused the previous output.
TSX
1
function Child() {
2
return <div>Child Component</div>;
3
}
4
5
// React.memo skips the re-render when props are unchanged.
React.memo only helps when the props actually stay the same. Here we pass value={count}, which is different on every click — so memo compares, sees a new value, and re-renders the child anyway. Memo is a guard, not a freeze.
App·1 renderChild (value=0)·1 render
count0
Child climbs in step with App again. The takeaway: memo is only as good as the stability of the props you pass it — which is exactly what useCallback and useMemo (next) fix.
{/* value changes every click → memo can't skip */}
14
<MemoizedChild value={count} />
15
</>
16
);
17
}
4 · useCallback stabilizes a function prop
Functions are recreated on every render, so passing one as a prop normally defeats memo (a brand-new function looks like a changed prop). useCallback memoizes the function so its identity is stable, and the memoized child stops re-rendering. The Child badge itself is the button — clicking it also increments.
App·1 render
count0
Child holds at 1 render. The combo is the rule of thumb: memo on the child + useCallback on every function prop you hand it.
// Without useCallback, this function is a NEW value every render,
11
// so memo sees a "changed" prop and re-renders the child.
12
const handleClick = useCallback(() => {
13
setCount(c => c + 1);
14
}, []); // stable identity across renders
15
16
return (
17
<>
18
<p>Count: {count}</p>
19
<MemoizedChild onClick={handleClick} />
20
</>
21
);
22
}
5 · useMemo stabilizes an object prop
The same trap applies to objects and arrays: an inline { theme: "paper" } is a new reference every render. useMemo caches the object so its identity is stable, keeping the memoized child quiet even though we pass it both a function and an object.
App·1 renderChild·1 rendertheme: paper
count0
Child stays at 1. Full recipe for a quiet memoized child: memo the child, useCallback its function props, and useMemo its object/array props — every reference it receives must be stable.
1. By default, a parent render re-renders all children. 2.React.memo skips a child whose props are unchanged. 3. Inline functions and objects are new references every render, which silently defeats memo. 4.useCallback stabilizes function props; useMemo stabilizes object/array props. Reach for these only when a real render cost shows up — not everywhere by default.