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
- What React.memo does and when React re-renders by default
- Shallow prop comparison and Object.is
- Why inline objects/arrays/functions break memoization
- Combine React.memo with useMemo and useCallback
- Write a custom comparison function
- When memoizing is and isn't worth it
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 referenceYour 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
// false2️⃣ 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 -> skips3️⃣ 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 AvatarQuick 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)
- memo'd child still re-renders: a prop is a new reference each render. Wrap objects/arrays in useMemo and functions in useCallback.
- Inverted comparison function: returning true means "skip render." Returning the wrong boolean re-renders constantly (or never).
- Memoizing cheap components: the comparison itself costs. Don't wrap tiny leaf nodes that render rarely.
- Passing children as a prop: JSX children are usually new each render, so a memo'd wrapper around {children} rarely skips.
📋 Quick Reference
| Goal | Syntax |
|---|---|
| Memoize a component | const C = memo(function C(props) { ... }); |
| Stable object prop | const o = useMemo(() => ({...}), []); |
| Stable function prop | const f = useCallback(() => fn(), []); |
| Custom compare | memo(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
- ✅ By default, children re-render with their parent
- ✅ React.memo skips a render when props are shallowly equal
- ✅ Inline objects/arrays/functions break memoization — stabilize them
- ✅ memo pairs with useMemo and useCallback
- ✅ A custom compare returns true to skip
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
- Previous: useMemo & useCallback
- Next: Lifting State Up — Share state between components by moving it to a common parent
- Quick reference: React cheat sheet