admond@portfolio:~/blog
← all posts
$ cat react-performance-patterns-that-actually-matter.md

React Performance Patterns That Actually Matter

Fri Nov 14 · 8 min read
[react][performance][javascript]

The problem with most performance advice

Most React performance articles teach you to wrap everything in useMemo and React.memo. This is bad advice. Memoization has a cost — the comparison on every render — and if the value changes frequently, you pay the cost without getting the benefit. Worse, memoization adds cognitive overhead that makes code harder to reason about.

Real performance work starts with measurement, not intuition. Open the React DevTools Profiler, record an interaction that feels slow, and look at what actually took time. You will almost always find one of three problems: unnecessary renders, expensive computations in the render path, or poor list virtualization.

Unnecessary renders: the real cause

A component re-renders when its state changes, its parent re-renders, or its context changes. The question is never "how do I prevent re-renders" — it's "does this re-render produce visible work?"

React's reconciliation is fast. A component that re-renders but produces the same JSX costs almost nothing. The expensive case is a component that re-renders AND does expensive work (fetching, sorting, formatting large datasets).

The first tool to reach for is component decomposition, not memoization:

// Slow: the entire list re-renders when filter changes
function ProductPage() {
  const [filter, setFilter] = useState('all');
  const products = useProducts(); // 500 items

  return (
    <div>
      <FilterBar filter={filter} onChange={setFilter} />
      {products
        .filter(p => filter === 'all' || p.category === filter)
        .map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

// Better: isolate the filter state into a child component
function ProductList({ filter }: { filter: string }) {
  const products = useProducts();
  const visible = useMemo(
    () => products.filter(p => filter === 'all' || p.category === filter),
    [products, filter]
  );
  return <>{visible.map(p => <ProductCard key={p.id} product={p} />)}</>;
}

By moving state down and splitting components, you limit re-render scope automatically.

When useMemo actually helps

useMemo is worth reaching for when:

const sortedItems = useMemo(
  () => [...items].sort((a, b) => a.price - b.price),
  [items]
);

If items comes from a stable source (fetched once, stored in state), this memoization is legitimate. If items is recreated on every render, the memoization is theater.

useCallback for stable function references

Pass callbacks to memoized children without useCallback and the memoization breaks — new function reference on every render means the child always sees a "changed" prop.

const handleDelete = useCallback((id: string) => {
  setItems(prev => prev.filter(item => item.id !== id));
}, []); // stable: no deps

The rule: use useCallback when the function is passed to a React.memo component or a hook that takes a callback in its dependency array.

Context performance: the hidden bottleneck

Context re-renders every consumer whenever the context value changes. If you put both state and dispatch in the same context, every component reading dispatch will re-render when state changes.

Split them:

const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<Dispatch<Action> | null>(null);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
}

Components that only dispatch actions won't re-render when state changes.

List virtualization

No memoization trick will save a component rendering 10,000 DOM nodes. If you have long lists, virtualize them. @tanstack/react-virtual is the current best option:

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(vItem => (
          <div
            key={vItem.key}
            style={{ transform: `translateY(${vItem.start}px)` }}
          >
            <ItemRow item={items[vItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

With virtualization, only the visible rows exist in the DOM. Scrolling through 10,000 items costs the same as scrolling through 20.

The hierarchy of impact

  1. Virtualize long lists — the highest ROI, always
  2. Decompose components — keeps re-render scope minimal by default
  3. Split context — prevents dispatch-only components from re-rendering on state changes
  4. useMemo for expensive calculations — only when measured, not assumed
  5. React.memo + useCallback — only for stable prop boundaries

Measure first. Memoize second. Decompose as the default.

← all posts
admond tamang · portfoliotheme: mono