React.memo & Avoiding Re-renders

Reviewed & published by Brayan K

React.memo is a higher-order component that tells React to skip re-rendering a component when its props haven't changed. It does a shallow comparison of props by reference. Paired with useMemo/useCallback, it prevents wasted renders in large trees. By the end you'll know exactly why a memoized component sometimes still re-renders — and how to fix it.

Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

1️⃣ The Default: Children Re-render Too

By default, when a component re-renders, all of its children re-render — even if their props didn't change. Usually that's harmless and fast. But for an expensive child, or a child rendered many times, those wasted renders add up. React.memo wraps a component so React compares its props and skips the render when they're shallowly equal.

import { memo } from "react";

// Without memo: re-renders whenever the parent does.
function Avatar({ url, alt }) {
  return <img src={url} alt={alt} />;
}

// With memo: re-renders ONLY when url or alt actually change.
const Avatar = memo(function Avatar({ url, alt }) {
  return <img src={url} alt={alt} />;
});

The demo models that shallow prop comparison so you can see what "props didn't change" really means:

// React.memo skips a re-render when new props are SHALLOWLY equal to the
// old ones. We model that shallow comparison here as plain JS.

function shallowEqual(a, b) {
  const ka = Object.keys(a), kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  return ka.every(key => Object.is(a[key], b[key]));   // compare by reference
}

// Same primitive values -> equal -> React would SKIP the re-render.
console.log(shallowEqual({ name: "Ada", age: 28 }, { name: "Ada", age: 28 }));   // true

// One value differs -> not equal -> React would re-render.
console.log(shallowEqual({ name: "Ada", age: 28 }, { name: "Ada", age: 29 }));   // false

// Same CONTENT but new object reference -> NOT equal (shallow!).
const user1 = { profile: { city: "Oslo" } };
const user2 = { profile: { city: "Oslo" } };
console.log(shallowEqual(user1, user2));   // false — profile is a new reference

Your turn — complete the per-key check that decides whether props are equal:

// 🎯 YOUR TURN — fill in each ___ then run it.
// Finish the per-key comparison so identical props count as "equal".

function shallowEqual(a, b) {
  const ka = Object.keys(a), kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  // 1) Every key must have the same value in both objects (compare by reference).
  return ka.every(key => Object.is(a[key], ___));   // 👉 b[key]
}

console.log(shallowEqual({ x: 1, y: 2 }, { x: 1, y: 2 }));   // expect true
console.log(shallowEqual({ x: 1, y: 2 }, { x: 1, y: 9 }));   // expect false

// ✅ Expected output:
//    true
//    false

2️⃣ Why memo "Doesn't Work": Unstable References

The most common surprise: you wrap a child in memo, but it still re-renders every time. The cause is almost always a prop that's a new reference on every render — an inline object, array, or arrow function. Shallow comparison sees a different reference and gives up.

// ❌ New object + new function every render -> memo never skips
<Avatar style={{ borderRadius: 8 }} onClick={() => open(id)} />

// ✅ Stable references -> memo can skip
const style = useMemo(() => ({ borderRadius: 8 }), []);
const handleClick = useCallback(() => open(id), [id]);
<Avatar style={style} onClick={handleClick} />

The demo proves it: an inline object is never equal across renders, but a stable reference is:

// The #1 reason memo "doesn't work": a new reference passed every render.

function shallowEqual(a, b) {
  return Object.keys(a).every(k => Object.is(a[k], b[k]))
      && Object.keys(a).length === Object.keys(b).length;
}

// Simulate two renders that pass an INLINE object prop.
const render1 = { style: { color: "red" } };   // new object
const render2 = { style: { color: "red" } };   // brand-new object again
console.log("inline object stable?", shallowEqual(render1, render2));   // false -> re-renders

// Now simulate a STABLE reference (as useMemo would give you).
const stableStyle = { color: "red" };
const r1 = { style: stableStyle };
const r2 = { style: stableStyle };            // same reference reused
console.log("memoized object stable?", shallowEqual(r1, r2));         // true -> skips

3️⃣ Custom Comparison Functions

memo accepts an optional second argument: a function (prevProps, nextProps) => boolean. Return true to skip the render (props are "equal"), false to re-render. Use it when shallow comparison isn't enough — but keep it cheap, since it runs every render.

const Row = memo(
  function Row({ user }) {
    return <li>{user.name}</li>;
  },
  // return true => "equal, skip render"; compare only the field we care about
  (prev, next) => prev.user.id === next.user.id
);

Beware: the return value is inverted from what you might expect. true means "they match, don't re-render." A common bug is returning the opposite and re-rendering constantly.

Reorder Challenge

These lines define a memoized child and the stable props its parent must pass. Put them in a correct order:

A.   const Avatar = memo(function Avatar({ style, onClick }) {
B.   const handleClick = useCallback(() => open(id), [id]);
C.   const style = useMemo(() => ({ size: 40 }), []);
D.   return <Avatar style={style} onClick={handleClick} />;
E.   });   // closes Avatar

Quick Recall

1) A memoized child gets style={{ color: "red" }} inline. Does memo skip its render?

No. The inline object is a new reference every render, so the shallow comparison fails and the child re-renders.

2) In a custom comparison function, what does returning true mean?

"Props are equal — skip the re-render." It is the inverse of what people often expect.

3) Does React.memo compare props shallowly or deeply by default?

Shallowly — each prop is compared with Object.is. Nested objects with equal contents but different references count as different.

Common Errors (and the fix)

📋 Quick Reference

GoalSyntax
Memoize a componentconst C = memo(function C(props) { ... });
Stable object propconst o = useMemo(() => ({...}), []);
Stable function propconst f = useCallback(() => fn(), []);
Custom comparememo(C, (prev, next) => prev.id === next.id)

Mini-Challenge: Will It Skip?

Use the shallow comparison to decide whether memo would skip a re-render. Compare two prop objects where one shares a reference and one is inline. Predict each result first.

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

const cb = () => {};          // a stable function reference
// your code here:
// 1) compare { onClick: cb } vs { onClick: cb }  -> should skip (true)
// 2) compare { data: [1] } vs { data: [1] }      -> should NOT skip (false)

🎉 Lesson Complete

Practice quiz

What does React.memo do?

  • Caches a component's state
  • Memoizes a value like useMemo
  • Skips re-rendering when props are shallowly equal to the previous props
  • Prevents all re-renders forever

Answer: Skips re-rendering when props are shallowly equal to the previous props. memo wraps a component so React reuses its last output when props haven't changed (shallowly).

How does React.memo compare props by default?

  • Shallowly, with Object.is per prop
  • Deeply, recursively
  • By JSON.stringify
  • By component name

Answer: Shallowly, with Object.is per prop. It compares each prop with Object.is; nested objects with equal content but new references count as different.

By default, when a component re-renders, its children…

  • Never re-render
  • Re-render only if memoized
  • Unmount and remount
  • Re-render too, even if their props didn't change

Answer: Re-render too, even if their props didn't change. Children re-render with their parent by default; memo lets React skip that when props are unchanged.

Why does a memoized child still re-render every time here: <Avatar style={{ borderRadius: 8 }} />?

  • memo only works on classes
  • The inline object is a new reference each render, so shallow comparison fails
  • style isn't a valid prop
  • It needs a key

Answer: The inline object is a new reference each render, so shallow comparison fails. Inline objects/arrays/functions create a new reference every render, breaking the shallow comparison.

How do you stabilize an object prop so memo can skip?

  • useMemo(() => ({...}), [])
  • useState
  • useRef each render
  • Inline it

Answer: useMemo(() => ({...}), []). useMemo returns a stable reference across renders so the shallow comparison passes.

How do you stabilize a function prop passed to a memoized child?

  • useMemo
  • useState
  • useCallback
  • bind it inline

Answer: useCallback. useCallback keeps the same function identity across renders.

In a custom comparison function passed to memo, returning true means…

  • Re-render the component
  • Props are equal — skip the re-render
  • Throw an error
  • Force a deep compare

Answer: Props are equal — skip the re-render. memo's compare returns true to skip (props 'equal') and false to re-render — the inverse of what many expect.

What is the signature of memo's optional second argument?

  • (state, action) => state
  • (a, b) => number
  • () => void
  • (prevProps, nextProps) => boolean

Answer: (prevProps, nextProps) => boolean. It's a comparison function (prevProps, nextProps) => boolean controlling whether to skip the render.

Should you wrap every component in React.memo?

  • Yes, always
  • No — the comparison has a cost; use it for components that render often or are expensive, and profile first
  • Yes, for performance
  • Only class components

Answer: No — the comparison has a cost; use it for components that render often or are expensive, and profile first. memo adds a comparison each render; apply it selectively and measure before optimizing.

Why does a memo'd wrapper around {children} rarely skip rendering?

  • children can't be a prop
  • memo ignores children
  • JSX children are usually a new reference each render
  • children are always equal

Answer: JSX children are usually a new reference each render. Children passed as JSX are typically new each render, so the shallow comparison fails.

Continue this course