Memoization Guarantees
Overview
The React Compiler automatically memoizes component logic by transforming high-level code into Reactive Scopes. These scopes ensure that expensive computations and object allocations only re-run when their dependencies change. The compiler's primary goal is to provide semantic parity with manual useMemo and useCallback while optimizing for runtime performance and code size.
Escape Analysis and Pruning
To optimize the generated output, the compiler performs Escape Analysis. Not every value created within a component requires memoization. Memoizing every local variable would increase bundle size and potentially increase runtime overhead due to excessive dependency tracking.
Definition of "Escaping"
A value is considered to have "escaped" the local scope of a component or hook if:
- Direct Return: It is returned by the function or transitively aliased by a return value.
- Hook Arguments: It is passed as an input to a hook. Since hooks (like
useEffector custom hooks) can store references internally, the compiler treats these values as escaping. - JSX Props: It is passed as a prop to a JSX element.
Pruning Logic
The compiler includes a PruneNonEscapingScopes pass. This pass identifies identifiers that do not escape and removes their reactive scopes if they are not necessary to bound downstream computations.
function Component(props) {
// This object does not escape and is not a dependency of an escaping value.
// The compiler prunes this scope; it is recreated on every render.
const a = { value: props.val };
// This object is returned, so it escapes.
// The compiler generates a reactive scope to memoize 'b'.
const b = { data: 'constant' };
return b;
}
Transitive Memoization Guarantees
A core guarantee of the compiler is that memoized outputs will not be invalidated by unmemoized dependencies.
If a value b is memoized (because it escapes), all of its transitive dependencies (like a) must also be memoized, even if a itself never escapes. Failing to memoize a dependency would cause the downstream reactive scope to invalidate on every render, breaking the memoization chain.
Interleaved Dependencies
The compiler groups values whose mutations interleave into single scopes. If any value in that scope escapes, the entire scope—and its dependencies—are preserved.
function Component(props) {
// 'a' does not escape, but it is a dependency of 'c'.
// To ensure 'b' (which escapes) remains memoized, 'a' must also be memoized.
const a = [props.a];
const b = [];
const c = {};
c.link = a; // 'a' is now a dependency of the scope containing 'b' and 'c'
b.push(props.b);
return b; // 'b' escapes, forcing memoization of the entire chain
}
Static vs. Dynamic Memoization
The compiler prioritizes Static Memoization (inline dependency checks) over dynamic memoization (like React.memo).
- Static Memoization: Incurs potentially larger code-size but allows the JavaScript engine (JIT) to optimize precise property lookups without dynamic iteration.
- Dynamic Memoization: Uses
React.memoto wrap components, reducing code size but incurring a small runtime overhead for prop comparison.
The compiler's internal logic balances these strategies based on whether values are JSX elements or standard JavaScript objects.
Validation and Constraints
To maintain these guarantees, the compiler enforces specific coding patterns through its validation layer.
Capitalized Function Calls
The compiler reserves capitalized function calls (e.g., const x = MyFunction()) for components, which must be invoked via JSX (e.g., <MyFunction />). Calling a capitalized function directly can break the compiler's ability to track hooks and reactive dependencies accurately.
Allowed exceptions include:
- Built-in globals (e.g.,
String(),Number(),Boolean()). - Functions matching the
hookPattern(e.g.,useCustomHook). - Specific allowlisted constants.
Semantic Preservation
When @enablePreserveExistingMemoizationGuarantees is active, the compiler validates that it can provide at least the same level of memoization as existing useMemo or useCallback calls. If the compiler determines that a manual memoization block would be pruned but contains non-primitive logic, it ensures the reactivity is preserved to prevent performance regressions.
// The compiler ensures 'result' is memoized similarly to the manual useMemo
const result = useMemo(
() => makeObject(props.value).value + 1,
[props.value]
);
Technical Architecture Reference
| Pass | Role |
|:---|:---|
| BuildHIR | Converts AST to High-level Intermediate Representation. |
| InferMutationAliasing | Identifies which values are mutated together and must share a scope. |
| PruneNonEscapingScopes | Removes reactive scopes for values that don't exit the component. |
| ValidateNoCapitalizedCalls | Prevents component-like functions from being called as standard functions. |