How the React Compiler works
Follow one component through babel-plugin-react-compiler 1.0.0: HIR, SSA, effect inference, reactive scopes, the _c memo cache, and the Rules of React checks.
- 01Name the compiler's stagesFollow a component from Babel's tree through HIR, SSA, inference and reactive scopes to the code with _c(n).
- 02Read the compiler's own printoutsRead HIR, phis, effects, mutable ranges and scope dependencies, and say why a scope checks what it checks.
- 03Explain a bailoutSay which Rules of React the compiler checks, what it does with a function that breaks one, and what it can't catch.
One component, followed through the compiler
The React Compiler lesson showed what the compiler writes: a cache, $, and a check of each input before any work. This lesson opens the compiler and follows one small component through every stage, from your file to the code with _c(9) at the top. Everything you'll read is the real output of babel-plugin-react-compiler 1.0.0, the version this course installs.
Here is the component, a row in a tea shop's cart:
import { TagList } from "./TagList.jsx";
export default function CartRow({ tea, count }) {
const total = tea.price * count;
const tags = [tea.kind];
if (count > 3) {
tags.push("bulk");
}
return (
<li>
{tea.name}: {total} <TagList tags={tags} />
</li>
);
}Line 4 works out the row's total. Line 5 makes an array of tags with the tea's kind, and lines 6 to 8 add "bulk" when there are more than 3. Lines 9 to 13 return a list item with the name, the total and a TagList, the component imported on line 1. It runs below in a small shop. Press Raise the price, then Add one, and watch the log under the buttons:
import { useState } from "react";import CartRow from "./CartRow.jsx"; export default function App() { const [price, setPrice] = useState(4); const [count, setCount] = useState(1); const tea = { name: "Green tea", kind: "green", price }; return ( <> <ul> <CartRow tea={tea} count={count} /> </ul> <button onClick={() => setPrice(price + 1)}>Raise the price</button>{" "} <button onClick={() => setCount(count + 1)}>Add one</button> </> );}App.jsx keeps a price and a count, and passes a new tea object to CartRow on every render (line 7). The TagList.jsx tab writes a line to the log each time TagList renders, with log.js. In development each render writes two lines, because StrictMode calls every component twice. Every click renders TagList again, even Raise the price, which can't change the tags.
Now the same shop, where the CartRow.jsx tab holds what the compiler wrote. Press the same buttons:
import { useState } from "react";import CartRow from "./CartRow.jsx"; export default function App() { const [price, setPrice] = useState(4); const [count, setCount] = useState(1); const tea = { name: "Green tea", kind: "green", price }; return ( <> <ul> <CartRow tea={tea} count={count} /> </ul> <button onClick={() => setPrice(price + 1)}>Raise the price</button>{" "} <button onClick={() => setCount(count + 1)}>Add one</button> </> );}Raise the price changes the total and adds no line to the log: TagList didn't render. Add one does render it, even from 1 to 2, when the tags stay green. By the end of this lesson you'll know exactly why, from the compiler's own printouts: the tags depend on count itself, not on count > 3.
_c(n) cache.Before a busy evening, a kitchen manager rewrites a recipe. She splits it into numbered steps that each do one thing, labels every bowl, notes which bowls are still being mixed, groups the steps that have to be redone together, and adds one note to each group: if the ingredients are the same as last time, serve the plate already on the warmer. The dish is the same. The cooks just skip the work they did before.
- In real life: The recipe, written the way a cook thinks
- In React: Your component, as you wrote it
- In real life: Rewritten as numbered single steps, one action each
- In React: HIR: instructions like PropertyLoad and Binary, in blocks
- In real life: Each bowl gets a label, a new one each time it's refilled
- In React: SSA: tea$34, label$23, label$31
- In real life: Notes on which bowls are still being mixed, and which are plated
- In React: Effects and mutable ranges: Mutate, Freeze, [9:19]
- In real life: Steps that must be redone together are grouped
- In React: Reactive scopes: @0, @1, @2
- In real life: "If the ingredients are the same as last time, serve the plate on the warmer"
- In React: Codegen: if ($[0] !== count || ...) and the slots of _c(9)
The React Compiler is a Babel plugin that rebuilds each component in its own form, works out which values change together, and writes a cache check for each group. It turns Babel's tree into HIR, gives every assignment its own name (SSA), infers what each instruction does to each value, groups values into reactive scopes with their dependencies, and writes each scope as an if over the slots of _c(n), which is React's useMemoCache. Along the way it checks the Rules of React, and leaves alone any function that breaks one.
Where the compiler starts: components and Hooks only
The compiler lives in React's own repository, in the compiler folder. Its npm releases have no git tag, so this lesson links the files at the commit babel-plugin-react-compiler 1.0.0 was published from. Babel parses your file into a tree (the parsing and ASTs lesson shows one), and the plugin visits every function in it. Most functions aren't touched. This file has four:
import { useState } from "react";
function formatPrice(price) {
return "$" + price.toFixed(2);
}
const teaLabel = (tea) => <b>{tea.name}</b>;
function useCartCount(cart) {
const [count] = useState(cart.length);
return count;
}
export default function CartBadge({ cart }) {
const count = useCartCount(cart);
return <span>{count} items, {formatPrice(4)}</span>;
}The compiler sorts each function by its name and body, in getComponentOrHookLike in Program.ts:
function getComponentOrHookLike(
node: NodePath<
t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
>,
hookPattern: string | null,
): ReactFunctionType | null {
const functionName = getFunctionName(node);
// Check if the name is component or hook like:
if (functionName !== null && isComponentName(functionName)) {
let isComponent =
callsHooksOrCreatesJsx(node, hookPattern) &&
isValidComponentParams(node.get('params')) &&
!returnsNonNode(node);
return isComponent ? 'Component' : null;
} else if (functionName !== null && isHook(functionName, hookPattern)) {
// Hooks have hook invocations or JSX, but can take any # of arguments
return callsHooksOrCreatesJsx(node, hookPattern) ? 'Hook' : null;
}
/*
* Otherwise for function or arrow function expressions, check if they
* appear as the argument to React.forwardRef() or React.memo():
*/
if (node.isFunctionExpression() || node.isArrowFunctionExpression()) {
if (isForwardRefCallback(node) || isMemoCallback(node)) {
// As an added check we also look for hook invocations or JSX
return callsHooksOrCreatesJsx(node, hookPattern) ? 'Component' : null;
}
}
return null;
}- Line 7 finds the function's name:
CartBadgefor a declaration,teaLabelfor an arrow stored in aconst. - Lines 9 to 14: a name that starts with a capital letter is a component, if it calls a Hook or makes JSX (line 11), takes valid props (line 12), and doesn't return something that can't be rendered (line 13).
- Lines 15 to 17: a Hook name, such as
useCartCount, is a Hook if it calls a Hook or makes JSX. - Lines 24 to 28: a function expression or an arrow passed to
forwardReformemocounts as a component, if it calls a Hook or makes JSX. - Line 30: anything else returns
null, and the compiler leaves it alone.
The two name checks are short regular expressions, in the same file:
function isHookName(s: string, hookPattern: string | null): boolean {
if (hookPattern !== null) {
return new RegExp(hookPattern).test(s);
}
return /^use[A-Z0-9]/.test(s);
}
// ... (other helpers that check names)
function isComponentName(path: NodePath<t.Expression>): boolean {
return path.isIdentifier() && /^[A-Z]/.test(path.node.name);
}Line 5: a Hook name is use followed by a capital letter or a digit. Line 9: a component name starts with a capital letter. These are the naming rules from Rules of Hooks; the compiler depends on them. Here is what the compiler's logger reported for CartBadge.jsx. Read each row as one function in the file:
| The function | What the compiler sees | What the logger reports |
|---|---|---|
formatPrice | A lowercase name: neither a component nor a Hook. | Nothing. It stays as written. |
teaLabel | An arrow function with a lowercase name. It makes JSX, but the name decides first. | Nothing. It stays as written. |
useCartCount | A Hook name, and it calls useState. | CompileSuccess, with 0 slots: nothing in it is worth caching. |
CartBadge | A capitalized name that calls a Hook and makes JSX. | CompileSuccess, with 3 slots. |
For each function it picks, the plugin calls compileFn, which builds an Environment (the options and what the compiler knows about React's Hooks) and runs every stage in order, in runWithEnvironment in Pipeline.ts:
function runWithEnvironment(
func: NodePath<
t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
>,
env: Environment,
): CodegenFunction {
const log = (value: CompilerPipelineValue): void => {
env.logger?.debugLogIRs?.(value);
};
const hir = lower(func, env).unwrap();
log({kind: 'hir', name: 'HIR', value: hir});Line 10 is the first stage: lower turns Babel's tree for this one function into HIR. Line 11 hands the result to the logger, by name. Lines 7 to 9 explain how: log calls debugLogIRs, if you gave the compiler a logger with one. The pipeline calls log after nearly every pass, 44 times for CartRow, and that's how this lesson recorded every printout you'll see. Pipeline.ts is the compiler's table of contents: each line names a pass, and each pass has its own file in the src folder.
Lowering: from Babel's tree to HIR
Babel's tree is good for printing code but hard to analyze: one expression like tea.price * count hides three steps, and an if hides two paths. So the first stage, lower() in BuildHIR.ts, rewrites the function as HIR, the compiler's High-level Intermediate Representation: a list of small instructions in basic blocks. This is what it printed for CartRow:
CartRow(<unknown> #t0$0): <unknown> $32
bb0 (block):
[1] <unknown> $3 = Destructure Let { tea: <unknown> tea$1, count: <unknown> count$2 } = <unknown> #t0$0
[2] <unknown> $4 = LoadLocal <unknown> tea$1
[3] <unknown> $5 = PropertyLoad <unknown> $4.price
[4] <unknown> $6 = LoadLocal <unknown> count$2
[5] <unknown> $7 = Binary <unknown> $5 * <unknown> $6
[6] <unknown> $9 = StoreLocal Const <unknown> total$8 = <unknown> $7
[7] <unknown> $10 = LoadLocal <unknown> tea$1
[8] <unknown> $11 = PropertyLoad <unknown> $10.kind
[9] <unknown> $12 = Array [<unknown> $11]
[10] <unknown> $14 = StoreLocal Const <unknown> tags$13 = <unknown> $12
[11] <unknown> $19 = LoadLocal <unknown> count$2
[12] <unknown> $20 = 3
[13] <unknown> $21 = Binary <unknown> $19 > <unknown> $20
[14] If (<unknown> $21) then:bb2 else:bb1 fallthrough=bb1
bb2 (block):
predecessor blocks: bb0
[15] <unknown> $15 = LoadLocal <unknown> tags$13
[16] <unknown> $16 = PropertyLoad <unknown> $15.push
[17] <unknown> $17 = "bulk"
[18] <unknown> $18 = MethodCall <unknown> $15.<unknown> $16(<unknown> $17)
[19] Goto bb1
bb1 (block):
predecessor blocks: bb2 bb0
[20] <unknown> $22 = LoadLocal <unknown> tea$1
[21] <unknown> $23 = PropertyLoad <unknown> $22.name
[22] <unknown> $24 = JSXText ": "
[23] <unknown> $25 = LoadLocal <unknown> total$8
[24] <unknown> $26 = JSXText " "
[25] <unknown> $27 = LoadGlobal import { TagList } from './TagList.jsx'
[26] <unknown> $28 = LoadLocal <unknown> tags$13
[27] <unknown> $29 = JSX <<unknown> $27 tags={<unknown> $28} />
[28] <unknown> $30 = JSX <li>{<unknown> $23}{<unknown> $24}{<unknown> $25}{<unknown> $26}{<unknown> $29}</li>
[29] Return Explicit <unknown> $30Read it from the top. Every name ends in $ and a number, so that no two values share a name:
- Line 1: the function, its one parameter (the props object,
#t0$0) and its return value.<unknown>is the effect on that value, which isn't known yet. - Line 2:
bb0, the first basic block. Line 3 is instruction[1]: taketeaandcountout of the props. - Lines 4 to 8 are line 4 of
CartRow.jsx, in five steps: loadteainto the temporary$4, load itspriceinto$5, loadcountinto$6, multiply them into$7, and store that intotal$8. - Lines 9 to 12 build the array
[tea.kind]and store it intags$13. - Lines 13 to 16 are the
if: computecount > 3into$21, then a terminal,If, the last thing in a block. It jumps tobb2when$21is true, else tobb1. - Lines 17 to 23 are
bb2: loadtags, look up itspushmethod, call it with"bulk"(line 22), thenGoto bb1. - Lines 24 to 35 are
bb1, where both paths meet: line 31 loads the importedTagList, line 33 makes its JSX element, line 34 the<li>, and line 35 returns it.
If jumps to bb2 or bb1, Goto jumps to bb1, Return leaves.A block is defined in HIR.ts, with the instructions inside it:
export type BasicBlock = {
kind: BlockKind;
id: BlockId;
instructions: Array<Instruction>;
terminal: Terminal;
preds: Set<BlockId>;
phis: Set<Phi>;
};
// ... (terminals and instruction values)
export type Instruction = {
id: InstructionId;
lvalue: Place;
value: InstructionValue;
loc: SourceLocation;
effects: Array<AliasingEffect> | null;
};Lines 4 and 5: a block is a list of instructions, then one terminal. Line 6 lists the blocks that jump into it (the printout's "predecessor blocks"), and line 7 holds its phis, which the next stage adds. Lines 11 to 13: an instruction has its number ([5]), the place it stores into (its lvalue, like $7) and what it does (Binary $5 * $6). Line 15 stays null until the effects stage fills it in.
The playground shows CartRow after twelve of the compiler's stages, exactly as it printed them. Step through it now to see the shape of the whole trip, then come back to it in each section. Lines that a stage added or changed are marked.
import { TagList } from "./TagList.jsx";
export default function CartRow({ tea, count }) {
const total = tea.price * count;
const tags = [tea.kind];
if (count > 3) {
tags.push("bulk");
}
return (
<li>
{tea.name}: {total} <TagList tags={tags} />
</li>
);
}35 linesCartRow(<unknown> #t0$0): <unknown> $32bb0 (block):[1] <unknown> $3 = Destructure Let { tea: <unknown> tea$1, count: <unknown> count$2 } = <unknown> #t0$0[2] <unknown> $4 = LoadLocal <unknown> tea$1[3] <unknown> $5 = PropertyLoad <unknown> $4.price[4] <unknown> $6 = LoadLocal <unknown> count$2[5] <unknown> $7 = Binary <unknown> $5 * <unknown> $6[6] <unknown> $9 = StoreLocal Const <unknown> total$8 = <unknown> $7[7] <unknown> $10 = LoadLocal <unknown> tea$1[8] <unknown> $11 = PropertyLoad <unknown> $10.kind[9] <unknown> $12 = Array [<unknown> $11][10] <unknown> $14 = StoreLocal Const <unknown> tags$13 = <unknown> $12[11] <unknown> $19 = LoadLocal <unknown> count$2[12] <unknown> $20 = 3[13] <unknown> $21 = Binary <unknown> $19 > <unknown> $20[14] If (<unknown> $21) then:bb2 else:bb1 fallthrough=bb1bb2 (block):predecessor blocks: bb0[15] <unknown> $15 = LoadLocal <unknown> tags$13[16] <unknown> $16 = PropertyLoad <unknown> $15.push[17] <unknown> $17 = "bulk"[18] <unknown> $18 = MethodCall <unknown> $15.<unknown> $16(<unknown> $17)[19] Goto bb1bb1 (block):predecessor blocks: bb2 bb0[20] <unknown> $22 = LoadLocal <unknown> tea$1[21] <unknown> $23 = PropertyLoad <unknown> $22.name[22] <unknown> $24 = JSXText ": "[23] <unknown> $25 = LoadLocal <unknown> total$8[24] <unknown> $26 = JSXText " "[25] <unknown> $27 = LoadGlobal import { TagList } from './TagList.jsx'[26] <unknown> $28 = LoadLocal <unknown> tags$13[27] <unknown> $29 = JSX <<unknown> $27 tags={<unknown> $28} />[28] <unknown> $30 = JSX <li>{<unknown> $23}{<unknown> $24}{<unknown> $25}{<unknown> $26}{<unknown> $29}</li>[29] Return Explicit <unknown> $30
BuildHIR's lower() made 29 numbered instructions in three blocks. Each does one small thing and keeps its result in a temporary like $5. The if became a terminal, If, that jumps to bb2 or straight to bb1.
SSA: one name for every assignment
In CartRow every variable is assigned once. Most components aren't that tidy. In this badge, label gets a value on line 2 and maybe another on line 4:
export default function TeaBadge({ tea }) {
let label = tea.name;
if (tea.onSale) {
label = tea.name + ", on sale";
}
return <span>{label}</span>;
}Which label does line 6 read? It depends on the path. To make that question easy, the compiler moves to SSA (static single assignment): every assignment makes a new, numbered version of the name, and each version is assigned exactly once. JavaScript engines use the same form inside their optimizing compilers. This is TeaBadge after EnterSSA:
TeaBadge(<unknown> #t0$18): <unknown> $17
bb0 (block):
[1] <unknown> $20 = Destructure Let { tea: <unknown> tea$19 } = <unknown> #t0$18
[2] <unknown> $21 = LoadLocal <unknown> tea$19
[3] <unknown> $22 = PropertyLoad <unknown> $21.name
[4] <unknown> $24 = StoreLocal Let <unknown> label$23 = <unknown> $22
[5] <unknown> $25 = LoadLocal <unknown> tea$19
[6] <unknown> $26 = PropertyLoad <unknown> $25.onSale
[7] If (<unknown> $26) then:bb2 else:bb1 fallthrough=bb1
bb2 (block):
predecessor blocks: bb0
[8] <unknown> $27 = LoadLocal <unknown> tea$19
[9] <unknown> $28 = PropertyLoad <unknown> $27.name
[10] <unknown> $29 = ", on sale"
[11] <unknown> $30 = Binary <unknown> $28 + <unknown> $29
[12] <unknown> $32 = StoreLocal Reassign <unknown> label$31 = <unknown> $30
[13] Goto bb1
bb1 (block):
predecessor blocks: bb2 bb0
<unknown> label$33: phi(bb2: <unknown> label$31, bb0: <unknown> label$23)
[14] <unknown> $34 = LoadLocal <unknown> label$33
[15] <unknown> $35 = JSX <span>{<unknown> $34}</span>
[16] Return Explicit <unknown> $35- Line 6: the first assignment stores
label$23. - Line 16, in
bb2: the second assignment stores a new version,label$31. TheReassignsays it was a reassignment in your code. - Line 20, at the top of
bb1: a phi. It makeslabel$33:label$31if control came frombb2,label$23if it came frombb0. - Line 21: the JSX reads
label$33, one name with one definition.
label becomes label$23 and label$31. Where the two paths meet, a phi makes label$33: the version from whichever block ran.Here is where the phi is made, in EnterSSA.ts. When a block reads a name and more than one block leads into it, the version it needs depends on the path:
// There are multiple predecessors, we may need a phi.
const newId = this.makeId(oldPlace.identifier);
/*
* Adding a phi may loop back to our block if there is a loop in the CFG. We
* update our defs before adding the phi to terminate the recursion rather than
* looping infinitely.
*/
state.defs.set(oldPlace.identifier, newId);
return this.addPhi(block, oldPlace, {...oldPlace, identifier: newId});
}
addPhi(block: BasicBlock, oldPlace: Place, newPlace: Place): Identifier {
const predDefs: Map<BlockId, Place> = new Map();
for (const predBlockId of block.preds) {
const predId = this.getIdAt(oldPlace, predBlockId);
predDefs.set(predBlockId, {...oldPlace, identifier: predId});
}
const phi: Phi = {
kind: 'Phi',
place: newPlace,
operands: predDefs,
};
block.phis.add(phi);
return newPlace.identifier;
}- Line 2 makes a new identifier for the name:
label$33. - Line 8 records it as this block's version before looking at the paths, so a loop that leads back into the block finds it and stops.
- Lines 12 to 17: for each block that leads in (
block.preds), find the version that block ends with (getIdAt) and keep it inpredDefs. - Lines 19 to 25: make the phi,
{kind: 'Phi', place, operands}, and add it to the block'sphis.
Phis are only needed where versions really differ. Step CartRow's playground to SSA: bb1 starts with three phis whose two sides are the same version, such as tea$55: phi(bb2: tea$34, bb0: tea$34). The pass after it, EliminateRedundantPhi, removes those three. In TeaBadge the two sides differ, so its phi stays. Step through it here, and on to the code it ends as:
export default function TeaBadge({ tea }) {
let label = tea.name;
if (tea.onSale) {
label = tea.name + ", on sale";
}
return <span>{label}</span>;
}22 linesTeaBadge(<unknown> #t0$0): <unknown> $17bb0 (block):[1] <unknown> $2 = Destructure Let { tea: <unknown> tea$1 } = <unknown> #t0$0[2] <unknown> $3 = LoadLocal <unknown> tea$1[3] <unknown> $4 = PropertyLoad <unknown> $3.name[4] <unknown> $6 = StoreLocal Let <unknown> label$5 = <unknown> $4[5] <unknown> $12 = LoadLocal <unknown> tea$1[6] <unknown> $13 = PropertyLoad <unknown> $12.onSale[7] If (<unknown> $13) then:bb2 else:bb1 fallthrough=bb1bb2 (block):predecessor blocks: bb0[8] <unknown> $7 = LoadLocal <unknown> tea$1[9] <unknown> $8 = PropertyLoad <unknown> $7.name[10] <unknown> $9 = ", on sale"[11] <unknown> $10 = Binary <unknown> $8 + <unknown> $9[12] <unknown> $11 = StoreLocal Reassign <unknown> label$5 = <unknown> $10[13] Goto bb1bb1 (block):predecessor blocks: bb2 bb0[14] <unknown> $14 = LoadLocal <unknown> label$5[15] <unknown> $15 = JSX <span>{<unknown> $14}</span>[16] Return Explicit <unknown> $15
label$5 is stored twice: StoreLocal Let at [4], and StoreLocal Reassign at [12] in bb2. After the if, bb1 loads label$5 at [14], whichever store ran.
Types, effects and mutable ranges
To cache a value, the compiler has to know two things: can it change after it's made, and does the next render need a new one? Four passes in a row work it out. Step the CartRow playground through them as you read.
InferTypes adds a type after a colon. The props are TObject<BuiltInProps>, total is TPrimitive (a number, string or boolean), tags is TObject<BuiltInArray>, and each JSX value is TObject<BuiltInJsx>. A primitive can't be changed in place, and an array has known methods. tea gets no type: nothing in the file says what it is.
InferMutationAliasingEffects writes, under each instruction, what it does to values. The kinds are defined in AliasingEffects.ts; here are the ones CartRow uses:
export type AliasingEffect =
// ... (what Freeze means)
| {kind: 'Freeze'; value: Place; reason: ValueReason}
// ... (what Mutate means)
| {kind: 'Mutate'; value: Place; reason?: MutationReason | null}
// ... (what Capture means, and Alias)
| {kind: 'Capture'; from: Place; into: Place}
// ... (what Assign means)
| {kind: 'Assign'; from: Place; into: Place}
// ... (what Create means, and CreateFrom)
| {kind: 'Create'; into: Place; value: ValueKind; reason: ValueReason}
// ... (what ImmutableCapture means)
| {kind: 'ImmutableCapture'; from: Place; into: Place}- Line 3,
Freeze: the value must never change again.CartRow's printout hasFreeze $64 jsx-captured: oncetagsis passed to JSX, React may keep it. - Line 5,
Mutate: the instruction changes the value.Mutate $51is thepush. - Line 7,
Capture: information flows into a value without making them the same, as when you push an item into an array. - Line 9,
Assign:intois now the very same value, as inAssign tags$46 = $45. - Line 11,
Create: a new value of a kind.Create $45 = mutableis the array literal;Create tea$34 = frozenis a value read from props, which are frozen from the start. - Line 13,
ImmutableCapture: a value is read from a frozen one, which can't affect it.
InferMutationAliasingRanges follows those effects to give each value that can change a mutable range: the instructions from where it's made to just after its last change. The array is $45[9:19]: made at [9], pushed at [18]. Each operand also gets a word for what the instruction does with it: read, capture (read and keep), store, freeze, or mutate? (may change it, if it can be changed).
InferReactivePlaces adds {reactive} to every value that can be different on the next render: the props, everything computed from them, and anything a reactive value changed. The constant 3, "bulk" and the JSX text get no mark, so they'll never need a check.
These passes are also where the compiler catches a change to something frozen. If CartRow wrote tea.name = "Mint", the Mutate of a frozen value would be an error, and the compiler would leave the function alone. The Rules section shows it.
Reactive scopes: what is cached together
Now the compiler can decide what to cache. A value that's made and then changed must be made and changed together: caching tags right after line 5 would skip the push. So inferReactiveScopeVariables puts values that change together, or alias each other, in one reactive scope: a piece of code that will either run whole or be skipped whole. Its first line, 91, groups them with a disjoint set (a structure that merges groups); then each group becomes a scope:
scopeIdentifiers.forEach((identifier, groupIdentifier) => {
let scope = scopes.get(groupIdentifier);
if (scope === undefined) {
scope = {
id: fn.env.nextScopeId,
range: identifier.mutableRange,
dependencies: new Set(),
declarations: new Map(),
reassignments: new Set(),
earlyReturnValue: null,
merged: new Set(),
loc: identifier.loc,
};
scopes.set(groupIdentifier, scope);
} else {
if (scope.range.start === 0) {
scope.range.start = identifier.mutableRange.start;
} else if (identifier.mutableRange.start !== 0) {
scope.range.start = makeInstructionId(
Math.min(scope.range.start, identifier.mutableRange.start),
);
}
scope.range.end = makeInstructionId(
Math.max(scope.range.end, identifier.mutableRange.end),
);
scope.loc = mergeLocation(scope.loc, identifier.loc);
}
identifier.scope = scope;
identifier.mutableRange = scope.range;
});- Lines 2 to 14: the first value of a group makes the scope, with the next scope number (line 5) and that value's mutable range (line 6). Its dependencies and declarations start empty.
- Lines 15 to 25: every other value in the group stretches the range, to start at the earliest start and end at the latest end.
- Lines 28 and 29: each value points at its scope and takes the scope's range as its own.
Step the playground to InferReactiveScopeVariables. The array $45, tags$46, $51 and the push lookup $52 all end in _@0: one scope, @0, with the range [9:19]. The TagList element is @1, and the <li> is @2. total has no scope: a primitive is cheaper to compute than to cache.
A few passes later, PropagateScopeDependenciesHIR lists each scope's dependencies: the values it reads that were made before it. For @0 the printout says dependencies=[tea$34.kind, count$35:TPrimitive]. The scope reads tea.kind for the array and count for the if, so those are what it checks. It can't check the result of count > 3: that's computed inside the scope. That's why Add one made a new tags array, and rendered TagList, even from 1 to 2.
_c(9). The shaded slot in each row is the scope's result, which the next scope depends on.Try it. In the compiled shop above, press Edit & run, open the CartRow.jsx tab, and on line 11 delete $[0] !== count || , so the scope checks only tea.kind. Then press Add one three times. The row never says bulk: the scope never runs again, and the array from the first render stays in slot 2. The compiler checks every dependency so that this can't happen. Press Reset to put the code back.
Back to a tree: ReactiveFunction and the pruning passes
Blocks and jumps are easy to analyze but nothing like the code you'll ship. BuildReactiveFunction turns the graph back into a tree, a ReactiveFunction: the If terminal becomes an if block again, and each scope becomes a block holding the instructions it will run. Step the playground to BuildReactiveFunction: scope @0 [9:22] dependencies=[...] opens a block that holds the array, the if and the push.
Then a series of passes removes scopes and dependencies that would cost more than they save. Their names say what they do, and each file begins with a comment that explains it. Read the table one pass per row, with what it did to CartRow:
| The pass | What it removes or merges | In CartRow |
|---|---|---|
PruneNonEscapingScopes | Scopes whose values never reach the return value or a Hook, so nothing needs them kept. | Nothing changed. |
PruneNonReactiveDependencies | Dependencies that can never change between renders. | Dropped the TagList import and the JSX text from @1 and @2. |
PruneUnusedScopes | Scopes that no longer make a value of their own. | Nothing changed. |
MergeReactiveScopesThatInvalidateTogether | Neighboring scopes that always run again together become one, to save slots and checks. | Nothing changed. Exercise 3's ReceiptLine shows a merge. |
PruneAlwaysInvalidatingScopes | Caching below a value that is new on every render, such as an object literal that isn't cached. | Nothing changed. |
Only one of them changed CartRow: PruneNonReactiveDependencies. Step to it. The TagList import ($62) and the JSX text ($58, $61) can never change between renders, so checking them is wasted work. @1 is left with one dependency, tags, and @2 with three. After a few more passes, RenameVariables gives the scopes' outputs the names you'll see in the code, t1 and t2.
Codegen: every scope becomes a cache check
The last stage, CodegenReactiveFunction, writes JavaScript again with Babel's builders. Its output for CartRow is the CartRow.jsx tab of the compiled shop:
import { c as _c } from "react/compiler-runtime";
import { TagList } from "./TagList.jsx";
export default function CartRow(t0) {
const $ = _c(9);
const {
tea,
count
} = t0;
const total = tea.price * count;
let tags;
if ($[0] !== count || $[1] !== tea.kind) {
tags = [tea.kind];
if (count > 3) {
tags.push("bulk");
}
$[0] = count;
$[1] = tea.kind;
$[2] = tags;
} else {
tags = $[2];
}
let t1;
if ($[3] !== tags) {
t1 = <TagList tags={tags} />;
$[3] = tags;
$[4] = t1;
} else {
t1 = $[4];
}
let t2;
if ($[5] !== t1 || $[6] !== tea.name || $[7] !== total) {
t2 = <li>{tea.name}: {total} {t1}</li>;
$[5] = t1;
$[6] = tea.name;
$[7] = total;
$[8] = t2;
} else {
t2 = $[8];
}
return t2;
}For each scope, codegenReactiveScope gives each dependency a slot and a !== check, then joins the checks with ||:
for (const dep of [...scope.dependencies].sort(compareScopeDependency)) {
const index = cx.nextCacheIndex;
changeExpressionComments.push(printDependencyComment(dep));
const comparison = t.binaryExpression(
'!==',
t.memberExpression(
t.identifier(cx.synthesizeName('$')),
t.numericLiteral(index),
true,
),
codegenDependency(cx, dep),
);
// ... (store the dependency in its slot; then a slot for each declaration)
let testCondition = (changeExpressions as Array<t.Expression>).reduce(
(acc: t.Expression | null, ident: t.Expression) => {
if (acc == null) {
return ident;
}
return t.logicalExpression('||', acc, ident);
},
null as t.Expression | null,
);- Line 1: the dependencies, sorted. That's why line 11 of the compiled file checks
countbeforetea.kind. - Line 2 takes the next free slot number. Lines 4 to 12 build
$[index] !== dependency. - Lines 14 to 22 join every check with
||: the scope runs again if any one of them changed.
In the compiled file, scope @0 is lines 10 to 21: check slots 0 and 1 (line 11), run the scope (lines 12 to 15), store the dependencies and the result in slots 0, 1 and 2 (lines 16 to 18), or else read the result back (line 20). Scope @1 (lines 22 to 29) keeps the TagList element in slot 4. When the tags haven't changed, line 28 reads the element from the last render, the same object, and line 32 puts it in the <li>. React sees the same element and skips TagList: the same bailout the reconciliation lesson showed. Line 9 has no check: total is computed on every render.
Last, the function asks for the right number of slots, in the same file:
const cacheCount = compiled.memoSlotsUsed;
if (cacheCount !== 0) {
const preface: Array<t.Statement> = [];
const useMemoCacheIdentifier =
fn.env.programContext.addMemoCacheImport().name;
// The import declaration for `useMemoCache` is inserted in the Babel plugin
preface.push(
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(cx.synthesizeName('$')),
t.callExpression(t.identifier(useMemoCacheIdentifier), [
t.numericLiteral(cacheCount),
]),
),
]),
);Line 1 is the number of slots the scopes used. Line 5 adds the import of c from react/compiler-runtime to the file, and lines 8 to 17 write const $ = _c(9). That runtime is one line of React, in ReactCompilerRuntime.js:
export {useMemoCache as c} from './ReactHooks';_c is useMemoCache, a Hook. Like every Hook, it keeps its value on the component's fiber (the hooks internals lesson showed where). Here it is in ReactFiberHooks.js:
function useMemoCache(size: number): Array<mixed> {
let memoCache = null;
// Fast-path, load memo cache from wip fiber if already prepared
let updateQueue: FunctionComponentUpdateQueue | null =
currentlyRenderingFiber.updateQueue as any;
if (updateQueue !== null) {
memoCache = updateQueue.memoCache;
}
// ... (or copy the cache from the current fiber, if it has one)
// Finally fall back to allocating a fresh instance of the cache
if (memoCache == null) {
memoCache = {
data: [],
index: 0 as number,
};
}
if (updateQueue === null) {
updateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = updateQueue;
}
updateQueue.memoCache = memoCache;
let data = memoCache.data[memoCache.index];
if (data === undefined || (__DEV__ && ignorePreviousDependencies)) {
data = memoCache.data[memoCache.index] = new Array(size);
for (let i = 0; i < size; i++) {
data[i] = REACT_MEMO_CACHE_SENTINEL;
}
} else if (data.length !== size) {
// TODO: consider warning or throwing here
if (__DEV__) {
console.error(
'Expected a constant size argument for each invocation of useMemoCache. ' +
'The previous cache was allocated with size %s but size %s was requested.',
data.length,
size,
);
}
}
memoCache.index++;
return data;
}- Lines 4 to 8: if this render already made a cache, use it. That's what StrictMode's second call finds.
- Line 9 leaves out the update case: React copies the cache from the fiber on the screen, slot arrays and all, so a render that's thrown away can't spoil the cache the screen uses.
- Lines 11 to 21: on the first render, a new cache, kept in the fiber's
updateQueue. - Lines 23 to 28: the first time, an array of
sizeslots, each holdingREACT_MEMO_CACHE_SENTINEL, the marker for an empty slot:Symbol.for("react.memo_cache_sentinel"). - Lines 29 to 39: a different size on a later render is a mistake, and development builds say so.
- Lines 40 and 41: move on, so a second call in the same component gets its own array, and return this one.
You can call c yourself, to watch two slots. Only the compiler should write this code; this is for learning. Press Render again, then Raise the price, and read the log:
import { useState } from "react";import { c } from "react/compiler-runtime";import { note } from "./log.js"; function PriceLabel({ price }) { const $ = c(2); let label; if ($[0] !== price) { note($[0] === Symbol.for("react.memo_cache_sentinel") ? "slot 0 was empty: make a label" : "new price: make a label"); label = { text: "Price: " + price }; $[0] = price; $[1] = label; } else { note("same price: reuse slot 1"); label = $[1]; } return <p>{label.text}</p>;} export default function App() { const [price, setPrice] = useState(4); const [renders, setRenders] = useState(1); return ( <> <PriceLabel price={price} /> <button onClick={() => setRenders(renders + 1)}>Render again ({renders})</button>{" "} <button onClick={() => setPrice(price + 1)}>Raise the price</button> </> );}Line 6 asks for two slots. On the first render, line 9 finds slot 0 still holding the sentinel, so the log says slot 0 was empty: make a label. Lines 11 and 12 store the price and the label. The second line, same price: reuse slot 1, is StrictMode's second call, which gets the same cache. Render again logs two more same price lines: PriceLabel runs, but line 15 reuses the label. Raise the price logs new price: make a label, then same price: reuse slot 1 for StrictMode's second call.
The Rules of React checks, and bailouts
Everything above assumes the component follows the Rules of React. If it reads a ref while rendering, the effects the compiler inferred would be wrong, and a cache would show stale values. So the pipeline runs checks between its stages, in Pipeline.ts:
if (env.isInferredMemoEnabled) {
if (env.config.validateHooksUsage) {
validateHooksUsage(hir).unwrap();
}
// ... (more passes; the next checks run when memoization is on)
if (env.config.validateRefAccessDuringRender) {
validateNoRefAccessInRender(hir).unwrap();
}
if (env.config.validateNoSetStateInRender) {
validateNoSetStateInRender(hir).unwrap();
}Line 3 runs right after InferTypes, because it needs to know which values are Hooks. Lines 7 and 11 run after the mutable ranges. Each is on by default, and .unwrap() throws the error it found. The plugin catches it, in handleError in Program.ts:
function handleError(
err: unknown,
context: {
opts: PluginOptions;
filename: string | null;
},
fnLoc: t.SourceLocation | null,
): void {
logError(err, context, fnLoc);
if (
context.opts.panicThreshold === 'all_errors' ||
(context.opts.panicThreshold === 'critical_errors' && isError(err)) ||
isConfigError(err) // Always throws regardless of panic threshold
) {
throw err;
}
}Line 9 sends the error to your logger as a CompileError event. Lines 10 to 16 throw it, and fail the build, only if you asked for that with panicThreshold, or if the error is in the compiler's own options (line 13). The default is in Options.ts:
export type Logger = {
logEvent: (filename: string | null, event: LoggerEvent) => void;
debugLogIRs?: (value: CompilerPipelineValue) => void;
};
export const defaultOptions: ParsedPluginOptions = {
compilationMode: 'infer',
panicThreshold: 'none',Line 8: panicThreshold: 'none'. With it, the build goes on, and the function that broke a rule is bailed out: kept exactly as you wrote it, with no cache, while every other function in the file is still compiled. Lines 1 to 4 are the logger you can pass in: logEvent for events, and the optional debugLogIRs this lesson used. Pick a component in the playground:
import { useState } from "react";
export default function TeaNote({ showNote }) {
if (showNote) {
const [note, setNote] = useState("");
}
return <p>Tea</p>;
}left as writtenCompileError Hooks
import { useState } from "react";
export default function TeaNote({
showNote
}) {
if (showNote) {
const [note, setNote] = useState("");
}
return <p>Tea</p>;
}The logger got: Hooks, "Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)". With the default panicThreshold, "none", the build goes on and the function stays exactly as you wrote it.
The first four end with a CompileError in the logger (the ref read logs three, one per .current) and the function left alone; Babel prints it again, so only the spacing can differ. The last one, the clock, compiles with no error at all. That's the next section.
A clock the compiler froze
A shop shows how long the page has been open. Press Refresh after a few seconds:
import { useState } from "react"; const openedAt = Date.now(); function Clock() { const seconds = Math.floor((Date.now() - openedAt) / 1000); return <p>Open for {seconds} seconds</p>;} export default function App() { const [refreshes, setRefreshes] = useState(0); return ( <> <Clock /> <button onClick={() => setRefreshes(refreshes + 1)}>Refresh ({refreshes})</button> </> );}Line 3 notes when the page opened. Line 6 reads the clock while Clock renders. Each Refresh renders App and Clock again, and the seconds go up. Now the compiler's version:
import { c as _c } from "react/compiler-runtime";import { useState } from "react";const openedAt = Date.now();function Clock() { const $ = _c(1); const seconds = Math.floor((Date.now() - openedAt) / 1000); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = <p>Open for {seconds} seconds</p>; $[0] = t0; } else { t0 = $[0]; } return t0;}export default function App() { const $ = _c(6); const [refreshes, setRefreshes] = useState(0); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = <Clock />; $[0] = t0; } else { t0 = $[0]; } let t1; if ($[1] !== refreshes) { t1 = () => setRefreshes(refreshes + 1); $[1] = refreshes; $[2] = t1; } else { t1 = $[2]; } let t2; if ($[3] !== refreshes || $[4] !== t1) { t2 = <>{t0}<button onClick={t1}>Refresh ({refreshes})</button></>; $[3] = refreshes; $[4] = t1; $[5] = t2; } else { t2 = $[5]; } return t2;}It stays at Open for 0 seconds. Two caches hold the old time. In App, lines 20 to 25 keep the <Clock /> element forever: it has no dependencies, so the scope checks for the sentinel and runs once. React gets the same element and doesn't render Clock at all. And inside Clock, line 6 still reads the time, but lines 8 to 13 keep the first <p>: Date.now() isn't a reactive value, so PruneNonReactiveDependencies dropped seconds from the scope's dependencies.
The compiler isn't wrong. It assumes components are pure: same props and state, same result. Reading the clock while rendering breaks that rule, and the check that would catch it, validateNoImpureFunctionsInRender, is off by default. The logger gives you a clue: Clock compiled with 1 slot and no dependencies. To see more, run the compiler yourself with a logger. This Node script prints each event and the HIR, the way this lesson's test does:
// log-compiler.mjs: run with node, next to a CartRow.jsx
import { readFileSync } from "node:fs";
import { transformSync } from "@babel/core";
import reactCompiler, { printFunctionWithOutlined } from "babel-plugin-react-compiler";
const logger = {
logEvent(filename, event) {
console.log(event.kind, event.fnName ?? "", event.memoSlots ?? event.detail?.options?.reason ?? "");
},
debugLogIRs(value) {
if (value.name === "HIR") console.log(printFunctionWithOutlined(value.value));
},
};
const code = readFileSync("CartRow.jsx", "utf8");
const result = transformSync(code, {
filename: "CartRow.jsx",
parserOpts: { plugins: ["jsx"] },
plugins: [[reactCompiler, { logger }]],
});
console.log(result.code);Lines 6 to 13 are the logger: logEvent prints each event's kind with its function and slot count, or the error's reason, and debugLogIRs prints the first stage with the plugin's own printer. Lines 16 to 20 run Babel with the compiler and that logger. The playground at playground.react.dev shows the same stages in your browser.
The fix is to make the time state, set in the event handler, where impure code belongs:
import { c as _c } from "react/compiler-runtime";import { useState } from "react";const openedAt = Date.now();function Clock(t0) { const $ = _c(2); const { now } = t0; const seconds = Math.floor((now - openedAt) / 1000); let t1; if ($[0] !== seconds) { t1 = <p>Open for {seconds} seconds</p>; $[0] = seconds; $[1] = t1; } else { t1 = $[1]; } return t1;}export default function App() { const $ = _c(5); const [now, setNow] = useState(openedAt); let t0; if ($[0] !== now) { t0 = <Clock now={now} />; $[0] = now; $[1] = t0; } else { t0 = $[1]; } let t1; if ($[2] === Symbol.for("react.memo_cache_sentinel")) { t1 = <button onClick={() => setNow(Date.now())}>Refresh</button>; $[2] = t1; } else { t1 = $[2]; } let t2; if ($[3] !== t0) { t2 = <>{t0}{t1}</>; $[3] = t0; $[4] = t2; } else { t2 = $[4]; } return t2;}Now Clock gets the time as a prop, now, so seconds is reactive and line 11 checks it. App's element for Clock depends on now (line 24). The button's handler reads the clock (line 33), which is allowed: handlers run after rendering. Its scope has no dependencies, because setNow never changes.
Common mix-ups
- "The compiler runs in the browser and watches renders." No. It runs once, when you build, as a Babel plugin. The browser only runs the code it wrote, and
useMemoCache. - "It wraps your component in useMemo calls." No. It writes plain
ifchecks over one array of slots per component, from_c(n), which isuseMemoCache. - "It caches every value." No. Primitives like
totalare recomputed, and pruning passes remove scopes that can't save work. - "A scope checks the condition it uses." No. It checks the values it reads from outside, like
count, not expressions computed inside, likecount > 3. - "A Rules of React error stops the build." No. With the default
panicThreshold, the error goes to the logger and that one function is left as written. - "If it compiles, the component is correct." No. Some checks are off by default; the clock compiled and froze.
- Splits
tea.price * countinto loads and aBinary - Turns an
ifinto anIfterminal that jumps between blocks - Adds
label$33: phi(...)where two paths meet - Says props are frozen and the array literal is mutable
- Gives the array the range
[9:19] - Marks values that can change between renders
{reactive} - Puts the array,
tagsand the push in@0 - Lists
tea.kindandcountas@0's dependencies - Writes
const $ = _c(9) - Writes
if ($[3] !== tags)around the TagList element
Put each job under the stage of the compiler that does it.
Practice exercises
Without running anything: what number does the compiler put in _c(n) for this PriceTag?
export default function PriceTag({ price, theme }) {
const style = { color: theme.accent };
return <b style={style}>{price}</b>;
}import { c as _c } from "react/compiler-runtime";
export default function PriceTag(t0) {
const $ = _c(5);
const {
price,
theme
} = t0;
let t1;
if ($[0] !== theme.accent) {
t1 = {
color: theme.accent
};
$[0] = theme.accent;
$[1] = t1;
} else {
t1 = $[1];
}
const style = t1;
let t2;
if ($[2] !== price || $[3] !== style) {
t2 = <b style={style}>{price}</b>;
$[2] = price;
$[3] = style;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
}The style object's scope depends on theme.accent and makes t1: 2 slots. The <b> element's scope depends on price and style, and makes t2: 3 slots. 2 + 3 = 5, so the compiler writes _c(5).
This file has four functions. Which ones does the compiler compile?
import { useState } from "react";
function sortTeas(teas) {
return [...teas].sort();
}
function useTeaFilter(teas) {
const [query, setQuery] = useState("");
return [teas.filter((tea) => tea.includes(query)), setQuery];
}
const teaCount = (teas) => <p>{teas.length} teas</p>;
export function TeaShelf({ teas }) {
const [visible] = useTeaFilter(sortTeas(teas));
return <ul>{visible.map((tea) => <li key={tea}>{tea}</li>)}</ul>;
}import { c as _c } from "react/compiler-runtime";
import { useState } from "react";
function sortTeas(teas) {
return [...teas].sort();
}
function useTeaFilter(teas) {
const $ = _c(7);
const [query, setQuery] = useState("");
let t0;
if ($[0] !== query || $[1] !== teas) {
let t1;
if ($[3] !== query) {
t1 = tea => tea.includes(query);
$[3] = query;
$[4] = t1;
} else {
t1 = $[4];
}
t0 = teas.filter(t1);
$[0] = query;
$[1] = teas;
$[2] = t0;
} else {
t0 = $[2];
}
let t1;
if ($[5] !== t0) {
t1 = [t0, setQuery];
$[5] = t0;
$[6] = t1;
} else {
t1 = $[6];
}
return t1;
}
const teaCount = teas => <p>{teas.length} teas</p>;
export function TeaShelf(t0) {
const $ = _c(6);
const {
teas
} = t0;
let t1;
if ($[0] !== teas) {
t1 = sortTeas(teas);
$[0] = teas;
$[1] = t1;
} else {
t1 = $[1];
}
const [visible] = useTeaFilter(t1);
let t2;
if ($[2] !== visible) {
t2 = visible.map(_temp);
$[2] = visible;
$[3] = t2;
} else {
t2 = $[3];
}
let t3;
if ($[4] !== t2) {
t3 = <ul>{t2}</ul>;
$[4] = t2;
$[5] = t3;
} else {
t3 = $[5];
}
return t3;
}
function _temp(tea) {
return <li key={tea}>{tea}</li>;
}useTeaFilter is a Hook name and calls useState; TeaShelf is capitalized and makes JSX. sortTeas and teaCount have lowercase names, so getComponentOrHookLike returns null for them, and the compiled file keeps them as written.
The compiler gives this component one scope. Which values does that scope check? Name them the way the compiled code does (the total is t1).
import { ReceiptRow } from "./ReceiptRow.jsx";
export default function ReceiptLine({ tea, tax }) {
const line = { name: tea.name, total: tea.price + tax };
return <ReceiptRow line={line} />;
}import { c as _c } from "react/compiler-runtime";
import { ReceiptRow } from "./ReceiptRow.jsx";
export default function ReceiptLine(t0) {
const $ = _c(3);
const {
tea,
tax
} = t0;
const t1 = tea.price + tax;
let t2;
if ($[0] !== t1 || $[1] !== tea.name) {
const line = {
name: tea.name,
total: t1
};
t2 = <ReceiptRow line={line} />;
$[0] = t1;
$[1] = tea.name;
$[2] = t2;
} else {
t2 = $[2];
}
return t2;
}MergeReactiveScopesThatInvalidateTogether puts the line object and the <ReceiptRow> element in one scope, because the element only changes when the object does. That scope reads tea.name and t1, the total, which is computed on line 9 on every render. So the check is $[0] !== t1 || $[1] !== tea.name, and the file asks for _c(3).
In the CartRow.jsx tab, delete $[0] !== count || from line 11. Then press Add one three times. What does the row say?
import { useState } from "react";import CartRow from "./CartRow.jsx"; export default function App() { const [price, setPrice] = useState(4); const [count, setCount] = useState(1); const tea = { name: "Green tea", kind: "green", price }; return ( <> <ul> <CartRow tea={tea} count={count} /> </ul> <button onClick={() => setPrice(price + 1)}>Raise the price</button>{" "} <button onClick={() => setCount(count + 1)}>Add one</button> </> );}import { c as _c } from "react/compiler-runtime";import { TagList } from "./TagList.jsx";export default function CartRow(t0) { const $ = _c(9); const { tea, count } = t0; const total = tea.price * count; let tags; if ($[1] !== tea.kind) { tags = [tea.kind]; if (count > 3) { tags.push("bulk"); } $[0] = count; $[1] = tea.kind; $[2] = tags; } else { tags = $[2]; } let t1; if ($[3] !== tags) { t1 = <TagList tags={tags} />; $[3] = tags; $[4] = t1; } else { t1 = $[4]; } let t2; if ($[5] !== t1 || $[6] !== tea.name || $[7] !== total) { t2 = <li>{tea.name}: {total} {t1}</li>; $[5] = t1; $[6] = tea.name; $[7] = total; $[8] = t2; } else { t2 = $[8]; } return t2;}Without $[0] !== count, scope @0 runs only on the first render. Adding one changes count to 4 and the total to 16, but the row keeps the array from slot 2, which has only green. That's the stale value every dependency check exists to prevent.
This search box works, but the compiler leaves it as written. Which category does its CompileError have? Then rewrite it so the compiler can compile it.
import { useRef, useState } from "react"; export default function TeaSearch() { const inputRef = useRef(null); const [, setTyped] = useState(0); const query = inputRef.current ? inputRef.current.value : ""; return ( <> <input ref={inputRef} onChange={() => setTyped((typed) => typed + 1)} placeholder="Search teas" /> <p>Searching for: {query}</p> </> );}import { useState } from "react"; export default function TeaSearch() { const [query, setQuery] = useState(""); return ( <> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search teas" /> <p>Searching for: {query}</p> </> );}The starter works, but the compiler logs CompileError with the category Refs, "Cannot access refs during render", and leaves it as written. The fix keeps the query in state, set from event.target.value. Nothing reads a ref while rendering, so the compiler compiles it with 3 slots.
Rewrite PriceTag the way the compiler would, with c from react/compiler-runtime and 5 slots. The page should look and behave the same.
import { useState } from "react"; // Rewrite PriceTag with const $ = c(5), the way the compiler would.function PriceTag({ price, theme }) { const style = { color: theme.accent }; return <b style={style}>{price}</b>;} export default function App() { const [price, setPrice] = useState(4); const [theme, setTheme] = useState({ accent: "seagreen" }); return ( <> <PriceTag price={price} theme={theme} />{" "} <button onClick={() => setPrice(price + 1)}>Raise the price</button>{" "} <button onClick={() => setTheme({ accent: "seagreen" })}>Same color, new theme object</button> </> );}import { useState } from "react";import { c } from "react/compiler-runtime"; function PriceTag(t0) { const $ = c(5); const { price, theme } = t0; let t1; if ($[0] !== theme.accent) { t1 = { color: theme.accent }; $[0] = theme.accent; $[1] = t1; } else { t1 = $[1]; } const style = t1; let t2; if ($[2] !== price || $[3] !== style) { t2 = <b style={style}>{price}</b>; $[2] = price; $[3] = style; $[4] = t2; } else { t2 = $[4]; } return t2;} export default function App() { const [price, setPrice] = useState(4); const [theme, setTheme] = useState({ accent: "seagreen" }); return ( <> <PriceTag price={price} theme={theme} />{" "} <button onClick={() => setPrice(price + 1)}>Raise the price</button>{" "} <button onClick={() => setTheme({ accent: "seagreen" })}>Same color, new theme object</button> </> );}This is what the compiler writes for PriceTag, with _c written as c and without export default. "Same color, new theme object" gives a new theme, but theme.accent is the same, so the style in slot 1 and the element in slot 4 come back unchanged.
Check your understanding
Question 1 of 7What does
_c(9)at the top of a compiled component call?Choose an answer to see the explanation.
Question 2 of 7In SSA, what does
label$33: phi(bb2: label$31, bb0: label$23)mean?Choose an answer to see the explanation.
Question 3 of 7CartRow's tags scope checks
$[0] !== count. Whycount, and notcount > 3?Choose an answer to see the explanation.
Question 4 of 7With the default options, what happens to a component that calls a Hook inside an
if?Choose an answer to see the explanation.
Question 5 of 7How many slots does the compiler ask for in this badge?
Read the code, then predictexport default function TeaBadge({ tea }) { let label = tea.name; if (tea.onSale) { label = tea.name + ", on sale"; } return <span>{label}</span>; }Choose an answer to see the explanation.
Question 6 of 7Which pass groups values that are made and changed together into one reactive scope?
Choose an answer to see the explanation.
Question 7 of 7The compiled clock stays at
Open for 0 seconds. Why?Choose an answer to see the explanation.
Key takeaways
- The compiler is a Babel plugin that compiles components and Hooks, picked by their names, one function at a time.
- It lowers each function to HIR: numbered instructions in basic blocks that end in terminals.
- SSA gives each assignment its own version, with phis where paths meet.
- Type and effect inference work out what can change, until when (mutable ranges), and what can differ between renders.
- Values made and changed together form a reactive scope, which checks the values it reads from outside.
- Codegen writes each scope as an
ifover the slots of_c(n), which is React'suseMemoCache. - A function that breaks a checked rule is left as written; one that breaks an unchecked rule can be cached wrongly.
The React Compiler turns each component into small instructions, finds the values that change together, and wraps each group in a check of what it reads.
Write components that follow the Rules of React, and those checks are all you need.
Coming next: Build your own tiny React. You'll write createElement, a fiber work loop, reconciliation and useState yourself, and see the real files each one stands for.