July 28, 2026 · 11 min read
Stop reaching for useMemo first: a practical guide to React renders
Learn when React re-renders matter, why memoization is not the default answer, and how state placement and composition solve common performance problems.
React codebases often accumulate memoization before anyone has identified a slow interaction. A component renders a few times in development, the render count looks alarming, and useMemo or React.memo appears “just in case.”
That instinct is understandable, but it is rarely the best first move. Memoization adds dependencies, assumptions, and review overhead. A clearer component boundary often removes the work entirely.
The useful distinction is simple: a React render is not automatically a DOM update, and an extra render is not automatically a user-facing problem. The goal is not to eliminate renders. The goal is to prevent expensive work from running at the wrong time.
TL;DR: optimize in this order
When an interaction is slow, start with the shape of the tree. Most avoidable work disappears when state is moved closer to the component that uses it, or when expensive content is passed through a stateful wrapper as children.
- A render is a component function call; it is not automatically a DOM update.
- Local state, consumed context, and parent-driven reconciliation are different update paths.
- React.memo only helps with a narrow parent-to-child case. It does not block local state or context updates.
- Render fewer rows before trying to memoize a large list.
- Profile a production-like build before changing code.
- Use useMemo, useCallback, and React.memo only when a measured bottleneck gives them a specific job.
A render is work, but not necessarily expensive work
During rendering, React runs a component function and receives a description of the next UI. It then reconciles that result with the previous tree. The browser only changes when React finds a committed difference that needs to reach the DOM.
A component can therefore run many times while the visible page stays unchanged. For a small component, creating a few JavaScript objects is usually insignificant. The costly cases are large DOM updates, heavy computations, expensive children, and rendering far more data than the screen can display.
This is why “my component rendered again” is not a useful diagnosis on its own. The question that matters is: what does this render make the application do, and can the user feel it?
Know the update path before choosing an optimization
A component can render because its own state changes, because a context value it reads changes, or because work in its parent reaches that branch of the tree. These paths are different, which is why one optimization cannot solve all of them.
React.memo only participates when React is considering a child because of its parent. It compares props and may reuse the previous result. It does not prevent the memoized component from responding to its own state or a context it consumes.
It is also useful to remember the direction of updates. A parent can cause work below it. A child updating its own state does not make the parent render in reverse. This is the reason state placement has such a large effect on performance.
- Local state updates belong to the component that owns the state.
- Context updates reach every consumer of the changed value.
- Parent work can reach children unless the tree gives React an opportunity to reuse a subtree.
- A changed key is a remount, not a normal re-render: React discards the previous instance and its state.
Move state down before adding a cache
A common performance issue is not that state exists, but that it lives too high in the tree. If a text field owns its value in a page component, every keystroke makes the page responsible for reconciling everything beneath it.
In this example, the search state sits beside two potentially expensive siblings. Even if the chart and table end up doing little work, they are still in the path React must consider for every input update.
State owned too high · ts
function Dashboard() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
<AnalyticsChart />
<LargeResultsTable />
</>
);
}Reduce the blast radius
The search field is the only part that needs its state, so it can own it. The chart and table are now outside that update boundary. This is a structural change: it reduces the amount of work React has to consider instead of asking React to cache around the work.
The same principle applies to hover state, temporary dialog state, scroll position, tab selection, and local form state. State should be lifted only as far as the nearest component that truly needs to coordinate it.
State isolated near its consumer · ts
function SearchSection() {
const [query, setQuery] = useState('');
return <SearchInput value={query} onChange={setQuery} />;
}
function Dashboard() {
return (
<>
<SearchSection />
<AnalyticsChart />
<LargeResultsTable />
</>
);
}Use composition to protect expensive children
Sometimes a stateful wrapper genuinely needs to surround an expensive child. A scroll-aware shell, a popover, or a resizable panel may need state while still containing a costly table, chart, or report.
Pass that expensive content as children instead of constructing it inside the wrapper’s state-driven render path. If the parent that created the child element is not updating, React has more opportunity to reuse that child while the wrapper updates local state.
Do not accidentally undo this benefit with a render prop unless the render prop is necessary. Calling a render prop inside the wrapper creates the child element during the wrapper update, putting the expensive branch back on the hot path.
A stateful shell with stable children · ts
function ScrollShell({ children }: { children: ReactNode }) {
const scrollY = useScrollY();
return (
<div className={scrollY > 96 ? 'compact' : ''}>
{children}
</div>
);
}
function Page() {
return (
<ScrollShell>
<ExpensiveProductList />
</ScrollShell>
);
}Fix data volume before component memoization
A table with 5,000 visible rows is expensive because it asks the browser to manage 5,000 rows. React.memo cannot make that fundamental workload disappear. It may avoid some repeated calls, but it does not make a huge DOM cheap.
Virtualization, pagination, incremental loading, and server-side filtering are the appropriate tools when the amount of rendered data is the problem. First reduce what must exist on screen; then profile any remaining interaction cost.
- Virtualize long lists when only a viewport-sized subset needs to be visible.
- Paginate or request narrower result sets when the user does not need every record at once.
- Avoid sorting, filtering, and grouping large collections repeatedly during render when that work can be moved or cached deliberately.
Memoize only a measured bottleneck
useMemo is appropriate for a calculation that is expensive enough to affect an interaction and whose inputs frequently stay the same. Sorting a large data set, deriving a complex visualization model, or preserving a value required by an already-memoized child can qualify.
It is not useful as decoration around simple string concatenation or a tiny array operation. The cache itself has a cost: React must track dependencies, and future maintainers must understand why the cached value exists. Treat useMemo as a performance hint, never as a requirement for correctness.
| Observed problem | First response | Memoization role |
|---|---|---|
| Thousands of visible rows | Virtualize or paginate the list | Usually not the main fix |
| Input updates an unrelated heavy branch | Move state down or compose the branch as children | Often unnecessary |
| Large transformation repeats with identical inputs | Measure the transformation | useMemo can cache it |
| Heavy child receives unchanged, stable props | Confirm its render cost in a profiler | React.memo can skip parent-driven work |
Be deliberate with references and context values
Objects, arrays, and functions created during rendering are normal. They become meaningful when identity is part of another contract: a memoized child, a dependency array, or a context provider value.
Context providers deserve special attention. An inline object becomes a new value whenever the provider renders. When the contained values are stable, memoizing the provider value can avoid notifying consumers unnecessarily. If different consumers need unrelated data, splitting one broad context into focused contexts can be even more effective.
Do not use useCallback simply because a callback appears in JSX. It is useful when function identity is observed by a memoized child or another dependency-sensitive API. Otherwise, an inline handler is often the clearest option.
A stable context value · ts
const value = useMemo(() => ({ theme, user }), [theme, user]);
return (
<SettingsContext value={value}>
{children}
</SettingsContext>
);Use transitions when the work is necessary but not urgent
Some updates are genuinely expensive and still must happen. Filtering a large collection while a user types is a good example. In that situation, the objective is not necessarily to make the calculation disappear; it is to keep the urgent interaction responsive.
useDeferredValue and startTransition allow React to give priority to urgent updates such as typing, clicking, and focus changes. The heavy result can follow shortly after. They improve scheduling, not the raw speed of the underlying calculation.
For a deferred value to help with an expensive child, that child must be able to skip the intermediate urgent update. In practice, that commonly means giving a memoized child the deferred value rather than making the slow subtree re-render for both values.
Keep the input responsive while results catch up · ts
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<section aria-busy={isStale}>
<MemoizedResults query={deferredQuery} />
</section>
);
}Profile production-like behavior, not development noise
Development mode is optimized for feedback, not for benchmark numbers. Strict Mode can intentionally repeat render-related work, and development tooling adds checks that production does not carry.
Record the slow interaction in the React DevTools Profiler, inspect which components consume time, and validate the conclusion with a production build. Width and duration are more useful than a component flashing during a render-inspection session.
If typing feels instant, scrolling is smooth, and the profiler shows no meaningful cost, leave the code alone. An optimization that does not improve a user-visible interaction is usually just another maintenance burden.
- Measure the slow action, not an abstract render count.
- Find the expensive calculation, subtree, or DOM workload.
- Fix data volume and component boundaries before adding caches.
- Re-profile after the change to confirm that it improved the experience.
What React Compiler changes—and what it does not
Automatic memoization can remove some manual caching work in projects that use a compatible React Compiler setup. That can reduce the need for hand-written useMemo, useCallback, and React.memo in straightforward cases.
It does not change the architectural question. A compiler cannot decide that your input state belongs in a lower component, that a huge list should be virtualized, or that a broad context should be split. Those choices determine how much work exists in the first place.
Final take
Re-renders are a normal part of React, not an error condition. The durable optimization is usually to render less data, narrow the state boundary, or compose the tree so unrelated work is not in the update path. Measure first, fix the structure second, and use memoization only when it has a specific, proven job. The resulting code is often not only faster, but easier to reason about.