Skip to content

Avoiding Unnecessary Re-Renders

Aman Kumar Singh8 min read
Part 10 of 50From the React & Next.js Complete Guide series
Avoiding Unnecessary Re-Renders — article by Aman Kumar Singh

The previous article in this series covered how to measure React performance before touching any code: the Profiler, flame charts, and the discipline of finding a real cost before optimizing it. This one assumes you've done that measurement and found the actual problem: a component tree that re-renders far more often than the UI actually changes. Now we deal with the fix.

This is part of the React & Next.js Complete Guide, and it leans on two things from earlier articles: the re-render model from the fundamentals piece (a component re-renders whenever its parent does, regardless of whether its own props changed) and the memoization mechanics from the useMemo/useCallback article. Neither of those articles covered the structural techniques that prevent the re-render cascade in the first place. That's the gap this one fills.

Most re-render problems I've reviewed in production code get fixed by restructuring where state lives and how components are composed, rarely by wrapping more things in React.memo. The parts of the tree that don't need to update simply stop being in the update path. Memoization is a patch applied after the fact. Composition is the design decision that avoids needing the patch.

React.memo: what it buys you and what it costs

React.memo wraps a component so React skips re-rendering it when its props are shallowly equal to the previous render's props. It's the most direct tool for "this specific subtree keeps re-rendering for no reason."

type UserAvatarProps = {
  name: string;
  imageUrl: string;
};

const UserAvatar = React.memo(function UserAvatar({ name, imageUrl }: UserAvatarProps) {
  return (
    <img
      src={imageUrl}
      alt={name}
      className="h-10 w-10 rounded-full"
    />
  );
});

If the parent of UserAvatar re-renders every few seconds because of a websocket-driven counter elsewhere on the page, and name and imageUrl haven't changed, React.memo stops UserAvatar from re-rendering along with it. That's a real, measurable win when the wrapped component does nontrivial work: rendering a large subtree, running its own expensive calculation, or holding uncontrolled DOM state you don't want disturbed.

The cost is easy to underestimate. React.memo does a shallow comparison of every prop on every render, which is not free, and it only helps if the props are actually stable across renders. Pass an inline object, array, or function as a prop, and the shallow comparison fails every time regardless of whether the underlying data changed, because a new object has a new identity even with identical contents. Wrapping a component in React.memo without controlling how its props are created upstream is the single most common way I see this pattern applied for zero benefit.

There's also a readability cost. A codebase where React.memo is applied to every component "for safety" is harder to reason about, because now every prop needs to be scrutinized for referential stability, and every dependency array upstream matters more than it would otherwise. Apply it where you've identified a component that re-renders often and does real work, not as a blanket policy.

Move state down instead of memoizing around it

The technique that fixes more re-render problems than any memoization trick is simply moving state closer to where it's used. A common pattern in code review: a parent component holds state for something that only affects a small part of its own render output, and every keystroke or toggle re-renders the entire parent, including large sibling subtrees that have nothing to do with that state.

// Before: every keystroke re-renders the whole dashboard, including
// the (potentially large) DataTable below it.
function Dashboard({ rows }: { rows: RowData[] }) {
  const [search, setSearch] = useState("");

  return (
    <div>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <DataTable rows={rows} />
    </div>
  );
}

// After: search state is isolated in its own component, so typing
// only re-renders SearchBox, not DataTable.
function Dashboard({ rows }: { rows: RowData[] }) {
  return (
    <div>
      <SearchBox />
      <DataTable rows={rows} />
    </div>
  );
}

function SearchBox() {
  const [search, setSearch] = useState("");
  return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}

It's single-responsibility thinking applied to component boundaries, nothing React-specific about it. State that's local to one piece of UI shouldn't live in a shared ancestor just because that's where the component happened to be written first. The Dashboard example above needed no React.memo, no useCallback, and no dependency array tuning. It needed the state to live somewhere that doesn't force an unrelated subtree to re-render.

The tradeoff is that this sometimes means introducing a component that exists purely to own a piece of state, which can feel like unnecessary indirection for a small feature. I'd take that tradeoff most of the time; an extra small component is a lot easier to reason about than a React.memo wrapper whose effectiveness depends on every prop passed to it staying referentially stable.

Use the children prop to break up re-render cascades

A related composition trick handles a subtler case: a component that holds state affecting only part of its own JSX, where that JSX also contains an expensive child passed in from outside. Passing that child as children instead of rendering it directly inside the stateful component keeps it out of the re-render path entirely, because children is created by the parent and its element reference doesn't change just because the component holding it re-renders.

type ExpandableProps = {
  children: React.ReactNode;
};

function Expandable({ children }: ExpandableProps) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen((prev) => !prev)}>
        {isOpen ? "Collapse" : "Expand"}
      </button>
      {isOpen && <div>{children}</div>}
    </div>
  );
}

function Panel({ rows }: { rows: RowData[] }) {
  return (
    <Expandable>
      <ExpensiveChart rows={rows} />
    </Expandable>
  );
}

When isOpen changes, Expandable itself re-renders, but children was created in Panel's render, not Expandable's. React sees the same element reference for ExpensiveChart across Expandable's re-render (as long as Panel itself hasn't re-rendered), so it doesn't re-render ExpensiveChart at all. This works without React.memo, without useMemo, without any dependency array. It's a direct consequence of how React's reconciliation treats children as a value owned by whichever component created it.

This pattern is worth knowing specifically because it solves a problem React.memo can't: state and expensive children living in the same component. Wrapping ExpensiveChart in React.memo wouldn't help here at all. A naive implementation would re-run ExpensiveChart's render function as a side effect of Expandable's own state changing, and that's what's expensive, not Expandable re-rendering itself. The children composition avoids that structurally.

Split contexts instead of scaling one big provider

Context is a common source of re-render cascades that has nothing to do with React.memo at all. Every component that calls useContext for a given context re-renders whenever that context's value changes, regardless of which part of the value it actually reads. A context holding both user and theme means a theme toggle re-renders every component consuming that context, including ones that only care about user.

// One context holding unrelated concerns forces every consumer to
// re-render on any change, even to a field it doesn't use.
const AppContext = createContext<{ user: User; theme: Theme } | null>(null);

// Split by concern instead: consumers only re-render when the
// specific value they read actually changes.
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme | null>(null);

Splitting contexts by how often their values change, and by which parts of the tree actually consume them, is usually a better first move than reaching for a memoized selector library. It costs an extra provider in the tree, which is a minor structural overhead, in exchange for consumers that only re-render for changes they actually care about. If a context genuinely has many fine-grained consumers reading different slices of a large value, that's the point where a selector-based state library earns its complexity; I wouldn't start there for a context with two or three fields.

Production pitfalls

Passing inline object or array literals as props defeats every technique above. A React.memo-wrapped component receiving style={{ color: "red" }} inline re-renders every time regardless of memoization, because the object has a new identity each render. This is the same problem covered in the useMemo/useCallback article, and it applies just as much here: composition and memoization both depend on referential stability upstream.

Wrapping the wrong layer in React.memo. Memoizing a component right above the one that actually re-renders expensively does nothing if the expensive component itself isn't memoized or isn't received via children. Trace the re-render to the component actually doing the expensive work before deciding where the fix belongs.

Assuming context splitting alone solves a fine-grained update problem. If a single context value updates on every keystroke and dozens of components read narrow slices of it, splitting into two or three contexts won't be enough. That's a sign the state shape itself needs rethinking, not just the provider boundary.

Forgetting that React.memo doesn't help with children. React.memo performs a shallow comparison of the props object; it doesn't look inside children to see if the elements inside changed in a way that matters. If children itself is recreated on every parent render (which it usually is, since JSX children are just function calls under the hood), memoizing the wrapper component buys you nothing for that prop.

Key takeaways

  • `React.memo` skips re-rendering a component when its props are shallowly equal to last render, but it only helps if those props are actually referentially stable across renders.
  • Moving state closer to the component that owns it, rather than lifting it further up than necessary, prevents unrelated subtrees from re-rendering in the first place.
  • Passing an expensive subtree as `children` keeps it out of a stateful parent's re-render path, because React reuses the same element reference as long as the component that created it hasn't re-rendered.
  • Context re-renders every consumer on any value change, regardless of which field the consumer reads. Split contexts by concern and by how often each part changes.
  • These are structural fixes, not memoization tricks; they solve problems `React.memo` and `useMemo` can't reach on their own, like state and an expensive child living in the same component.
  • Diagnose with the Profiler first (from the previous article) so you fix the component that's actually expensive, not the one nearest to where you noticed the slowdown.

Frequently asked questions

Does React.memo make a component render faster?

No. It prevents a re-render from happening at all when the props haven't changed. The component's own render speed is unaffected; `React.memo` just decides whether that render needs to run this time.

Why does React.memo not work on my component even though the props look the same?

Check whether any prop is an inline object, array, or function literal. Those get a new reference every render even when their contents are identical, which fails `React.memo`'s shallow equality check regardless of whether the actual data changed.

Is moving state down always better than using React.memo?

Not always, but it's usually the first thing worth trying, because it removes the re-render cascade at its source instead of filtering it after the fact. Reach for `React.memo` when the component genuinely needs to receive changing props but still shouldn't re-render for every one of them.

When does context splitting stop being enough?

When a single context has many fine-grained consumers reading different narrow slices of a large, frequently changing value. At that point a selector-based state library or a purpose-built store is usually a better fit than adding more and more context providers.

Can passing children as a prop cause bugs, not just performance wins?

It can if the child depends on state from the parent that owns it, since children created higher up the tree won't automatically pick up state defined lower down. Use this pattern for genuinely independent subtrees, not ones that need direct access to the stateful component's internals.

Should I combine React.memo, moving state down, and context splitting on the same component?

Only if you've profiled and found more than one distinct cause. Applying all three preemptively adds structural overhead without a proven benefit. Fix the one cause the Profiler actually points to, then measure again before adding another technique.

Related articles

useMemo and useCallback, When They Help — Aman Kumar Singh
Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.