memo wraps a component and returns one that remembers its last props and its last output. When its parent re-renders, React 7,897 compares the new props with the old using Object.is on each key — a shallow comparison, one level deep — and if every prop matches it reuses the previous output and skips the component and everything below it.
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
import React, { memo, useState } from 'react';
import { createRoot } from 'react-dom/client';
const n = { plain: 0, stable: 0, unstable: 0 };
function Plain() { n.plain++; return <li>plain, no props — render count {n.plain}</li>; }
const Stable = memo(function Stable({ label }) {
n.stable++;
return <li>memo, string prop "{label}" — render count {n.stable}</li>;
});
const Unstable = memo(function Unstable({ style }) {
n.unstable++;
return <li style={style}>memo, inline object prop — render count {n.unstable}</li>;
});
function App() {
const [count, setCount] = useState(0);
return <div style={{ font: '15px system-ui' }}>
<button onClick={() => setCount((c) => c + 1)}>Clicked {count} times</button>
<ul><Plain /><Stable label="steady" /><Unstable style={{ color: 'teal' }} /></ul>
</div>;
}
createRoot(document.getElementById('root')).render(<App />);
[100, 200, 300].forEach((t) => setTimeout(() => document.querySelector('button').click(), t));
</script>
The clicks are spaced 100 ms apart on purpose: three clicks fired in the same task would be batched into one update and one render. As it is, four renders of App. The plain child runs all four times although it takes no props. Stable runs once — its only prop is a string that never changes. Unstable runs four times, because { color: 'teal' } is a new object on every render and Object.is on two different objects is false: the wrap bought nothing and added a comparison.
That failure is the reason useMemo and useCallback exist, and the commonest way memo is wasted — an object, an array, or an inline arrow passed as a prop defeats it every time. Three further limits matter. memo guards props only, so a component holding its own state, or reading a context whose value changed (Context Performance Pitfalls), re-renders regardless; it does nothing for elements arriving through children, which the parent recreates; and every wrap retains the last props and output in memory.
An optional second argument replaces the shallow check with your own predicate: memo(Row, (prev, next) => prev.item.id === next.item.id) treats two rows as equal when their ids match. Note the polarity — returning true means skip the render, the opposite of the class-era shouldComponentUpdate.