Skip to content
Back to Blog
ReactPerformanceuseMemoReact.memoOptimizationReact 19

Stop Memoizing Everything: React Rerender Optimization in the Right Order

Most React performance problems are solved before you ever reach useMemo, and knowing the right order of operations — colocation, composition, then memoization — will save you hours of debugging cascading stale closures.

14 min read

TL;DR: Every rerender starts with a state change, and the component that changed plus all its descendants rerender by default. Before reaching for React.memo or useMemo, fix the slow render itself, then try state colocation or the children-as-props pattern. Memoization is the right tool in specific situations, not a default. React Compiler won't save you from skipping this order.


You get a performance complaint. You open React DevTools Profiler, see a bunch of yellow bars, and do what feels obvious: wrap things in React.memo, reach for useCallback, sprinkle useMemo across the render. The yellow bars get fewer. You ship it.

Six months later you're debugging a stale closure. A useCallback with an empty dependency array is reading a value from three renders ago. The component you memoized is still re-rendering because someone passed an inline object as a prop. The useMemo you added has a dependency that changes on every render anyway.

The optimization created more problems than the original slowness.

Here's what I've learned the hard way: memoization is a last resort, not a first instinct. Most React performance problems are solved with two patterns that don't require you to maintain a single dependency array.


What Actually Causes a Rerender

The mental model most developers carry is: props change → component rerenders. It's wrong, and it leads directly to misdiagnosed performance problems.

Every rerender starts with a state change. That's the only trigger. From there, the rerender cascades downward through all descendants of the component that changed state. Not upward, not sideways. Just down.

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <Child />        {/* rerenders when count changes */}
      <Sibling />      {/* also rerenders — same parent */}
    </>
  );
}
 
function Child() {
  return <GrandChild />;  {/* rerenders too, cascades further down */}
}

Child and Sibling receive no props from Parent. They still rerender. Not because their props changed, but because their parent did. React needs a fresh description of what they should look like before it can determine whether the DOM needs updating.

Two things that follow from this:

A component can rerender without any visible DOM change. React rerenders to calculate what changed, then commits only the differences. Most rerenders produce zero DOM mutations.

"Prop changed" is not the cause of a rerender. The parent's state changed, which caused the parent to rerender, which caused the children to rerender. The prop is coincidentally different as a result.

React 18 made this slightly better with automatic batching: multiple state updates inside the same event loop tick now produce a single rerender instead of one per update. A pattern that caused four rerenders in React 17 causes one in React 18. Worth upgrading for if you haven't.


Rule One: Fix the Slow Render Before You Fix the Rerenders

Kent C. Dodds put it best: "Rather than reducing how often you blink while punching yourself in the face, stop punching yourself first."

If a component takes 50ms to render and you reduce it from re-rendering five times to once, you saved 200ms. If you reduce the render itself from 50ms to 5ms, you saved 225ms even with five rerenders.

Fix the computation before you fix the frequency.

Open React DevTools Profiler. Record an interaction. Look at the flamegraph for wide bars. Click the bar. See what took time. Often you'll find:

  • A filter or sort running on every render with no memoization
  • A deeply nested component tree doing redundant work
  • An expensive third-party library rendering something it shouldn't

Fix the computation first. Profile again. Only then, if rerenders are still the problem, move to the patterns below.

A note on profiling: always test in production builds. Development mode runs extra checks that inflate every render time by 2–4x. The component that looks slow in dev might be perfectly fine in prod.


The Cheapest Optimization: Move State Down

If a piece of state is only used by one subtree, it should live in that subtree. Not at the top of the component tree just because it was convenient to put it there.

// ❌ Dog name state lives in App — every keystroke rerenders SlowComponent
function App() {
  const [dogName, setDogName] = useState('');
  return (
    <>
      <input value={dogName} onChange={e => setDogName(e.target.value)} />
      <SlowComponent />
    </>
  );
}

Typing in the input calls setDogName, which rerenders App, which rerenders SlowComponent. SlowComponent doesn't use dogName. It just happens to live next to it.

// ✅ Dog name state colocated — SlowComponent never rerenders on keystroke
function DogNameInput() {
  const [dogName, setDogName] = useState('');
  return <input value={dogName} onChange={e => setDogName(e.target.value)} />;
}
 
function App() {
  return (
    <>
      <DogNameInput />
      <SlowComponent />
    </>
  );
}

No React.memo. No useMemo. No dependency arrays to maintain. SlowComponent simply doesn't share a parent with the state that's changing. The problem disappears at the source.

This works all the way down. A ResizablePanel that tracks its own width shouldn't store that width in a global store. A form field that tracks focus shouldn't lift focus state to the form parent unless another field actually needs it.

The rule: if only one component needs a piece of state, that component should own it.


The Composition Trick: Children as Props

This one surprises a lot of developers. You can prevent rerenders through how you structure JSX, without any memoization at all.

The core insight: when a component rerenders due to its own state change, the props passed into it from the parent stay unchanged. If you pass a component as a prop (or as children), that element was created in the parent, which isn't re-rendering. Its reference is stable.

// ❌ Logger rerenders every time Counter's state changes
function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Logger label="counter" />  {/* new element object every render */}
    </div>
  );
}
// ✅ Logger never rerenders — it's created outside Counter's render
function Counter({ logger }: { logger: React.ReactNode }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {logger}  {/* stable reference from the parent */}
    </div>
  );
}
 
// Usage:
<Counter logger={<Logger label="counter" />} />

The children prop works exactly the same way:

function ResizableContainer({ children }: { children: React.ReactNode }) {
  const [width, setWidth] = useState(300);
  // children won't rerender when width changes
  return <div style={{ width }}>{children}</div>;
}

Kent C. Dodds measured a real-world case and went from 13.4ms to 3.6ms render time with this pattern, no memoization involved.

This also explains why React.memo breaks when you pass JSX as children inline. <Memo><Child /></Memo> creates a new element object on every parent render, defeating the referential equality check that memo relies on.


When Memoization Is the Right Tool

After colocation and composition, some components still rerender unnecessarily. That's when memoization earns its place.

React.memo

Wraps a component and skips rerenders when props haven't changed (shallow equality via Object.is).

const UserCard = memo(function UserCard({ user }: { user: User }) {
  return <div>{user.name}</div>;
});

Worth using when:

  • The component has expensive rendering logic (measured, not assumed)
  • Its props are stable across most parent rerenders
  • You've tried colocation and composition first and the problem remains

The trap: memoization is defeated the moment a single prop gets a new reference on every render.

// ❌ New object reference every render — memo does nothing
<UserCard style={{ color: 'blue' }} />
 
// ❌ New array reference every render
<UserCard tags={['admin', 'user']} />
 
// ❌ New function reference every render
<UserCard onClick={() => handleClick(user.id)} />

Every one of these requires useMemo or useCallback to fix, turning one memoization into a chain.

useMemo

Caches a computed value between renders.

const filteredUsers = useMemo(
  () => users.filter(u => u.active && u.role === selectedRole),
  [users, selectedRole]
);

The "is it expensive enough?" test from the React docs:

console.time('filter');
const result = users.filter(u => u.active && u.role === selectedRole);
console.timeEnd('filter');
// < 1ms → probably not worth it
// ≥ 1ms → consider memoizing

Run this in a production build with CPU throttling at 4x to simulate a mid-range device.

Valid cases: expensive computations that run on known slow inputs, stabilizing references for React.memo children, stabilizing values used in useEffect dependency arrays.

useCallback

Caches a function reference between renders. It's useMemo(() => fn, deps) with cleaner syntax.

const handleSelect = useCallback((userId: string) => {
  setSelected(prev => [...prev, userId]);
}, []); // functional update means no dependency on selected

Only useful when the function is passed to a memoized child component, or sits in a hook's dependency array. A useCallback attached to a non-memoized component adds overhead and delivers nothing.

The honest version of the rule: these tools are always a cost. You're paying the overhead of maintaining the cache, running the comparison, and tracking the dependency array. They pay off only when the avoided rerender is more expensive than all of that. That threshold is higher than most developers assume.


Context Is a Different Problem

Context doesn't play by the same rules as props. Every component consuming a context re-renders when the context value changes, regardless of which part of the value they use. There's no selector API. There's no way to subscribe to a slice.

The most common mistake:

// ❌ New object on every render — all consumers rerender on every state change
function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

The fix: split state and API into separate contexts. The API context (dispatch functions) never changes, so its consumers never rerender.

const UserStateContext = createContext<User | null>(null);
const UserAPIContext = createContext<{ setUser: (u: User) => void }>({} as any);
 
function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
 
  // Stable reference — setUser from useState never changes
  const api = useMemo(() => ({ setUser }), []);
 
  return (
    <UserAPIContext.Provider value={api}>
      <UserStateContext.Provider value={user}>
        {children}
      </UserStateContext.Provider>
    </UserAPIContext.Provider>
  );
}

A component that only calls setUser (like a login form) consumes UserAPIContext and never rerenders when the user object changes. A component that only reads user consumes UserStateContext and doesn't care about the API.

If you have state that updates frequently (form fields, real-time values, mouse position) and many consumers, Context is the wrong tool entirely. Zustand or Jotai use subscription-based models where consumers only rerender when their specific slice changes.


Concurrent Rendering: useTransition and useDeferredValue

React 18 introduced the ability to mark some state updates as non-urgent. React renders the urgent update immediately, then works on the non-urgent update in the background, interruptible by new urgent work.

useTransition is for when you control the state update:

const [isPending, startTransition] = useTransition();
 
function handleTabChange(tab: string) {
  setActiveTab(tab);                     // urgent: tab indicator updates immediately
  startTransition(() => {
    setTabContent(computeContent(tab));  // non-urgent: content can wait
  });
}

useDeferredValue is for when you don't control the update (a prop coming from a parent):

function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
 
  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      <ResultList query={deferredQuery} />
    </div>
  );
}

One thing that's easy to miss: both of these only work if the slow component is wrapped in React.memo with stable props. Without that, the "non-urgent" render still happens immediately. The concurrent features don't replace memoization here; they work alongside it.

Also: useTransition doesn't work on controlled inputs. The input value needs to update synchronously, or the field fights the user.

// ❌ breaks the input
startTransition(() => setInputValue(e.target.value));
 
// ✅ immediate update for the input, deferred update for the search
setInputValue(e.target.value);
startTransition(() => setSearchQuery(e.target.value));

Big Lists: Virtualization

Every DOM node participates in style calculation, layout, and paint. A list of 10,000 items creates 10,000 DOM nodes. The browser slows down. Scroll performance degrades. The fix isn't rendering fewer times. It's rendering fewer nodes.

Virtualization keeps only the visible rows in the DOM. As you scroll, off-screen rows are replaced with new ones. The total scrollable height is preserved for accurate scrollbars.

TanStack Virtual is the current standard for new projects. It's headless: you control the markup, it provides the math.

import { useVirtualizer } from '@tanstack/react-virtual';
 
function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
 
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
  });
 
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(row => (
          <div
            key={row.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${row.start}px)`,
            }}
          >
            {items[row.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

The outer div is the scroll container. The inner div holds the full virtual height. Each row is positioned absolutely with a transform. Roughly 20–30 DOM nodes exist at any time regardless of list length.

For pure display lists with no user interaction, content-visibility: auto with contain-intrinsic-size is a CSS-only alternative. The browser skips layout and paint for off-screen items. No JavaScript required, no library overhead.


What About the React Compiler?

The React Compiler (formerly React Forget) automatically inserts memoization at build time. It's available as an opt-in beta as of 2025, not a default.

It helps. When I've tested it on production codebases it fixed roughly 20–30% of existing rerender issues. That sounds promising until you realize it means 70–80% of issues remain.

Cases it doesn't handle well: hook return values that aren't properly destructured, dynamic lists using index-based keys, and components that need to be extracted into separate functions for proper memoization boundaries. React DevTools can show a component as memoized by the Compiler while it still rerenders, which is a frustrating false negative to debug.

The more useful thing to know: to diagnose Compiler shortcomings, you need to understand memoization mechanics thoroughly. The Compiler doesn't let you stop thinking about this. It just handles the easy cases.


Your Debugging Toolkit

React DevTools Profiler: Record an interaction. Flamegraph shows render times. Enable "Record why each component rendered" in settings to see the exact prop, state, or context that triggered each render. This is built into DevTools and should be your first stop.

why-did-you-render: A dev-only library that monkey-patches React and logs every unnecessary rerender with the specific prop or state that caused it.

// wdyr.ts — import FIRST in your entry file
import React from 'react';
if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, { trackAllPureComponents: true });
}

Opt specific components in:

UserCard.whyDidYouRender = true;

The console output shows the old vs. new value side by side. Especially useful for catching inline object props that look stable but aren't.

Browser Performance Tab: For frame drops and long tasks. Enable 4–6x CPU throttling, record the interaction, look for red-flagged long tasks (>50ms) in the main thread flame chart. Gives you wall-clock evidence, not just React-level evidence.


The Order That Actually Works

Here's the sequence that solves most React performance problems, in order of cost and complexity:

StepTechniqueDependency arrays?Maintenance cost
1Fix the slow render itselfNoLow
2State colocationNoLow
3Children/elements as propsNoLow
4Code splitting / lazy loadingNoLow
5Virtualization (large lists)NoMedium
6Split Context by domainYes (useMemo)Medium
7React.memo on specific componentsNoMedium
8useMemo / useCallbackYesHigh
9useTransition / useDeferredValueNoHigh

Most apps never need steps 7–9 if steps 1–5 are done well.

The instinct to reach for useMemo first is understandable. It feels like the "serious" performance tool. But it's the most expensive tool on the list in terms of cognitive overhead, maintenance burden, and debugging complexity. It belongs at the end of the process, not the beginning.

Measure first. Fix the slow render. Move state down. Use composition. Reach for memoization only when something remains after all of that.

The codebase that reaches step 8 rarely needed to get there.