#Next.js#React

Practical React Performance Optimization

Most React slowdowns come from unnecessary re-renders. Learn the right order: measure with Profiler, then optimize; use memo/useMemo/useCallback correctly; improve keys and lists, split state, use concurrent features, and avoid premature optimization.

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Your React app is lagging. What is your first instinct? In most cases, it is to wrap components in memo, values in useMemo, and functions in useCallback. After doing that, it barely seems faster, so you keep wrapping things until memo is everywhere in the codebase. The result is not only no faster; sometimes it is slower.

This is the most common React performance optimization mistake: treating optimization techniques as the default coding style and skipping the "measure first" step entirely. React rendering itself is not that expensive. Most performance problems are not "rendering is too slow," but rather "an unnecessary component tree rendered at an unnecessary time." This article is not documentation-level material about "how to use memo." It covers the correct sequence and real costs of optimization: measure before changing, the truth about memo, lists and keys, moving state down, concurrent features, and why 90% of memo calls never need to be written.

1. First Understand "Rendering": A Re-render Does Not Equal Slow; an Unnecessary Re-render Does

To optimize React performance, you first need to understand what React is doing. In one sentence: React's default rendering model is "when a parent component renders, all descendant components render too" unless you explicitly intercept them with memo. But "rendering" and "updating the DOM" are different things:

  • Rendering (render): A component function runs once and produces a React element tree.
  • Committing (commit): React diffs the new tree against the old one and writes only the parts that actually changed to the DOM.

In other words, even if a component runs again a hundred times, not a single DOM node moves as long as the UI it produces has not changed. Rendering itself is extremely cheap for most components: it means running a function and diffing a few layers of objects. Two things are actually expensive:

  1. Heavy computation during rendering, such as filtering or sorting large lists, complex formatting, or runtime compilation;
  2. Real DOM work triggered by rendering, such as reflowing and repainting many nodes or forced reflow.

So the problem is never "React rendered." It is "an unrelated state change caused an expensive component tree to re-render alongside it." Type one character into a search field and an entire thousand-row table recalculates. That is the root cause of lag. The core optimization principle is simple: only recalculate or render expensive parts when the data they truly depend on changes.

2. The First Principle: Measure with Profiler Before Blindly Adding memo

The first iron rule of performance optimization is: without measurement, there is no optimization. If you wrap things in memo based on intuition, you will probably optimize the wrong place. You may think BigList is slow when an unrelated outer Header is actually re-rendering every time.

React DevTools Profiler is your first tool. Start recording, trigger the laggy interaction, then stop. You will get a flame graph where every bar represents the time spent on one render. Focus on:

  • The ranked view: it sorts by duration, so you can immediately see the real culprit.
  • why did this render: open a component to see why it re-rendered. Did props change? Did state change? Or did its parent drag it into a render? This directly determines whether memo is the right next step.
tsx
// 甚至可以埋点拿到真实耗时,只在不达标时上报
import { Profiler } from "react";

<Profiler id="search-panel" onRender={(id, phase, actualDuration) => {
  if (actualDuration > 16) {
    // 超过一帧(16.6ms),值得查
    console.warn(`${id} ${phase} 耗时 ${actualDuration.toFixed(1)}ms`);
  }
}}>
  <SearchPanel />
</Profiler>

First identify exactly who is slow and why, then choose an optimization technique. If computation is expensive, memoize the computation. If the tree is too large, split state. If typing is laggy, use concurrent features. Treat the cause, rather than adding memo indiscriminately. A blunt but useful rule of thumb: if a component takes less than 1 ms in Profiler, it is not worth spending time optimizing.

3. The Truth About memo / useMemo / useCallback: They Are Optimizations, Not Defaults, and Misuse Can Make Things Slower

This is one of the largest misconceptions in the React community: treating memo, useMemo, and useCallback as a free lunch that "makes things faster whenever it is added." They do not. Each one has a real cost:

  • useMemo(fn, deps): Every render must shallow-compare deps and retain the previous result in cache. It uses memory, and comparison itself takes time.
  • useCallback: It is essentially useMemo(() => fn, deps), with the same cost.
  • memo(Component): Before every render, the wrapped component must shallow-compare all props.

They are only worthwhile when "the cost saved > the cost introduced." More importantly, memo has a hard constraint: memo works only when every incoming prop reference is stable. This mistake is extremely common:

tsx
const MemoizedList = memo(List);

function Parent() {
  // 每次渲染都是全新函数、全新对象 → memo 的浅比较必失败 → 白包
  const handleSelect = (id: string) => {
    /* ... */
  };
  const itemConfig = { active: true, editable: false };

  return <MemoizedList onSelect={handleSelect} config={itemConfig} />;
}

handleSelect and itemConfig are new references every time. The shallow comparison in memo(List) returns false 100% of the time, so memo is effectively useless. To make it work, you would also need to wrap both with useCallback/useMemo. But that sends you into the rabbit hole of "adding memo for the sake of memo": every extra cache layer adds cost and cognitive load.

The correct decision sequence is:

  1. Is this component actually slow in Profiler? Continue only if it is slow; otherwise stop here.
  2. Is its re-render really caused by unstable props? Profiler's why information will tell you.
  3. If yes, stabilize the props that truly change with useMemo/useCallback, or, preferably, move state down (see Section 5) so the component does not re-render in the first place.

In most cases, the "move state down" option in step three is much cleaner than piling on memo. memo is an optimization technique, not a default coding style. When the reason for adding memo is "I heard it is faster" rather than "measurement shows this is slow," do not add it.

4. Lists and key: Do Not Use index; Virtualize Large Lists

Lists are a major source of performance problems. You must thoroughly understand two things: the meaning of key and virtualization.

key is not an identifier included just to satisfy React. It is how React determines during diffing whether "this item is still the same item." Using index as a key may appear to work, but when a list is inserted into, deleted from, or reordered, React mismatches items. It applies new data to old component instances, which not only wastes DOM work (what should be one changed node becomes a sequence of changes), but also corrupts the internal state of components with input, animations, useState, and similar stateful behavior.

tsx
// 反例:index 当 key,删除中间一项后所有后续项的 DOM 都要重写、状态全乱
{items.map((item, i) => <Row key={i} data={item} />)}

// 正解:用业务上的稳定唯一 id
{items.map((item) => <Row key={item.id} data={item} />)}

The rule is simple: a key must be stable and unique for each piece of data; prefer an ID supplied by the backend. Only in the rare case where a list only appends, is never reordered, and its items have no internal state is index barely acceptable.

The second point is virtualization (windowing). Once a list exceeds a few hundred rows, rendering every DOM node is expensive by itself. Even if React diffs quickly, the browser cannot comfortably lay out and paint thousands of DOM nodes. Virtualization means rendering only nodes in the viewport plus a small buffer, then replacing them dynamically while scrolling.

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

function BigList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,      // 每行预估高度
    overscan: 5,                 // 视口外多渲染几行,滚动不闪
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((vi) => (
          <Row
            key={items[vi.index].id}
            data={items[vi.index]}
            style={{ position: "absolute", top: vi.start, height: vi.size }}
          />
        ))}
      </div>
    </div>
  );
}

The difference is one configuration choice: from "rendering ten thousand DOM rows" to "rendering twenty or thirty rows." Once a list reaches thousands of rows, use virtualization without hesitation. @tanstack/react-virtual and react-window are both mature solutions.

5. State Splitting and Moving State Down: Keep Changes in the Smallest Cage

Return to the classic scenario: a search field plus a large table, with the search field's state held in an outer container. Every keystroke re-renders the container, and the entire large table re-renders with it, even if the filtering result has not changed at all.

The root cause is this: frequently changing state and an expensive-to-render component live in the same component. The solution is not to wrap the table in memo. It is to move that frequently changing state down to the smallest component that actually needs it.

tsx
// 反例:inputValue 在父组件,每次输入整棵树重渲染,大表格被无辜牵连
function Page({ rows }: { rows: Row[] }) {
  const [inputValue, setInputValue] = useState("");
  return (
    <>
      <SearchInput value={inputValue} onChange={setInputValue} />
      <HugeTable rows={rows} filter={inputValue} />
    </>
  );
}

// 正解:把 input 的 state 关进 SearchInput 自己肚子里
function Page({ rows }: { rows: Row[] }) {
  const [filter, setFilter] = useState("");
  return (
    <>
      <SearchInput onFilterChange={setFilter} />   {/* 输入只重渲染这个小组件 */}
      <HugeTable rows={rows} filter={filter} />
    </>
  );
}

The decision rule is: state should live in the lowest-level component that uses it; even one level higher is too high. State that changes frequently, such as input, animation, mouse coordinates, and timers, especially needs to move down. Do not attach a value that changes every frame to a root component that can drag an entire page into rendering.

This principle eliminates more than half of the urge to add memo. Many times when you think you need memo, the state is simply in the wrong place. Put state in the right place first, and much of the need for memo disappears naturally.

6. Concurrent Features: useDeferredValue / useTransition Keep Input from Being Blocked by Heavy Work

Some cases cannot be solved by splitting state. A search field's value may have to drive an expensive filtering or search computation. What you want then is not "do not re-render," but "do not let this expensive update block the user's input." React 18's concurrent features are designed for this.

Use useTransition when you have the setter. Use it to mark an expensive update as "interruptible, low priority." The input updates immediately while the heavy work runs gradually in the background:

tsx
function SearchBox({ data }: { data: Item[] }) {
  const [input, setInput] = useState("");
  const [results, setResults] = useState(data);
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <input
        value={input}
        onChange={(e) => {
          const v = e.target.value;
          setInput(v);                              // 高优先级:输入立刻可见
          startTransition(() => {
            setResults(expensiveFilter(data, v));   // 低优先级:可被打断
          });
        }}
      />
      {isPending && <Spinner />}
      <ResultList items={results} />
    </>
  );
}

Use useDeferredValue when you do not have the setter because the value comes from props, context, or a third-party store. In that case, defer consuming the value:

tsx
function ResultList({ query, data }: { query: string; data: Item[] }) {
  // query 来自上层,我没法 startTransition 它,但可以"延迟消费"
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
  const results = useMemo(
    () => expensiveFilter(data, deferredQuery),
    [data, deferredQuery],
  );

  return <ul style={{ opacity: isStale ? 0.6 : 1 }}>{results.map(/* ... */)}</ul>;
}

The difference in one sentence: if you can control the setter, use useTransition; if you only receive a changing value, use useDeferredValue. They solve the same class of problem: demote heavy work from the priority that blocks input to a priority that can be interrupted, keeping the interface responsive. After React 18, these are the preferred tools for handling input lag, far more effective than blindly adding memo.

7. Code Splitting and Bundles: Lazy-load by Route Instead of Loading the Entire World at Once

Everything above focuses on runtime rendering, but performance has another half: how much JavaScript the first screen loads. If an SPA puts every route and every heavy dependency, such as charting libraries, rich-text editors, and an entire date-processing suite, into one bundle, users must download several megabytes of JavaScript when opening the home page. On low-end devices, parsing and execution alone can take several seconds. Fast rendering cannot save that experience.

The solution is code splitting: lazy-load by route, removing code the user does not currently need and fetching it only when it is needed.

tsx
import { lazy, Suspense } from "react";

// 重依赖按需加载,不进主 bundle
const RichEditor = lazy(() => import("./RichEditor"));
const ChartPanel = lazy(() => import("./ChartPanel"));

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/editor" element={<RichEditor />} />
        <Route path="/chart" element={<ChartPanel />} />
      </Routes>
    </Suspense>
  );
}

Next.js App Router makes this easier: route-level and component-level code splitting are enabled by default, while next/dynamic can provide more granular on-demand loading beyond SSR. Regardless of framework, regularly run a bundle analysis using webpack-bundle-analyzer or Next's @next/bundle-analyzer to check for unexpectedly huge dependencies. It is an extremely high-return optimization.

A few practical rules:

  • Route-level splitting is almost always worthwhile. Code for different pages does not need to load together.
  • Load heavy dependencies on demand: lazy-load editors and charting libraries used only in a particular tab or dialog.
  • Watch for "kitchen sink" dependencies: if you use 5% of a library's capabilities but import the whole package, prioritize alternatives that support tree shaking or selective imports.
  • Focus on first-screen JavaScript size, not only runtime behavior. Cutting bundle size often produces a more immediate perceived improvement than optimizing rendering.

8. Premature Optimization Is the Root of All Evil: 90% of memo Calls Never Need to Be Written

Finally, return to a simple engineering principle. Knuth's phrase, "premature optimization is the root of all evil," is particularly true in React because React's default rendering is not slow to begin with. Much of the useMemo/useCallback seen in the community is motivated by "I heard it improves performance" or "I am afraid it may be slow later, so I will wrap it now," with no measurement basis at all. The result is one layer less readable code because every value requires dependency reasoning, plus caching costs and stale-closure bugs from incorrect dependencies, in exchange for a performance gain that did not exist.

The right approach is:

  1. Write correct, clear code first. Put state in the right place, use stable keys for lists, and schedule heavy work correctly. This kind of structural correctness is worth more than any memo.
  2. Measure in real scenarios using Profiler plus real devices or throttling. Find the 5% that actually lags.
  3. Target only that 5%. Add memo where memo is needed, split state where state needs splitting, virtualize where virtualization is needed, and use concurrent features where they are appropriate.

This is a closed loop of "write it correctly first -> measure next -> optimize precisely last," not "add memo everywhere from the beginning." A clean React component tree has sufficient rendering performance in most scenarios without any memo at all. memo is a precise weapon after you have measured a problem, not a preventative vitamin. Treating it like a vitamin you take every day only makes the code harder to maintain without delivering perceptible speed.

Summary

  1. Most React performance problems are "unnecessary re-renders." Re-rendering itself is not slow. What is slow is "one unrelated state change causes an entire expensive subtree to compute and render together." The core optimization principle is to move expensive parts only when the data they truly depend on changes.
  2. The first principle is measurement, not memo. Use React Profiler's ranked view and "why did this render" to identify "who is slow and why," then apply the appropriate fix. A component that takes less than 1 ms is not worth optimizing.
  3. memo / useMemo / useCallback are optimization tools, not default syntax. They have comparison and memory costs, and memo works only when every prop reference is stable. Pass in a new function or object and memo immediately fails. In most cases, moving state down is cleaner than piling on memo.
  4. Use stable, unique keys for lists, never index; virtualize once a list reaches thousands of rows. @tanstack/react-virtual / react-window can reduce "rendering ten thousand DOM rows" to "rendering twenty or thirty rows."
  5. Use concurrent features for "input blocked by heavy work." useTransition when you hold the setter and useDeferredValue when you only receive a changing value demote expensive updates to interruptible low priority. Combine them with route-level code splitting to control first-screen JavaScript size.

Key takeaway: React is not slow by default. It is slow when you put change in the wrong cage. First write the code correctly and put state at the smallest necessary scope, then use Profiler to measure the real bottleneck. memo is the precise scalpel after measurement, not a vitamin you reach for automatically.

Related

Related Content