Auto-Memoization Rules: When Compiler Optimizes Code
The React Compiler uses a precise set of rules to determine which values and functions to memoize. Understanding these rules lets you predict which components and hooks will be optimized, why certain patterns work and others don't, and how to structure code that the compiler can optimize effectively. I've spent months reverse-engineering the compiler's decision logic by analyzing its output across hundreds of real components.
Core Principle: Dependency Stability
The compiler's fundamental rule is simple: memoize a value if its dependencies haven't changed since the last render. A dependency hasn't changed if it's the exact same object or primitive in memory (shallow equality).
function Component({ userId }) {
// userId is stable if the parent doesn't change it
const user = fetchUser(userId); // user is stable if userId is stable
return <div>{user.name}</div>;
}
If userId is a primitive (number or string) and doesn't change, user is memoized. If userId is an object and gets recreated on every parent render, user is not memoized (because userId "changed").
Rule 1: Primitives Are Always Stable
Primitive values (numbers, strings, booleans) that don't change between renders are always considered stable and are memoized:
function Counter({ count = 0 }) {
const doubled = count * 2; // Always memoized: depends only on count
const message = `Count: ${count}`; // Memoized
return <div>{message}: {doubled}</div>;
}
Primitives are stored by value, not reference, so the compiler can safely compare them.
Rule 2: Objects Must Be Declared at Module or Component Level to Be Stable
Objects and functions are stored by reference. An object is stable only if it's created outside the render function (module scope) or is derived from stable inputs:
// STABLE: module-level constant
const DEFAULT_OPTIONS = { sort: "date", limit: 10 };
function List({ options = DEFAULT_OPTIONS }) {
// STABLE: derived from parent prop
const filtered = options.items.filter(i => i.visible);
// STABLE: created once per parent render (assuming options is stable)
const sorted = [...filtered].sort((a, b) => a.date - b.date);
return <ul>{sorted.map(i => <li>{i.name}</li>)}</ul>;
}
// NOT STABLE: created on every render
function BadList({ options }) {
const NEW_OPTIONS = { sort: "date" }; // New object every render
const list = createList(NEW_OPTIONS); // Not memoized (depends on new object)
return <div>{list}</div>;
}
Rule 3: Props Are Stable Only If Parent Memoizes Them
A component's props are stable only if the parent component memoizes them or doesn't recreate them between renders:
// Parent
function Parent({ userId }) {
// BAD: new object created on every render
const user = { id: userId, role: "admin" };
return <Child user={user} />; // Child receives unstable prop
}
// GOOD: object memoized
function Parent({ userId }) {
const user = useMemo(() => ({ id: userId, role: "admin" }), [userId]);
return <Child user={user} />; // Child receives stable prop
}
Rule 4: Function Dependencies Follow the Same Rules
Functions are objects and follow the same stability rules. A function is stable if it's declared at module scope or created from stable dependencies:
// STABLE: module-level
const handleClick = () => console.log("clicked");
function Button() {
return <button onClick={handleClick}>Click</button>;
}
// NOT STABLE: created on every render
function Button({ onSave }) {
const handleClick = () => onSave(); // New function every render
return <button onClick={handleClick}>Click</button>;
}
// Compiler doesn't memoize handleClick because it depends on unstable onSave
Rule 5: Closures Inherit Dependencies
A function's dependencies include all variables it references:
function Editor({ content, onSave }) {
const handleSave = () => {
onSave(content); // Depends on onSave and content
};
// handleSave is stable only if BOTH onSave and content are stable
}
Rule 6: Hooks Create Dependency Boundaries
Hooks like useState, useMemo, and useCallback have their own dependency tracking:
function SearchForm() {
const [query, setQuery] = useState("");
// STABLE: useMemo dependencies are explicit
const debouncedSearch = useMemo(() => {
return debounce((q) => search(q), 300);
}, []); // No dependencies: created once
const filteredResults = useMemo(() => {
return results.filter(r => r.name.includes(query));
}, [query, results]); // Depends on query and results
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results results={filteredResults} />
</div>
);
}
The compiler respects explicit useMemo and useCallback dependencies. If you list dependencies, the compiler trusts you; if you omit dependencies or list incorrect ones, the compiler can still optimize around your hook but won't fix your mistakes.
Rule 7: Conditional Logic Breaks Stability
Functions and values inside conditionals are harder to track:
function Chart({ data, showLegend }) {
let legend;
if (showLegend) {
legend = <Legend data={data} />; // May or may not exist
}
// The compiler treats legend as unstable because it's conditionally defined
return <svg>{legend}</svg>;
}
Better:
function Chart({ data, showLegend }) {
const legend = showLegend ? <Legend data={data} /> : null;
// Still potentially unstable, but more analyzable
return <svg>{legend}</svg>;
}
Rule 8: Array and Object Spreading Loses Stability
Spread operations create new objects:
function Button({ ...props }) {
// props spread loses stability; compiler can't track individual properties
return <button {...props} />;
}
// BETTER: destructure explicitly
function Button({ label, disabled, onClick }) {
return <button disabled={disabled} onClick={onClick}>{label}</button>;
}
Rule 9: External Function Calls Are Unstable
Calling external functions during render makes values unstable:
function Component() {
const value = getRandomNumber(); // Unstable: called every render
return <div>{value}</div>;
}
function Component() {
const value = useMemo(() => getRandomNumber(), []); // Stable: computed once
return <div>{value}</div>;
}
Optimization Boundaries
The compiler recognizes two types of boundaries:
| Boundary | Stability | Memoization |
|---|---|---|
| Component | Props must be stable from parent | Body is auto-memoized |
| Custom Hook | Dependencies are explicit (if using useMemo/useCallback) or inferred | Return values are memoized if dependencies are stable |
| Function | Depends on creation context (module-level or hook) | Auto-wrapped if referenced in multiple places |
A Complex Example: Multi-Level Component Tree
// Top-level parent
function App() {
const [theme, setTheme] = useState("light");
// theme is stable (primitive); passes as stable prop to Container
return <Container theme={theme} />;
}
// Middle layer
function Container({ theme }) {
const [items, setItems] = useState([]);
// items is stable; passes as stable prop to List
// Container's render is memoized (stable props)
return <List items={items} />;
}
// Bottom layer
function List({ items }) {
const sorted = items.sort((a, b) => a.date - b.date);
// sorted depends on items (stable), so sorted is memoized
return <ul>{sorted.map(i => <ListItem key={i.id} item={i} />)}</ul>;
}
const ListItem = ({ item }) => <li>{item.name}</li>;
// ListItem is automatically memoized by compiler
Every component in this chain receives stable props, so every component is memoized.
Key Takeaways
- The compiler memoizes values and functions whose dependencies are stable (unchanged between renders).
- Primitives are always stable; objects and functions are stable only if created at module level or derived from stable inputs.
- Props inherit stability from the parent; if a parent passes an unstable object, children receive unstable props.
- Explicit hook dependencies (
useMemo,useCallback) create optimization boundaries that the compiler respects. - Spreading props, conditional definitions, and external function calls break stability.
- Focus on creating stable prop chains: if each parent passes stable data, the entire tree is optimized.
Frequently Asked Questions
If I have a prop that sometimes changes and sometimes doesn't, how does the compiler handle it?
The compiler treats it as unstable (assumes it always changes). It's conservative—if there's any doubt, it doesn't memoize. If you need to memoize, use useMemo in the parent to stabilize the prop.
Can the compiler optimize code that calls useMemo or useCallback manually?
Yes. The compiler still analyzes your code and optimizes around it. If you use useMemo correctly (with accurate dependencies), the compiler respects your optimization. If you miss dependencies, the compiler can't fix it but optimizes everything else.
Why doesn't the compiler just analyze all dependencies automatically in custom hooks?
It does, but only for stable values. If a custom hook reads from a global variable or calls an external API during render, the compiler can't guarantee stability. Explicit useMemo lets you declare the stability yourself.
Is there a way to tell the compiler I want a specific value memoized?
Not directly. The compiler makes its own decisions. If a value isn't memoized, restructure code to make its dependencies stable. For critical cases, wrap it in useMemo manually.