Re-render problems in React usually come from one of two places: state that lives too high in the tree, or components rendered inside a parent that doesn't need to own them. Fix the structure first. Memoize what's left.
This article covers the structural fix, how reorganizing component dependencies reduces unnecessary re-renders before you touch a single memo or useCallback.
Isolating components from parent state
The first question to ask is: which components in this tree actually need the parent's state?
Any component that doesn't depend on the parent's state is a candidate for isolation. Once isolated, it becomes its own unit, with its own logic, its own internal state if needed, and no reason to re-render when the parent updates.
The most common mistake is rendering those components directly inside the parent's render function:
// Parent.tsx — Child is created inside Parent's render
import Child from "./Child";
export default function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<Child />
</div>
);
}
Child doesn't use count, but it re-renders every time count changes. The fix is to pass Child as a children prop instead of rendering it directly:
// App.tsx — Child is created outside Parent
<Parent>
<Child />
</Parent>
// Parent.tsx — renders whatever is passed in
export default function Parent({ children }: PropsWithChildren) {
const [count, setCount] = useState(0);
return <div>{children}</div>;
}
Now Parent can update as many times as it needs to. Child won't be touched.
If a component genuinely needs the parent's state, a handler, a derived value, a prop, this approach doesn't apply. That's a different problem, and memoization is the right tool for it.
Why composition comes first
Memoization works. memo, useCallback, and useMemo are legitimate tools and React ships them for a reason. The argument isn't that you should avoid them, it's that reaching for them before fixing the component structure usually means solving the wrong problem.
Consider this:
export default function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []);
return (
<div>
<ExpensiveChild onClick={handleClick} />
<button onClick={() => setCount((c) => c + 1)}>Update</button>
</div>
);
}
useCallback stabilizes handleClick so ExpensiveChild doesn't re-render on every count update. It works, until requirements change and handleClick needs to read from a value that updates:
const handleClick = useCallback(() => {
console.log(count); // needs count now
}, []); // stale closure — count is always 0
The dependency array is now wrong. count is missing from it, so handleClick always reads the initial value. You fix it by adding count to the array, which causes handleClick to be recreated on every count change, which causes ExpensiveChild to re-render anyway, the memoization bought nothing.
This is the maintenance cost. Every dependency array is a contract you have to keep updated as the component evolves. Miss one, and you have a stale closure bug that's silent until it isn't.
If ExpensiveChild doesn't actually need to live inside Parent, the composition approach from the previous section removes the problem entirely, no useCallback, no dependency array, no contract to maintain. Memoization becomes necessary only for components that genuinely can't be isolated from their parent's state.
Conclusion
Component composition isn't a performance trick, it's a structural decision. When your component tree is organized around actual dependencies, memoization stops being a patch and starts being a precision tool used in the few places that genuinely need it.
Most re-render problems aren't asking for more memoization. They're asking for a cleaner structure. Fix that first, and you'll find the surface area that actually needs memo is much smaller than it looked.
You can explore the before/after examples from this article at github.com/Jancera/react-component-composition.
Top comments (0)