Type something to search...
Preventing Unnecessary Re-Renders with React.memo

Preventing Unnecessary Re-Renders with React.memo

When a component's state changes, React re-renders that component and every component below it, whether their props changed or not. Most of the time that's fine, because renders are cheap. But when a parent holds frequently changing state, like a search input or a timer, and has an expensive child like a chart or a long table, those free re-renders stop being free.

React.memo is the built-in way to tell React "skip this component if its props are the same as last time." It's simple to apply and easy to get wrong. A memoized component that receives a new object, array, or function on every render is re-rendered anyway, and you've just added a comparison on top.

This post covers how memo decides whether to skip a render, the reasons it silently stops working, how to keep props stable, custom comparison functions, alternatives that avoid memoization entirely, and where the React Compiler fits in.

Why Components Re-Render

A component re-renders for one of three reasons:

  • Its own state changed (useState, useReducer).
  • A context it reads changed (useContext or use).
  • Its parent re-rendered.

The third one is the source of most unnecessary work. React doesn't check whether props changed before rendering a child. By default, if the parent renders, the child renders.

import { useState } from "react";

function ExpensiveChart({ data }: { data: number[] }) {
  console.log("chart render");
  // pretend this does heavy work
  return <svg width={400} height={200}>{/* ... */}</svg>;
}

const chartData = [3, 8, 2, 9, 4];

export default function Dashboard() {
  const [search, setSearch] = useState("");
  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <ExpensiveChart data={chartData} />
    </>
  );
}

Every keystroke logs "chart render," even though chartData never changes.

How React.memo Works

memo wraps a component and returns a new component that compares its props with the previous ones before rendering:

import { memo } from "react";

const ExpensiveChart = memo(function ExpensiveChart({
  data,
}: {
  data: number[];
}) {
  console.log("chart render");
  return <svg width={400} height={200}>{/* ... */}</svg>;
});

Now typing doesn't log anything, because data is the same array every time.

The comparison is shallow. React loops over each prop and checks it with Object.is. Primitive values (strings, numbers, booleans) compare by value. Objects, arrays, and functions compare by reference: two arrays with the same contents are not equal unless they're literally the same array.

Object.is(5, 5);           // true
Object.is("a", "a");       // true
Object.is([1, 2], [1, 2]); // false: different arrays
const arr = [1, 2];
Object.is(arr, arr);       // true: same reference

A few things memo does not affect:

  • State changes inside the component still re-render it.
  • Context changes still re-render it if it reads that context.
  • It's a performance hint, not a guarantee. React may still re-render in rare cases, so your component must work correctly either way.

Why memo Silently Fails

The most common problem is passing props that are new every render. All of these break memoization:

export default function Dashboard() {
  const [search, setSearch] = useState("");

  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      {/* New array every render */}
      <ExpensiveChart data={[3, 8, 2, 9, 4]} />

      {/* New object every render */}
      <ExpensiveChart data={chartData} options={{ color: "blue" }} />

      {/* New function every render */}
      <ExpensiveChart data={chartData} onPointClick={(p) => console.log(p)} />
    </>
  );
}

In each case, memo compares, finds a different reference, and renders. You pay for the comparison and the render. The React DevTools Profiler shows this clearly: the reason will read "Props changed" with the name of the culprit prop.

Stabilizing Values with useMemo and useCallback

When a prop must be computed from state or props, wrap it in useMemo. When it's a function, wrap it in useCallback. Both return the same reference until their dependencies change:

import { memo, useCallback, useMemo, useState } from "react";

type Point = { x: number; y: number };

const ExpensiveChart = memo(function ExpensiveChart({
  points,
  color,
  onPointClick,
}: {
  points: Point[];
  color: string;
  onPointClick: (p: Point) => void;
}) {
  return (
    <svg width={400} height={200}>
      {points.map((p) => (
        <circle
          key={p.x}
          cx={p.x * 40}
          cy={200 - p.y * 20}
          r={4}
          fill={color}
          onClick={() => onPointClick(p)}
        />
      ))}
    </svg>
  );
});

const raw = [3, 8, 2, 9, 4];

export default function Dashboard() {
  const [search, setSearch] = useState("");
  const [scale, setScale] = useState(1);
  const [selected, setSelected] = useState<Point | null>(null);

  const points = useMemo(
    () => raw.map((y, x) => ({ x, y: y * scale })),
    [scale]
  );

  const handlePointClick = useCallback((p: Point) => {
    setSelected(p);
  }, []);

  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <button onClick={() => setScale((s) => s + 1)}>Scale up</button>
      {selected && <p>Selected: {selected.y}</p>}
      <ExpensiveChart
        points={points}
        color="steelblue"
        onPointClick={handlePointClick}
      />
    </>
  );
}

Now typing in the search box doesn't re-render the chart. Clicking "Scale up" does, because points really changed. Clicking a point updates selected, which re-renders Dashboard, but the chart's props are all the same, so it skips.

Notice useCallback has an empty dependency array because it only calls setSelected, and state setters are always stable. For a deeper look at these two hooks, see useMemo and useCallback: When Memoization Actually Helps.

Static Values Belong Outside the Component

If a value doesn't depend on props or state, you don't need useMemo. Move it to module scope:

const chartOptions = { color: "blue", grid: true };

export default function Dashboard() {
  // chartOptions is the same object forever
  return <ExpensiveChart data={chartData} options={chartOptions} />;
}

The children Prop Breaks memo

JSX creates a new object every time it runs. So children passed as JSX is a new prop value on every parent render:

const Card = memo(function Card({ children }: { children: React.ReactNode }) {
  console.log("card render");
  return <div className="card">{children}</div>;
});

function Parent() {
  const [n, setN] = useState(0);
  return (
    <>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Card>
        <p>Static content</p>
      </Card>
    </>
  );
}

Card renders on every click, because <p>Static content</p> produces a new element object each time. Wrapping layout components like Card in memo rarely pays off. Memoize the expensive leaf component instead, or hoist the static JSX into a constant if it truly never changes.

Custom Comparison Functions

memo accepts a second argument, arePropsEqual(prevProps, nextProps), which returns true when the render can be skipped:

import { memo } from "react";

type Row = { id: string; name: string; updatedAt: number };

const RowView = memo(
  function RowView({ row, onEdit }: { row: Row; onEdit: (id: string) => void }) {
    return (
      <tr>
        <td>{row.name}</td>
        <td>
          <button onClick={() => onEdit(row.id)}>Edit</button>
        </td>
      </tr>
    );
  },
  (prev, next) =>
    prev.row.id === next.row.id &&
    prev.row.updatedAt === next.row.updatedAt &&
    prev.onEdit === next.onEdit
);

This is useful when your data layer creates new objects for unchanged rows, but each row has a reliable version field like updatedAt.

Be careful with custom comparers:

  • Compare every prop that affects output, including functions. If you ignore onEdit and it changes to a function that closes over new state, the row will call a stale version. Stale closures are among the hardest bugs to track down.
  • Never deep-compare large structures. A deep equality check on a big object can cost more than the render you're trying to avoid.
  • The return value is the opposite of shouldComponentUpdate. true means "props are equal, skip rendering."

Context and memo

A memoized component still re-renders when a context it consumes changes. And if a context provider's value is a new object each render, every consumer re-renders whenever the provider's component does:

import { createContext, useMemo, useState } from "react";

type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggle: () => void };

export const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  const value = useMemo(
    () => ({
      theme,
      toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
    }),
    [theme]
  );

  return <ThemeContext value={value}>{children}</ThemeContext>;
}

Memoizing the value means consumers only re-render when theme actually changes. In React 19 you can render the context directly as a provider, as shown. For larger apps, splitting state and actions into separate contexts, or reaching for a store with selectors, keeps renders narrow. The Context API guide covers those patterns.

Alternatives That Avoid memo Entirely

Before reaching for memo, consider restructuring so the expensive component isn't a child of the changing state in the first place.

Move State Down

If only a small part of the tree uses the state, put the state in that part:

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

export default function Dashboard() {
  return (
    <>
      <SearchBox />
      <ExpensiveChart data={chartData} />
    </>
  );
}

Typing re-renders SearchBox only. No memoization needed.

Lift Content Up as children

If a wrapper needs the state but the expensive content doesn't, pass the content in as children. The children are created by the grandparent, so they don't change when the wrapper's state changes:

function ScrollTracker({ children }: { children: React.ReactNode }) {
  const [scrollY, setScrollY] = useState(0);
  return (
    <div
      style={{ height: 400, overflowY: "auto" }}
      onScroll={(e) => setScrollY(e.currentTarget.scrollTop)}
    >
      <p>Scrolled: {Math.round(scrollY)}px</p>
      {children}
    </div>
  );
}

export default function Page() {
  return (
    <ScrollTracker>
      <ExpensiveChart data={chartData} />
    </ScrollTracker>
  );
}

When scrollY changes, ScrollTracker re-renders, but the children element is the same object that Page created, so React skips it. This is the same reason the children example earlier broke memo: the element's identity depends on where it's created.

The React Compiler Changes the Picture

The React Compiler automatically memoizes components, values, and callbacks at build time. With it enabled, most of the manual memo, useMemo, and useCallback calls in this post become unnecessary, because the compiler caches JSX and computed values based on what they actually depend on.

If you're on the compiler, keep existing memo calls (they're harmless) but don't add new ones by default. Reach for manual memoization only when profiling shows a specific component still re-rendering too much, or when a third-party library relies on reference equality.

When to Use React.memo

Use memo when all of these are true:

  • The component re-renders often with the same props, usually because a parent has frequently changing state.
  • Its render is measurably expensive: a large list, a chart, a complex form section, or anything the Profiler shows as slow.
  • You can keep its props stable, either because they're primitives or because you memoize them.

Skip it when:

  • The component is small and cheap. The comparison costs about as much as the render.
  • Its props change on almost every render anyway.
  • It receives children as JSX from a frequently rendering parent.

Common Mistakes with React.memo

  • Wrapping everything in memo. It adds a comparison to every render and makes code noisier, often with zero benefit. Measure first.
  • Passing inline objects, arrays, or arrow functions. They're new every render and defeat the comparison. Hoist constants, and use useMemo and useCallback for derived values.
  • Ignoring callbacks in custom comparers. Leaving functions out of arePropsEqual causes stale closures.
  • Deep equality checks. Comparing large nested data deeply can be slower than re-rendering.
  • Expecting memo to block context updates. Consumers re-render when context changes regardless of memo. Memoize provider values and split contexts.
  • Relying on memo for correctness. If your component breaks when it re-renders, the bug is in the component. memo is only an optimization.

Frequently Asked Questions (FAQ) About React.memo

memo wraps a component and skips re-rendering it when its props are unchanged. useMemo is a hook that caches a computed value inside a component between renders. They're often used together: useMemo keeps an object or array prop stable so that the memo wrapper on the child can actually skip.

No. It does a shallow comparison of each prop using Object.is. Objects, arrays, and functions are equal only if they are the same reference. You can pass a custom comparison function as the second argument, but avoid deep comparisons on large data because they can cost more than the render.

Usually because one of its props is a new object, array, function, or JSX element on every render. Other causes are state changes inside the component itself and changes to a context it reads. Turn on the Profiler setting that records why each component rendered, and it will name the prop or hook that changed.

No. Most components are cheap to render, and the prop comparison adds overhead and code noise. Apply memo to components that the Profiler shows as expensive and that re-render frequently with the same props.

Mostly not. The compiler memoizes components and values automatically at build time. Keep existing memo calls, since they're harmless, but only add new ones when profiling shows a specific problem the compiler didn't handle.

Yes. In React 19, ref is a regular prop for function components, so you can wrap a component that accepts ref directly in memo. In earlier versions, you wrap the forwardRef result in memo.

Conclusion

React.memo skips re-rendering a component when a shallow comparison finds its props unchanged. It works best on expensive components with stable props, and it fails silently when those props are new objects, arrays, functions, or JSX on every render. Pair it with module-level constants, useMemo, and useCallback, memoize context provider values, and be cautious with custom comparison functions.

Before reaching for memo, try the structural fixes: move state down to where it's used, or pass expensive content in as children. They often solve the problem with less code. And whatever you choose, profile before and after, so every memoization in your codebase is there because it made a measurable difference.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading