The Rules of React: Writing Pure Code for Compilers and Concurrency
In the early days of React, writing idiomatic components was largely guided by community conventions and tribal knowledge. Developers followed general rules of thumb like "don't mutate state directly" and "don't call hooks in conditionals," but the boundaries between permissible JavaScript patterns and dangerous React anti-patterns remained fuzzy.
With the evolution of Concurrent React, Streaming Server-Side Rendering (SSR), and the React Compiler (React documentation), React’s runtime guarantees have tightened. Purity is no longer merely a stylistic preference; it is a strict formal specification known as the Rules of React (React documentation).
When code violates these rules, the consequences are severe: automated compilers bail out of memoization optimizations, concurrent renders produce corrupted DOM state, and streaming servers throw hydration mismatch errors.
In this deep dive, we examine the three pillars of the Rules of React: component idempotence and purity, the strict boundaries between local and non-local mutations, and the immutability contracts that power modern compilation.
Pillar 1: Components and Hooks Must Be Pure
The foundational rule of React is that rendering must be a pure calculation (React documentation). A pure component:
- Is Idempotent: Given the same inputs (props, state, and context), it must always return the exact same JSX output.
- Has No Side Effects in Render: Code that modifies the external world (network requests, DOM mutations, timers) must execute outside of the render phase—either in event handlers or
useEffect. - Does Not Mutate Non-Local Values: A component must never modify variables or objects that existed before the current render cycle began.
The Non-Local Mutation Trap:
// ❌ FAILS: Mutating a non-local variable during render
let globalCounter = 0;
function ImpureHeader() {
globalCounter++; // Side effect during render!
return <h1>Page Views: {globalCounter}</h1>;
}
In Concurrent React, React may render ImpureHeader twice in Strict Mode, or abort and restart rendering if a high-priority user interaction arrives. If rendering is impure, globalCounter increments unpredictable numbers of times, desynchronizing UI from actual state.
Local Mutation vs. Non-Local Mutation: What is Allowed?
A frequent misconception is that React forbids all JavaScript mutations. This is false. Local mutation within the same render pass is completely valid and idiomatic:
// ✅ VALID: Local mutation of objects created in the current render
function ItemList({ items }: { items: string[] }) {
const formattedItems = []; // Created locally in this render pass
for (let i = 0; i < items.length; i++) {
formattedItems.push(items[i].trim().toUpperCase()); // Local push is safe!
}
return <ul>{formattedItems.map(item => <li key={item}>{item}</li>)}</ul>;
}
Because formattedItems was instantiated inside the component body, mutating it affects nothing outside the current render frame.
The Violation: Mutating Props or Existing Data
// ❌ WRONG: Mutating an incoming prop array
function BadSort({ items }: { items: string[] }) {
items.sort(); // Mutates the parent's array in-place!
return <ul>{items.map(item => <li key={item}>{item}</li>)}</ul>;
}
// ✅ CORRECT: Copy before sorting
function GoodSort({ items }: { items: string[] }) {
const sortedItems = [...items].sort(); // Mutates only the local copy
return <ul>{sortedItems.map(item => <li key={item}>{item}</li>)}</ul>;
}
Pillar 2: Props, State, and JSX Values Are Immutable Snapshots
In React, props and state variables are immutable snapshots of a specific moment in time (React documentation).
1. Values Passed to Hooks Are Immutable
Once a value or object is passed as an argument to a custom Hook or React primitive (useMemo, useCallback, useTransition), you must not mutate it. Mutating an argument invalidates memoization caches and leads to stale dependency calculations.
2. Values Passed to JSX Are Immutable
Once an object is passed as a prop into JSX, you must treat it as frozen:
// ❌ WRONG: Mutating after passing to JSX
const config = { theme: 'dark' };
const element = <ThemeCard settings={config} />;
config.theme = 'light'; // Mutating after JSX consumption breaks reconciliation!
Pillar 3: React Calls Components and Hooks
React is a declarative framework. You describe what the UI should look like, and React decides when to call your component functions based on scheduling priorities (React documentation).
Rule: Never Call Component Functions Directly
// ❌ WRONG: Calling a component function directly
function Parent() {
return <div>{ChildComponent({ title: 'Hello' })}</div>;
}
// ✅ CORRECT: Let React orchestrate component calls via JSX
function Parent() {
return <div><ChildComponent title="Hello" /></div>;
}
When you call ChildComponent() directly:
- React cannot attach Hooks to the child's identity in the fiber tree.
- Reconciliation is bypassed, causing the entire subtree to re-render without optimization.
- DevTools cannot inspect the child component as an independent entity.
Pillar 4: The Rules of Hooks
Hooks represent a specialized mechanism for attaching state and lifecycle behavior to a component. To preserve React’s internal fiber call order (React documentation):
- Only Call Hooks at the Top Level: Never call Hooks inside
ifstatements, loops, nested helper functions, ortry/catchblocks (with the singular exception of React 19'suseAPI, which supports conditional unwrapping). - Only Call Hooks from React Functions: Call Hooks only from React function components or custom Hooks. Never call Hooks from arbitrary utility classes or event handlers.
Why Compliance Unlocks the React Compiler
The React Compiler automatically analyzes JavaScript code to insert memoization (useMemo, useCallback) without requiring manual annotations.
However, the compiler operates under the fundamental assumption that your components adhere to the Rules of React. When the compiler detects:
- Mutations of non-local variables
- Direct mutation of props or Hook returns
- Calling impure global functions (
Math.random(),Date.now()) during render
It will bail out of optimizing that component to avoid producing incorrect runtime behavior. By keeping components pure, your codebase becomes automatically optimizable out of the box.
Conclusion
The Rules of React are not arbitrary constraints; they are the architectural bedrock that makes declarative UI, Concurrent rendering, and automated compilation possible. By treating rendering as pure calculation, respecting snapshot immutability, and letting React orchestrate execution, frontend engineers build predictable, bug-free, and high-performance applications.