The Hidden Cost of Wrappers
Every time you call useCallback, useMemo, or wrap a component in memo, you aren't removing work. You're trading standard JavaScript execution for memory allocation and runtime diffing. React has to store your dependency arrays inside internal fiber nodes, step through those arrays on every single render, and execute Object.is on each item.
Instantiating an inline arrow function in modern V8 takes around 0.0001ms. Storing a dependency array, invoking useCallback, and running dependency array comparisons takes significantly more overhead. If your component tree isn't re-rendering hundreds of times per second, or if the child component renders in under 1ms anyway, your wrapper is making your application slower, not faster.
How Shallow Equality Breaks Your Guarantees
The biggest problem with manual memoisation isn't just the execution cost; it's that it frequently fails silently. React.memo performs a shallow comparison of incoming props. If any prop changes its reference identity between renders, the memoisation bypasses completely, giving you zero render savings while still paying the cost of prop comparison.
Here is a classic broken implementation I see in production Next.js 16 codebases:
import { memo, useState, useCallback } from 'react';
// Parent component passing unstable references
export function UserDashboard({ user }) {
const [query, setQuery] = useState('');
// Unstable object reference created on every render
const filterConfig = { activeOnly: true, search: query };
// Broken callback because filterConfig changes identity on every render
const handleFilterChange = useCallback((newQuery) => {
setQuery(newQuery);
}, [filterConfig]);
return (
<div>
<SearchInput onSearch={handleFilterChange} />
<UserProfileCard user={user} config={filterConfig} />
</div>
);
}
// UserProfileCard is wrapped in memo, but re-renders EVERY time because config is a new object
export const UserProfileCard = memo(function UserProfileCard({ user, config }) {
return (
<div className="card">
<h3>{user.name}</h3>
<p>Filter: {config.search}</p>
</div>
);
});In this example, UserProfileCard re-renders on every keystroke in SearchInput because filterConfig gets recreated as a new object literal on every render pass. React.memo runs its shallow comparison, sees that prevProps.config !== nextProps.config, and proceeds to re-render the card anyway. You pay for the memoisation check and the full component render.
Composition Beats Memoisation
Before reaching for react memo usememo, change your component architecture. React gives us pattern primitives like pushing state down or passing components as children. Composition completely eliminates re-renders without adding dependency tracking overhead.
Here is how to refactor the broken dashboard using simple composition principles:
import { useState } from 'react';
// Isolated state component: typing here only re-renders SearchBox
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
onSearch(e.target.value);
}}
/>
);
}
// Parent component no longer holds input state and doesn't re-render on keystrokes
export function UserDashboard({ user }) {
const handleSearch = (query) => {
// Perform API call or trigger routing
};
return (
<div>
<SearchBox onSearch={handleSearch} />
<UserProfileCard user={user} />
</div>
);
}
// Simple, un-memoised component. Doesn't re-render when SearchBox updates!
export function UserProfileCard({ user }) {
return (
<div className="card">
<h3>{user.name}</h3>
</div>
);
}Notice what happened here. We removed memo, useMemo, and useCallback entirely. By isolating state inside SearchBox, typing inside the input field updates SearchBox alone. UserDashboard and UserProfileCard do not execute at all during typing. Zero hook overhead, zero equality checks, zero maintenance burden.
Measuring Render Cost with React DevTools
Don't guess where bottlenecks live. Open Chrome DevTools, install the React Developer Tools extension, and switch to the Profiler tab. Turn on "Highlight updates when components render" in settings, then record a performance profile while interacting with your UI.
Look at two key metrics on the commit flamegraph:
- Render duration: How long a component spent executing its render function. If this number is under 1ms, memoising it will offer no visible UI benefit.
- Why did this render? Enable this setting in Profiler preferences. It explicitly states whether a render was triggered by parent render, state change, or context update.
If the profiler shows a component takes 0.2ms to render and renders twice per user click, wrapping it in memo saves 0.4ms across an entire interaction. Human perception threshold for UI responsiveness is roughly 100ms. Spending developer effort to save 0.4ms while adding fragile dependency arrays is a bad trade-off.
When You Should Actually Use useMemo and useCallback
Memoisation isn't inherently evil; it's just overused. There are three real-world scenarios where reaching for these APIs makes sense:
1. Truly Expensive Computation
If you are transforming 10,000 array items, running complex data parsing, or performing heavy math inside a render body, useMemo is the correct tool. A good rule of thumb: if execution time exceeds 5ms during profile testing, wrap it.
const sortedItems = useMemo(() => {
return largeDataset.slice().sort((a, b) => b.score - a.score);
}, [largeDataset]);2. Reference Identity for Heavy Third-Party Integration
When passing callbacks or objects into heavy chart libraries (like Chart.js, Recharts, or D3 wrappers) or complex grid controls (like AG Grid) that re-initialize canvas or DOM nodes when references change, reference stability is required. In these cases, useCallback prevents full third-party re-initialization.
3. Custom Hook Dependencies
When writing reusable custom hooks that accept functions or return objects used inside useEffect dependency arrays of consumer components, stabilizing your hook output prevents infinite effect loops.
React 19 and the React Compiler
In React 19 with Next.js 16, manual memoisation is quickly becoming legacy syntax. The React Compiler automatically memoises values and components at build time. It analyzes JavaScript semantics and inserts fine-grained memoisation instructions directly into the compiled output.
When you enable the React Compiler in your next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;The compiler automatically handles dependency tracking and object identity far more reliably than manual code ever could. Writing manual useMemo and useCallback calls in React 19 codebases with the compiler active adds noise and conflicts with automated optimisations. Stop adding them by default. Measure your component updates in the profiler, fix state structure first, and let the toolchain do the heavy lifting.









