Ch. 3 · React

React Memo and Reference Stability

Why React.memo still re-renders: inline objects, arrow functions and children break shallow comparison, and how to keep props stable.

~6 min readintermediateupdated Oct 6, 2026

“I wrapped the row in React.memo and it still re-renders on every keystroke. Why?” This is a favourite interview question because the fix is rarely in the memoised component. It is in the parent, which creates a new object or function on every render and hands it down as a prop.

The interviewer wants to hear that memo is a shallow, reference-based comparison, and that memoising a child is only half of a contract the parent must also keep.

Before you start

You should know how props flow from parent to child and that a parent re-render re-renders its children by default. Understand the difference between comparing values and comparing references in JavaScript. Familiarity with useMemo and useCallback helps. Everything here holds in React 18 and 19; if the React Compiler is enabled, it inserts much of this memoisation for you, which is useful to mention but not a substitute for understanding it.

The short answer

memo(Component) makes React skip re-rendering that component when its parent re-renders, as long as every prop is equal to the previous prop by Object.is. Primitives compare by value, but objects, arrays, functions and JSX elements compare by reference, and writing them inline creates a new reference each render. So a memoised child receiving style={{...}} or onClick={() => ...} re-renders every time. Fix it by stabilising those props in the parent, by passing primitives, or by restructuring so the expensive child is not under the changing state at all.

How it works

The default comparison is shallow:

function shallowEqual(prev, next) {
  const keys = Object.keys(next);
  if (keys.length !== Object.keys(prev).length) return false;
  return keys.every(k => Object.is(prev[k], next[k]));
}

shallowEqual({ id: 1 }, { id: 1 });                         // true
shallowEqual({ style: { color: 'red' } },
             { style: { color: 'red' } });                  // false
const fn = () => {};
shallowEqual({ onClick: fn }, { onClick: fn });             // true
shallowEqual({ onClick: () => {} }, { onClick: () => {} }); // false
JavaScript

Now look at a typical parent:

const Chart = memo(function Chart({ data, options, onSelect }) {
  // expensive SVG rendering
});

function Dashboard({ rows }) {
  const [filter, setFilter] = useState('');
  return (
    <>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <Chart
        data={rows.filter(r => r.active)}
        options={{ legend: true }}
        onSelect={id => console.log(id)}
      />
    </>
  );
}
JSX

Typing in the filter re-renders Dashboard. All three props of Chart are fresh references, so memo compares, finds differences, and renders anyway. You now pay for the comparison and the render.

Step-by-step walkthrough

Step 1: Confirm the re-render and its cause

Open React DevTools, enable “Highlight updates when components render”, or record in the Profiler with “Record why each component rendered” turned on. The Profiler will say something like “Props changed: options, onSelect”. That tells you which props to stabilise.

Step 2: Hoist constants out of the component

An object that never depends on props or state should not be created during render:

const CHART_OPTIONS = { legend: true };
// ...
<Chart options={CHART_OPTIONS} />
JSX

This has zero runtime cost and no dependency list.

Step 3: Memoise derived values and callbacks

Values that depend on props need useMemo; functions need useCallback:

const activeRows = useMemo(() => rows.filter(r => r.active), [rows]);
const handleSelect = useCallback(id => setSelectedId(id), []);

<Chart data={activeRows} options={CHART_OPTIONS} onSelect={handleSelect} />
JSX

Now typing in the filter changes none of Chart’s props, and React skips it. Note that rows itself must be stable from its parent, or the chain breaks one level up.

Step 4: Prefer structure over memoisation where possible

Often the cleanest fix is moving the fast-changing state down so it does not re-render the expensive sibling:

function FilterBox() {
  const [filter, setFilter] = useState('');
  return <input value={filter} onChange={e => setFilter(e.target.value)} />;
}

function Dashboard({ rows }) {
  return (
    <>
      <FilterBox />
      <Chart data={rows} />
    </>
  );
}
JSX

Dashboard no longer re-renders on keystrokes, so Chart does not need memo for this case at all. Passing expensive subtrees as children from a stable owner achieves the same effect.

Worked scenario

A data table with 500 memoised rows lags while the user types in a search box above it:

{rows.map(row => (
  <Row
    key={row.id}
    row={row}
    columns={columns.filter(c => c.visible)}
    onToggle={() => toggle(row.id)}
  />
))}
JSX

The Profiler shows every Row rendering with “Props changed: columns, onToggle”. Two references break memoisation: the filtered columns array is rebuilt per row per render, and each row gets a new arrow function.

The fix:

const visibleColumns = useMemo(() => columns.filter(c => c.visible), [columns]);
const onToggle = useCallback(id => {
  setSelected(prev => {
    const next = new Set(prev);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });
}, []);

{rows.map(row => (
  <Row key={row.id} row={row} columns={visibleColumns} onToggle={onToggle} />
))}
JSX

Row now calls onToggle(row.id) itself. Typing re-renders only the table container; all 500 rows are skipped. If selection is shown per row, pass a boolean selected={selected.has(row.id)} so only the changed rows re-render.

Common mistake

  • Memoising the child and forgetting the parent. memo without stable props is pure overhead.
  • Passing children to a memoised component. JSX elements are new objects each render, so <Card>{content}</Card> defeats memo(Card) unless the children are themselves memoised.
  • Expecting memo to block context or local state. A memoised component still re-renders when its own state changes or a context it reads changes. See context scope.
  • Writing a deep custom comparator. A deep compare on large props can be slower than re-rendering, and a comparator that ignores function props can cause stale callbacks.
  • Memoising everything by default. Most components are cheap; measure first.

Verify the behavior

Add a render log and compare before and after stabilising:

const Child = memo(function Child({ config }) {
  console.log('Child rendered');
  return <p>{config.label}</p>;
});

const CONFIG = { label: 'stable' };

export default function Parent() {
  const [n, setN] = useState(0);
  return (
    <>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Child config={{ label: 'inline' }} />
      <Child config={CONFIG} />
    </>
  );
}
JSX

After the initial mount, each click logs Child rendered exactly once: the inline-config child renders, the hoisted-config child is skipped. In the Profiler, the skipped child appears greyed out with “Did not render”.

Follow-up questions

What is the second argument to memo? An optional arePropsEqual(prev, next) that returns true to skip. Use it rarely, and never ignore function props unless you are sure they cannot be stale.

How does useMemo differ from memo? useMemo caches a value inside a component; memo caches a component’s render output based on props.

Does memo prevent the first render? No. It only skips re-renders when props are unchanged.

What does the React Compiler change? It automatically memoises components, JSX and values based on their inputs, so many manual memo, useMemo and useCallback calls become unnecessary in compiled code. See memo, useMemo and useCallback.

Interview exercise

<Avatar user={user} size={48} /> is wrapped in memo, yet it re-renders every time the parent’s unrelated clock ticks. The parent computes const user = { name: profile.name, url: profile.photo }; in its body. Give two fixes and say which you prefer.

Answer and reasoning

The parent creates a new user object each render, so the shallow comparison fails even though the fields are identical. Fix one: const user = useMemo(() => ({ name: profile.name, url: profile.photo }), [profile.name, profile.photo]);. Fix two: change the API to primitives, <Avatar name={profile.name} url={profile.photo} size={48} />, which needs no hook and compares by value. I prefer the second because it removes the reference problem entirely and makes the component’s dependencies explicit. A third option is to move the clock into its own component so the parent does not re-render each tick.

Continue learning

Continue with the React interview questions and the React MCQs. Related notes: useCallback and captured values, memo, useMemo and useCallback and context splitting for performance. Official reference: the memo reference on react.dev.

More in React

read ✓React · hard

React Context Splitting for Performance

Why every React context consumer re-renders when any part of the value changes, and how splitting state, actions and slow-changing data fixes it.

~6 min readread →
read ✓React · mid

React lazy Loading and Code Splitting

Split React bundles with lazy and Suspense, preload chunks before the click, and handle the chunk-load failures that follow every deploy.

~6 min readread →
read ✓React · mid

React Deferred Results and Stale UI

Keep a search input responsive with useDeferredValue, show users when results are stale, and see why memo is required and fetches aren't reduced.

~6 min readread →
esc