Skip to content

React.memo and Shallow Comparison

advanced11 min read

The Gatekeeper Component

React.memo adds a check before rendering: "Are the new props the same as the old props?" If yes, skip the render entirely. If no, render normally.

// Without memo: renders every time Parent renders
function ExpensiveList({ items, onSelect }) {
  console.log('ExpensiveList rendered');
  return items.map(item => (
    <div key={item.id} onClick={() => onSelect(item.id)}>
      {heavyComputation(item)}
    </div>
  ));
}

// With memo: only renders when items or onSelect actually change
const MemoizedList = memo(function ExpensiveList({ items, onSelect }) {
  console.log('ExpensiveList rendered');
  return items.map(item => (
    <div key={item.id} onClick={() => onSelect(item.id)}>
      {heavyComputation(item)}
    </div>
  ));
});

The Mental Model

Mental Model

Think of React.memo as a security guard at a nightclub door. Without the guard, everyone who walks up gets in (every render). With the guard, they check your ID (props comparison). If your ID matches someone already inside (same props as last render), the guard says "you're already in" and turns you away (skip render).

The guard does a quick glance at each ID field — that's the shallow comparison. They check if the name, photo, and birthdate are the same values. But they don't open your wallet and compare the contents — that would be deep comparison, and the line would be too slow.

If you hand the guard a new ID card with the same information on it, they let you in — because it's a different card (different object reference), even though the data is identical.

How Shallow Comparison Works

React.memo uses Object.is to compare each prop:

// React.memo's comparison (simplified)
function shallowEqual(prevProps, nextProps) {
  const prevKeys = Object.keys(prevProps);
  const nextKeys = Object.keys(nextProps);

  if (prevKeys.length !== nextKeys.length) return false;

  for (const key of prevKeys) {
    if (!Object.is(prevProps[key], nextProps[key])) {
      return false;  // Different value → re-render
    }
  }
  return true;  // All same → skip render
}
Execution Trace
Primitives
Object.is('hello', 'hello') → true
Same string value → props match → skip render
Same ref
Object.is(objA, objA) → true
Same object reference → props match → skip render
New object
Object.is(`{a:1}`, `{a:1}`) → false
Different references → props differ → RE-RENDER (even though values are equal)
New array
Object.is([1,2], [1,2]) → false
Different references → RE-RENDER
New function
Object.is(() => {}, () => {}) → false
Different references → RE-RENDER
NaN
Object.is(NaN, NaN) → true
Object.is treats NaN as equal to NaN (unlike ===)

The critical point: shallow comparison checks references, not values. Two objects with identical contents are "different" if they're different instances.

When memo Helps

React.memo is beneficial when ALL of these are true:

  1. The component re-renders frequently (parent updates often)
  2. The component's props rarely change (most re-renders are unnecessary)
  3. The component is expensive to render (heavy computation, large subtree)
// GOOD use of memo: expensive component, stable props, frequent parent renders
const ChartWidget = memo(function ChartWidget({ data, config }) {
  // Expensive: parses data, computes SVG paths, renders hundreds of elements
  const paths = computeChartPaths(data); // Heavy
  return (
    <svg>
      {paths.map(p => <path key={p.id} d={p.d} stroke={p.color} />)}
    </svg>
  );
});

function Dashboard() {
  const [time, setTime] = useState(Date.now());
  const chartData = useChartData(); // Stable reference (from external store)

  useEffect(() => {
    const id = setInterval(() => setTime(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <>
      <Clock time={time} />  {/* Updates every second */}
      <ChartWidget data={chartData} config={chartConfig} />  {/* memo skips re-render */}
    </>
  );
}

Without memo, ChartWidget re-renders every second (because Dashboard re-renders for the clock). With memo, it only re-renders when chartData or chartConfig changes.

When memo Doesn't Help (or Hurts)

// WASTEFUL: props change every render anyway
const UserCard = memo(function UserCard({ user, onClick }) {
  return <div onClick={onClick}>{user.name}</div>;
});

function UserList({ users }) {
  return users.map(user => (
    <UserCard
      key={user.id}
      user={user}
      onClick={() => selectUser(user.id)}  // New function every render!
    />
  ));
}

The onClick prop is a new function reference every render. Memo compares it: Object.is(oldFn, newFn) → false. The component re-renders anyway. The memo wrapper added overhead (the comparison) with zero benefit.

Common Trap

Common patterns that defeat memo:

// 1. Inline objects
<Child style={{ color: 'red' }} />  // New object every render

// 2. Inline functions
<Child onClick={() => handleClick(id)} /> // New function every render

// 3. Spread props
<Child {...computeProps()} />  // New object every render

// 4. Children as props
<Child><GrandChild /></Child>  // Children are a new React element every render

All of these create new references on every render, causing memo to always re-render. To fix them, stabilize references with useMemo, useCallback, or restructuring.

Custom Comparison Functions

React.memo accepts a custom comparison function:

const UserAvatar = memo(
  function UserAvatar({ user, size }) {
    return <img src={user.avatarUrl} width={size} height={size} alt={user.name} />;
  },
  // Custom comparison: only re-render if avatarUrl or size changed
  (prevProps, nextProps) => {
    return (
      prevProps.user.avatarUrl === nextProps.user.avatarUrl &&
      prevProps.size === nextProps.size
    );
  }
);

The custom comparator returns true to SKIP rendering (props are the same) and false to RE-RENDER (props differ). Note: this is the opposite of shouldComponentUpdate.

Warning

Custom comparison functions are a maintenance burden. They must be updated whenever the component's props change. If you add a new prop and forget to update the comparator, the component won't respond to changes in that prop. Use custom comparators only when the shallow default genuinely can't work (e.g., deep object props where only specific nested values matter).

Production Scenario: memo That Made Things Worse

A team wraps every component in React.memo "for performance." The result:

// Team adds memo to EVERY component
const Button = memo(function Button({ children, onClick, variant }) { ... });
const Text = memo(function Text({ children, size, color }) { ... });
const Icon = memo(function Icon({ name, size }) { ... });
const Flex = memo(function Flex({ children, gap, direction }) { ... });

// Usage in a form — EVERY prop is new every render
function LoginForm() {
  const [email, setEmail] = useState('');

  return (
    <Flex direction="column" gap={16}>  {/* children change → re-render anyway */}
      <Text size="lg" color="primary">Login</Text>  {/* No savings — just overhead */}
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <Button
        onClick={() => login(email)}  {/* New function → re-render */}
        variant="primary"
      >
        <Icon name="login" size={16} />  {/* children change → re-render */}
        Submit
      </Button>
    </Flex>
  );
}

Every component has memo, but almost every prop is a new reference each render (inline functions, children elements, inline objects). Memo checks props (cost), finds they're different (always), and renders anyway (same cost as without memo). Net result: slower than no memo, because the comparison overhead adds up.

The fix: remove memo from small/cheap components. Add it only to demonstrably expensive ones after profiling.

Common Mistakes

What developers doWhat they should do
Wrapping every component in React.memo as a blanket optimization
Memo adds overhead (prop comparison on every render). If props usually change, memo costs more than it saves
Profile first. Only memo components that are expensive AND receive stable props
Using memo without stabilizing object/function props
Inline objects and functions create new references every render, making memo's comparison always fail
Use useMemo for objects and useCallback for functions passed to memoized children
Assuming memo makes a component faster to render
Memo only adds a comparison gate. When props DO change, the component renders normally plus the comparison overhead
memo prevents unnecessary renders. When it does render, it's exactly as fast (or slightly slower) as without memo
Using memo on components that receive children
`<Parent>``<Child />``</Parent>` creates a new React element for Child on every Parent render. children prop fails shallow comparison
Children (JSX elements) are new objects every render. Memo can't help unless children are memoized too

Challenge

Challenge: Will memo prevent the re-render?

const ExpensiveChart = memo(function ExpensiveChart({ data, config, onHover }) {
  // ... renders a complex SVG chart
});

function Dashboard() {
  const [count, setCount] = useState(0);
  const data = useMemo(() => processData(rawData), [rawData]);
  const config = { theme: 'dark', animate: true };

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveChart
        data=`{data}`
        config=`{config}`
        onHover={(point) => console.log(point)}
      />
    </>
  );
}

// When the user clicks the button, does ExpensiveChart re-render?
// If yes, which prop(s) cause it? How would you fix it?
Show Answer

Yes, ExpensiveChart re-renders despite memo. Two props defeat it:

  1. config{ theme: 'dark', animate: true } is a new object every render. Object.is(oldConfig, newConfig)false.

  2. onHover(point) => console.log(point) is a new function every render. Object.is(oldFn, newFn)false.

data is fine — useMemo stabilizes the reference when rawData hasn't changed.

Fix:

function Dashboard() {
  const [count, setCount] = useState(0);
  const data = useMemo(() => processData(rawData), [rawData]);
  const config = useMemo(() => ({ theme: 'dark', animate: true }), []);
  const onHover = useCallback((point) => console.log(point), []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveChart data={data} config={config} onHover={onHover} />
    </>
  );
}

With the React Compiler (React 19+), these manual memoizations become unnecessary — the compiler inserts them automatically.

Quiz

Quiz
React.memo compares props using Object.is. What does Object.is({a:1}, {a:1}) return?

Key Rules

Key Rules
  1. 1React.memo wraps a component to skip re-renders when props haven't changed (via shallow Object.is comparison).
  2. 2Shallow comparison checks references, not values. {a:1} !== {a:1} because they're different objects in memory.
  3. 3memo is only worthwhile when the component is expensive AND its props are mostly stable. Profile before adding memo.
  4. 4Inline objects, arrays, and functions create new references every render, defeating memo. Stabilize them with useMemo and useCallback.
  5. 5Never memo every component by default. The comparison overhead on cheap components with changing props makes things slower.
  6. 6With React 19 Compiler enabled, manual memo/useMemo/useCallback become unnecessary — the compiler handles memoization automatically.