How hooks work inside
Read ReactFiberHooks.js at v19.3.1: hooks are a chain of objects on the fiber, a dispatcher swaps mount for update, and order is how React finds them.
- 01Follow a hook call into ReactExplain how the dispatcher in the slot H sends useState to mountState on the first render and to updateState after that.
- 02Read a component's chain of hooksSay what each hook object holds in memoizedState and queue, and how the chain is built on mount and copied on update.
- 03Explain the rules from the sourcePoint at the lines that warn about a changed order and that throw when a render calls more or fewer hooks than the last one.
Where useState keeps your count
You have written useState(0) many times. In State: a component's memory, a small model kept each component's state in a list, matched by order. The rules of hooks showed what breaks when that order changes. This lesson opens the real code, ReactFiberHooks.js at the tag v19.3.1, and finds where React keeps your state. Start with a counter, and click its two buttons:
import { useState } from "react";import { HookList } from "./HookList.jsx"; function TeaCounter() { const [count, setCount] = useState(0); const [tea, setTea] = useState("Green tea"); return ( <p> {count} × {tea}{" "} <button onClick={() => setCount(count + 1)}>Add one</button>{" "} <button onClick={() => setTea("Mint tea")}>Switch to mint</button> </p> );} export default function App() { return ( <HookList> <TeaCounter /> </HookList> );}Lines 5 and 6 call useState twice. Line 20 puts TeaCounter inside HookList, from the HookList.jsx tab. After every click, HookList reads what React stored for TeaCounter and lists it under the buttons. You don't need to read that tab yet; the section on the chain explains it.
The list has two entries, one for each useState call:
- The first says
memoizedState: 0. That iscount. Click Add one, and it becomes1. - The second says
memoizedState: "Green tea". Click Switch to mint, and it becomes"Mint tea". - Both end with
queue.dispatch: ƒ bound dispatchSetState. That function is yoursetCountandsetTea: React'sdispatchSetState, with this component's fiber and this hook's queue fixed in place by bind. The next lesson follows it.
Where did HookList read this from? From the component's fiber. A fiber is the object React keeps for each component in the tree, from one render to the next. It has a field called memoizedState, and for a function component that field points at the first hook object. Each hook object has a next field that points at the following one. That is a linked list: a chain of objects where each one knows only the next. (Fiber: React's unit of work covers the rest of a fiber's fields.)
TeaCounter's state on its fiber. fiber.memoizedState points at hook 1, and each hook points at the next one. Hook 1 holds count, 0; hook 2 holds "Green tea". Each useState hook also has a queue for its updates. Hook 2's next is null: the chain ends.HookList.jsx reads fields that aren't part of React's API: a key named __reactFiber$ on DOM nodes, and the fields of a fiber. They exist in React 19.3.1, and the lesson's test checks that they show what this page says. Any release may rename or remove them. Use them to look and learn, never in an app.
A train has an engine and carriages coupled one behind the other. On every trip, the conductor starts at the engine and checks each carriage in turn. The conductor doesn't know the carriages by name, only as "the next one". If a new carriage is coupled into the middle, every carriage behind it is now one place further back, and the conductor finds each group's luggage in the wrong place.
- In real life: The engine, at the front
- In React: The fiber, whose memoizedState points at the first hook
- In real life: A carriage, holding one group's luggage
- In React: A hook object, holding one hook's value in memoizedState
- In real life: The coupling to the next carriage
- In React: The hook's next field
- In real life: The conductor walking back from the engine, one carriage at a time
- In React: React reading one hook object per hook call, in order, on every render
React keeps a component's hooks as a chain of plain objects on its fiber. fiber.memoizedState points at the first hook, and each hook's next points at the one after it. On every render, React walks the chain in call order: the first hook call gets the first object, the second call the second. Which function does the work, one that makes hooks or one that reads them, depends on the dispatcher in the slot H.
One useState, two dispatchers
A tour of the React repository showed that the useState you import does almost nothing: it reads a slot, ReactSharedInternals.H, and calls the useState of the object it finds there. That object is a dispatcher: an object whose fields are the real hook functions. renderWithHooks puts one in the slot just before it calls your component. For a first render or a later one, the production build picks between two. Here they are, shortened, from ReactFiberHooks.js, lines 3926 to 3980:
const HooksDispatcherOnMount: Dispatcher = {
// …
useContext: readContext,
useEffect: mountEffect,
// …
useMemo: mountMemo,
useReducer: mountReducer,
useRef: mountRef,
useState: mountState,
// …
};
const HooksDispatcherOnUpdate: Dispatcher = {
// …
useContext: readContext,
useEffect: updateEffect,
// …
useMemo: updateMemo,
useReducer: updateReducer,
useRef: updateRef,
useState: updateState,
// …
};In plain words, line by line:
- Lines 1 to 11 are
HooksDispatcherOnMount, used on a component's first render. Lines 13 to 23 areHooksDispatcherOnUpdate, used on every render after that. Each line maps a hook's name to a function. The// …lines stand for the hooks left out here. - Line 9 says
useStateismountStateon the first render, and line 21 says it isupdateStatelater. That's how the sameuseState(0)line makes state once and reads it after. - Lines 4 to 8 and 16 to 20 do the same for
useEffect,useMemo,useReducer, anduseRef: amount…function and anupdate…function each. - Lines 3 and 15 are the odd ones:
useContextisreadContextin both. Reading a context works the same on every render, and, as you'll see, it keeps nothing in the chain.
useState(0) line never changes, but the object in the slot H does. On the first render it is HooksDispatcherOnMount, whose useState is mountState. On later renders it is HooksDispatcherOnUpdate, whose useState is updateState.The development build has its own dispatchers, which do the same work plus checks. renderWithHooks picks one at lines 549 to 561:
if (__DEV__) {
if (current !== null && current.memoizedState !== null) {
ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
// This dispatcher handles an edge case where a component is updating,
// but no stateful hooks have been used.
// We want to match the production code behavior (which will use HooksDispatcherOnMount),
// but with the extra DEV validation to ensure hooks ordering hasn't changed.
// This dispatcher does that.
ReactSharedInternals.H = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
}- Line 2 checks the
currentfiber, the one on screen from the last commit. If it exists and has saved hooks, this is an update, and line 3 usesHooksDispatcherOnUpdateInDEV. - Line 4 checks
hookTypesDev, a development-only list of the hook names the last render called. If there is one, the component has rendered before but saved no hooks, like a component that only callsuseContext. Line 10 usesHooksDispatcherOnMountWithHookTypesInDEV: it works like the mount dispatcher, but still checks the order. - Line 12 covers everything else, the first render:
HooksDispatcherOnMountInDEV.
What do the development dispatchers add? Compare their useState, from lines 4122 to 4134 and lines 4450 to 4462. (The two comment lines saying which dispatcher each comes from are the lesson's; the rest is copied.)
// In HooksDispatcherOnMountInDEV:
useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
currentHookNameInDev = 'useState';
mountHookTypesDev();
const prevDispatcher = ReactSharedInternals.H;
ReactSharedInternals.H = InvalidNestedHooksDispatcherOnMountInDEV;
try {
return mountState(initialState);
} finally {
ReactSharedInternals.H = prevDispatcher;
}
},
// In HooksDispatcherOnUpdateInDEV:
useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
currentHookNameInDev = 'useState';
updateHookTypesDev();
const prevDispatcher = ReactSharedInternals.H;
ReactSharedInternals.H = InvalidNestedHooksDispatcherOnUpdateInDEV;
try {
return updateState(initialState);
} finally {
ReactSharedInternals.H = prevDispatcher;
}
},- Line 5 records the name of the hook being called,
'useState', for warnings. Line 6,mountHookTypesDev, adds it tohookTypesDev. On an update, line 21,updateHookTypesDev, compares it with the name saved at this place instead. That is the order check you'll meet in the rules section. - Lines 7 and 8 save the dispatcher and put an
InvalidNested…one in the slot whilemountStateruns. If your initial-state function called a hook, that dispatcher would warn. Line 12 puts the real one back. - Lines 10 and 25 call the same
mountStateandupdateStateas the production build. The work is identical.
Each development dispatcher puts a different InvalidNested… dispatcher in the slot, so the text of its useState names it. The dispatcher.js tab uses that to tell them apart (for looking only, like HookList). Line 7 records which dispatcher is in H each time App is called. Click Add tea:
import { useState, __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as internals } from "react";import { dispatcherName } from "./dispatcher.js"; const calls = []; export default function App() { calls.push(dispatcherName(internals.H)); const [count, setCount] = useState(0); return ( <> <button onClick={() => setCount(count + 1)}>Add tea ({count})</button> <ol> {calls.map((name, index) => ( <li key={index}>{name}</li> ))} </ol> </> );}Before the click, the list has two lines, though the page rendered once: HooksDispatcherOnMountInDEV, then HooksDispatcherOnRerenderInDEV. Live examples run in StrictMode, which calls each component twice in development. The second call goes through renderWithHooksAgain, which uses the rerender dispatcher: it reuses the hooks the first call just made, so state isn't lost between the two calls. After the click, two more lines appear: HooksDispatcherOnUpdateInDEV, then HooksDispatcherOnRerenderInDEV again.
How to read the table: each row is one dispatcher, when the slot holds it, and what its useState does.
| Dispatcher | When the slot H holds it | What its useState does |
|---|---|---|
HooksDispatcherOnMount (development: HooksDispatcherOnMountInDEV) | A component's first render, when its fiber has no saved hooks yet. | mountState: makes a new hook object and its update queue. |
HooksDispatcherOnUpdate (development: HooksDispatcherOnUpdateInDEV) | A later render, when the fiber on screen has saved hooks. | updateState: copies the next saved hook and works through its queue. |
HooksDispatcherOnRerender (development: HooksDispatcherOnRerenderInDEV) | A second pass over the same component in one render: StrictMode's second call in development, or a set call made while rendering. | rerenderState: reuses the hook from the first pass and applies updates made during the render. |
HooksDispatcherOnMountWithHookTypesInDEV | Development only: a later render of a component that keeps nothing in the chain, like one that only calls useContext. | mountState, plus the order check a later render does. |
InvalidNestedHooksDispatcherOnMountInDEV and its update and rerender twins | Development only: while a hook's own code runs, like the function you pass to useMemo. | Warns that hooks can't be called inside other hooks, then does the same work as the outer dispatcher. |
ContextOnlyDispatcher | Outside any render: in event handlers, timers, and effects. | throwInvalidHookError: throws Invalid hook call. |
Each hook call adds an object to a chain
Every hook that keeps something gets one object of this type, from lines 197 to 203:
export type Hook = {
memoizedState: any,
baseState: any,
baseQueue: Update<any, any> | null,
queue: any,
next: Hook | null,
};- Line 2,
memoizedState: what this hook keeps. "Memoized" means remembered from the last render. Each kind of hook keeps something different, as a later section shows. - Lines 3 and 4,
baseStateandbaseQueue: used byuseStateanduseReducerwhen some updates have to wait for a later render. The next lesson covers them. - Line 5,
queue: the update queue of auseStateoruseReducerhook. For other hooks it staysnull. - Line 6,
next: the next hook object, ornullfor the last one.
The file says where the chain lives, right where it declares the variables that build it, at lines 264 to 271:
let currentlyRenderingFiber: Fiber = null as any;
// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;Line 1, currentlyRenderingFiber, is the fiber of the component being rendered right now; renderWithHooks sets it before calling your component. Lines 3 to 6 say it in React's own words: hooks are a linked list on the fiber's memoizedState. Lines 7 and 8 are two pointers into the chains, which the update section uses. They live outside any function, at the top of the file, because only one component renders at a time.
On the first render, every hook starts by calling mountWorkInProgressHook, at lines 983 to 1002:
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}- Lines 2 to 10 make an empty hook object, with every field
null. - Line 12 asks whether this is the component's first hook. If it is, line 14 points both
currentlyRenderingFiber.memoizedStateandworkInProgressHookat it: the chain starts here. - Otherwise, line 17 hangs the new hook on the last one's
next, and movesworkInProgressHookto it, so the next call appends after it. - Line 19 returns the hook. The hook that called it, such as
mountState, fills in what it keeps.
See a longer chain. This product card makes five hook calls. Click Add one, then press Edit & run and add one more hook, like const [note, setNote] = useState(""); after line 7, to watch the chain grow.
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";import { HookList } from "./HookList.jsx"; const Theme = createContext("light"); function ProductCard() { const [qty, setQty] = useState(1); const input = useRef(null); const theme = useContext(Theme); const total = useMemo(() => qty * 120, [qty]); useEffect(() => { document.title = qty + " × Green tea"; }, [qty]); return ( <p className={theme}> Green tea: <input ref={input} value={qty} readOnly size={2} /> {total} rupees{" "} <button onClick={() => setQty(qty + 1)}>Add one</button> </p> );} export default function App() { return ( <HookList> <ProductCard /> </HookList> );}Lines 7 to 13 call useState, useRef, useContext, useMemo, and useEffect, but the chain has four objects. useContext on line 9 calls readContext, which never calls mountWorkInProgressHook. The last line, fiber._debugHookTypes, still lists all five names: that is the hookTypesDev list a development render saves on the fiber, for the order check. In the HookList.jsx tab, fiberOf (lines 7 to 19) reads the __reactFiber$ key React puts on every DOM node it makes, climbs to the root, and finds that node in the tree on screen now. The loop on line 36, in readFiber, then follows memoizedState and next, the same links mountWorkInProgressHook made.
The key's name ends with random letters, made fresh each time react-dom loads, in ReactDOMComponentTree.js:
const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;That's why HookList searches for a name that starts with __reactFiber$. A feature flag, enableInternalInstanceMap, is false in this release; when it is on, React keeps the fiber in a separate map instead. One more reason never to rely on the key.
Later renders walk the saved chain in order
On a later render, React doesn't start empty. It has the chain the last render left. To read it, you need one fact about fibers: React keeps two copies of each one. The current fiber is the one on screen, from the last commit. The work-in-progress fiber is the one React is rendering now. Each points at the other through a field called alternate. When React commits, the work-in-progress copy becomes current. (The fiber lesson shows both trees.)
So a render builds a new chain on the work-in-progress fiber, by copying the saved chain on the current fiber. Every update dispatcher hook starts with updateWorkInProgressHook. Here it is, simplified from lines 1004 to 1073. The full function also reuses a hook made by an earlier pass of the same render, like StrictMode's second call, and checks for a bug in React itself; both parts are left out.
function updateWorkInProgressHook(): Hook {
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}
if (nextCurrentHook === null) {
throw new Error('Rendered more hooks than during the previous render.');
}
currentHook = nextCurrentHook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list.
currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
} else {
// Append to the end of the list.
workInProgressHook = workInProgressHook.next = newHook;
}
return workInProgressHook;
}- Lines 3 to 9 handle the component's first hook call.
currentHookisnull, so line 4 takes the current fiber (the work-in-progress fiber'salternate), and line 6 takes its first saved hook. - Line 11 handles every later call: the next saved hook is
currentHook.next. - Lines 14 to 16: if there is no saved hook left, this render called more hooks than the last one. React throws
Rendered more hooks than during the previous render. - Lines 18 to 28 copy the saved hook into a new object: the same
memoizedState,baseState,baseQueue, andqueue. The queue isn't copied: both hooks point at the same queue object, so updates you queued while the old fiber was on screen are found now. - Lines 30 to 36 append the copy to the new chain, the same way the mount function did. Line 37 returns it.
count becomes 2 and the memo becomes [240, [2]]. Call number 3 always gets saved hook number 3.How to read the table: each row asks one question about the two pointers this function moves.
| Question | currentHook | workInProgressHook |
|---|---|---|
| Which chain is it in? | The saved chain, on the current fiber: the one on screen. | The new chain, on the work-in-progress fiber React is rendering. |
| Where does it start? | At null. The first hook call reads current.memoizedState. | At null. The first hook call sets workInProgress.memoizedState. |
| What does each hook call do to it? | Moves it along next to the saved hook this call reads. | Moves it to the new hook copied from that saved hook. |
When does React put it back to null? | When the component returns, right after the fewer-hooks check. | At the same time. |
Here is the checkout the next playground uses, running. Click its button twice:
import { useEffect, useMemo, useState } from "react"; export default function Checkout() { const [gift, setGift] = useState(false); const [count, setCount] = useState(1); const total = useMemo(() => count * 120, [count]); useEffect(() => { document.title = count + " teas"; }, [count]); return ( <button onClick={() => setCount(count + 1)}> {count} teas, {total} rupees </button> );}Lines 4 to 9 call four hooks: two useState, a useMemo, and a useEffect. Each click runs line 12's setCount, so the page goes from 1 teas, 120 rupees to 2 teas, 240 rupees. Now step through what React does with the chain. Pick a render, then press Step for each hook call. The last two renders are bugs on purpose: one has a hook inside an if, the other returns early.
import { useEffect, useMemo, useState } from "react"; export default function Checkout() { const [gift, setGift] = useState(false); const [count, setCount] = useState(1); const total = useMemo(() => count * 120, [count]); useEffect(() => { document.title = count + " teas"; }, [count]); return ( <button onClick={() => setCount(count + 1)}> {count} teas, {total} rupees </button> );}mountNo saved chain: this is the first render.
New chain (work-in-progress fiber)
No hooks yet.
Checkout appears for the first time, so the slot H holds the mount dispatcher and there is no saved chain.
In First render, each call makes a hook. In Next render, call 1 copies saved hook 1, call 2 copies saved hook 2, and so on: the place is the only link between a call and its state. Gift ticked breaks that link at place 2, and Fourth tea leaves saved hook 4 unused. The next section shows the lines that catch both.
What each hook keeps in memoizedState
The chain is the same for every hook. What goes in each hook's memoizedState is up to the hook. Start with useState, which keeps the most. This is mountStateImpl, shortened from lines 1920 to 1946; the cut lines call your initializer a second time in StrictMode.
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
const initialStateInitializer = initialState;
// …
initialState = initialStateInitializer();
// …
}
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState as any,
};
hook.queue = queue;
return hook;
}- Line 2 adds a hook to the chain.
- Lines 3 to 8: if you passed a function, like
useState(() => readCart()), React calls it here. This runs only on mount, which is why an initializer function runs once. - Line 9 keeps the starting state in
memoizedState, and inbaseState. - Lines 10 to 16 make the update queue: no
pendingupdates yet, nolanes(priorities), adispatchfilled in right after bymountState, and the reducer that will apply updates,basicStateReducer. SouseStateis auseReducerwith a reducer built in.
useRef keeps much less. Here are both of its functions, at lines 2628 to 2638:
function mountRef<T>(initialValue: T): {current: T} {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}
function updateRef<T>(initialValue: T): {current: T} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}On mount, line 3 makes one object, {current: initialValue}, and line 4 keeps it. On every later render, lines 8 to 10 return that same object and ignore the value you pass. That is the whole of useRef: the same object, every render. Changing ref.current changes a field of that object and asks React for nothing, which is why it never renders again.
useMemo keeps a pair. This is updateMemo, shortened from lines 2963 to 2988; the cut lines call your function a second time in StrictMode.
function updateMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null,
): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
// Assume these are defined. If they're not, areHookInputsEqual will warn.
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
const nextValue = nextCreate();
// …
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}Line 7 reads the saved pair. Lines 9 to 14 compare the new dependencies with the saved ones, prevState[1], using areHookInputsEqual, which checks each item with Object.is (see Equality). If all match, line 12 returns the saved value without calling your function. Otherwise line 15 calls it, and line 17 keeps [nextValue, nextDeps]. useCallback keeps the same pair, with your function in place of a value.
useEffect keeps an effect object, and puts it in a second place too. Here is how it mounts, at lines 2640 to 2655:
function mountEffectImpl(
fiberFlags: Flags,
hookFlags: HookFlags,
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags,
createEffectInstance(),
create,
nextDeps,
);
}Line 7 adds a hook to the chain. Line 9 sets a flag on the fiber, so the commit knows this component has effects to run. Lines 10 to 15 keep an effect object in memoizedState, made by pushSimpleEffect: a tag, the inst that will hold your cleanup function, your function (create), and the dependencies. The tag on line 11 joins two bits, from ReactHookEffectTags.js:
export const NoFlags = /* */ 0b0000;
// Represents whether effect should fire.
export const HasEffect = /* */ 0b0001;
// Represents the phase in which the effect (not the clean-up) fires.
export const Insertion = /* */ 0b0010;
export const Layout = /* */ 0b0100;
export const Passive = /* */ 0b1000;HasEffect (line 4) is 1: this effect must run. Passive (line 9) is 8: it runs after the commit, usually after the page paints, which is what useEffect does. So a useEffect on mount has tag 1 + 8 = 9. (useLayoutEffect uses Layout, 4.) pushSimpleEffect hands the effect to pushEffectImpl, at lines 2605 to 2622:
function pushEffectImpl(effect: Effect): Effect {
let componentUpdateQueue: null | FunctionComponentUpdateQueue =
currentlyRenderingFiber.updateQueue as any;
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = componentUpdateQueue as any;
}
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
return effect;
}Lines 4 to 7 give the fiber an updateQueue if it has none. Lines 8 to 16 add the effect to a ring: a list whose last item points back at the first. lastEffect is the newest effect, and lastEffect.next the oldest. So each effect object sits in two places: in its hook, and in the fiber's ring, which the commit phase walks to run effects in order (the commit phase lesson follows it).
On an update, the effect hook makes a new effect object either way. The difference is the tag. This is the middle of updateEffectImpl, at lines 2670 to 2694:
if (currentHook !== null) {
if (nextDeps !== null) {
const prevEffect: Effect = currentHook.memoizedState;
const prevDeps = prevEffect.deps;
// $FlowFixMe[incompatible-type] (@poteto)
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushSimpleEffect(
hookFlags,
inst,
create,
nextDeps,
);
return;
}
}
}
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags,
inst,
create,
nextDeps,
);Lines 3 and 4 read the dependencies the saved effect used. If line 6 finds them equal, lines 7 to 12 push an effect with hookFlags alone, no HookHasEffect, and return: tag 8, which the commit skips. Otherwise lines 18 to 25 set the fiber flag and push one with HookHasEffect: tag 9, run it. See both in an order with two effects. Click Add one, then type a letter in the note:
import { useEffect, useState } from "react";import { HookList } from "./HookList.jsx"; let savedNote = ""; function Order() { const [count, setCount] = useState(1); const [note, setNote] = useState(""); useEffect(() => { document.title = count + " teas in your order"; }, [count]); useEffect(() => { savedNote = note; }, [note]); return ( <p> <button onClick={() => setCount(count + 1)}>Add one ({count})</button>{" "} <input placeholder="Note for the kitchen" value={note} onChange={(event) => setNote(event.target.value)} /> </p> );} export default function App() { return ( <HookList> <Order /> </HookList> );}The first effect, lines 10 to 12, depends on count; the second, lines 14 to 16, on note. At first, the ring says tags 9, 9: both run after the first commit. After Add one, it says tags 9, 8: only the count effect runs. Type H, and it says tags 8, 9. Both effect objects are new each time; only the bit changed.
How to read the table: each row is a hook, what its hook object keeps, and whether it has a queue.
| Hook | What hook.memoizedState holds | Does hook.queue hold anything? |
|---|---|---|
useState, useReducer | The current state, like 1 or "Green tea". | Yes: an update queue, with dispatch, the set function React gave you. |
useRef | The ref object itself, { current: … }. Every render returns this same object. | No. It stays null. |
useMemo | [value, deps]: the value it worked out and the dependencies it used. | No. It stays null. |
useCallback | [callback, deps]: your function and its dependencies. | No. It stays null. |
useEffect, useLayoutEffect | An effect object: tag, create, deps, inst, next. The same object is in the fiber's updateQueue ring. | No. It stays null. |
useContext | Nothing. It makes no hook object, because it calls readContext. | There's no hook to have one. |
Why the rules of hooks exist
Now you can read the rules as code. Each hook call takes the next object in the chain, and nothing else connects a call to its state. So every render must make the same hook calls, in the same order. Here is the checkout with a hook inside an if. Tick Gift wrap:
import { useEffect, useMemo, useRef, useState } from "react"; export default function Checkout() { const [gift, setGift] = useState(false); if (gift) { const message = useRef("Happy birthday!"); } const [count, setCount] = useState(1); const total = useMemo(() => count * 120, [count]); useEffect(() => { document.title = count + " teas"; }, [count]); return ( <label> <input type="checkbox" checked={gift} onChange={(event) => setGift(event.target.checked)} /> Gift wrap {count} teas, {total} rupees </label> );}The result turns into an error, and the console under it shows React's warning first. Line 6 runs for the first time, so this render calls useRef where the last one called useState. The warning lists both renders, place by place:
React has detected a change in the order of Hooks called by Checkout. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks
Previous render Next render
------------------------------------------------------
1. useState useState
2. useState useRef
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^Lines 3 to 7 are the table. Line 6 says that at place 2, the previous render called useState and this one calls useRef. The left column is hookTypesDev, the names saved on the fiber by the last render. The check is updateHookTypesDev, which every hook of the development update dispatcher calls, at lines 323 to 334:
function updateHookTypesDev(): void {
if (__DEV__) {
const hookName = currentHookNameInDev as any as HookType;
if (hookTypesDev !== null) {
hookTypesUpdateIndexDev++;
if (hookTypesDev[hookTypesUpdateIndexDev] !== hookName) {
warnOnHookMismatchInDev(hookName);
}
}
}
}- Line 3 takes the name of the hook being called now, like
'useRef'. - Line 6 moves to the next place in the saved list.
- Line 7 compares the saved name at this place with this call. If they differ, line 8 prints the warning. React warns once per component name, so later mismatches in the same component stay quiet.
The warning doesn't stop anything. useRef still copies saved hook 2, count's state, and returns its memoizedState, the number 1. Then line 8's useState copies saved hook 3, the useMemo hook, and updateReducerImpl finds no queue there, at lines 1323 to 1328:
if (queue === null) {
throw new Error(
'Should have a queue. You are likely calling Hooks conditionally, ' +
'which is not allowed. (https://react.dev/link/invalid-hook-call)',
);
}useRef gets the saved useState hook, and React warns. At place 3, useState gets the saved useMemo hook, which has no queue, so React throws. The saved useEffect is never reached.Returning early breaks the order the other way: the render calls fewer hooks. Here the checkout stops at 3 teas. Click the button three times:
import { useEffect, useMemo, useState } from "react"; export default function Checkout() { const [gift, setGift] = useState(false); const [count, setCount] = useState(1); const total = useMemo(() => count * 120, [count]); if (count > 3) { return <p>Only 3 teas per order.</p>; } useEffect(() => { document.title = count + " teas"; }, [count]); return ( <button onClick={() => setCount(count + 1)}> {count} teas, {total} rupees </button> );}The third click makes count 4, and line 8 returns before the useEffect on line 10. No warning this time: every hook that ran was in its usual place. React finds the problem when the component returns, in finishRenderingHooks, shortened from lines 660 to 712:
ReactSharedInternals.H = ContextOnlyDispatcher;
// This check uses currentHook so that it works the same in DEV and prod bundles.
// hookTypesDev could catch more cases (e.g. context) but only in DEV bundles.
const didRenderTooFewHooks =
currentHook !== null && currentHook.next !== null;
// …
if (didRenderTooFewHooks) {
throw new Error(
'Rendered fewer hooks than expected. This may be caused by an accidental ' +
'early return statement.',
);
}Line 1 puts ContextOnlyDispatcher back in the slot, so hooks called after the render throw. Lines 5 and 6 ask whether the last saved hook this render read, currentHook, still has a next: a saved hook nobody used. If so, lines 8 to 12 throw Rendered fewer hooks than expected. The comment on line 3 matters: this check, and the more-hooks check in updateWorkInProgressHook, use currentHook, so they run in production too. Only the warning table is development-only. How to read the table: each row is one way the order can break, in each build.
| What changed | Development build | Production build |
|---|---|---|
| A place calls a different kind of hook | Prints the warning with the Previous render and Next render table, then goes on. | Prints nothing and goes on. |
| A render calls more hooks than the last one | Throws Rendered more hooks than during the previous render. | Throws error 310, a number with a link to react.dev/errors/310. |
| A render calls fewer hooks than the last one | Throws Rendered fewer hooks than expected… | Throws error 300. |
A useState copies a hook that has no queue | Throws Should have a queue… | Throws error 311. |
That is why the rules are what they are. Call hooks at the top level, never inside conditions, loops, or after an early return, so every render makes the same calls in the same order. Call them only while a component renders, because outside a render the slot holds ContextOnlyDispatcher, whose hooks throw (the source tour showed it). The linter from The rules of hooks checks both before your code runs.
How React DevTools reads your hooks
React DevTools' Components panel lists a component's hooks with their values: State, Ref, Memo, Effect, in call order. It gets them from the same chain. The code is in packages/react-debug-tools. inspectHooksOfFiber sets currentHook to fiber.memoizedState (line 1357), puts a dispatcher of its own in the slot, and calls your component one more time. That dispatcher's hooks only read:
function nextHook(): null | Hook {
const hook = currentHook;
if (hook !== null) {
currentHook = hook.next;
}
return hook;
}
function useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = nextHook();
const state: S =
hook !== null
? hook.memoizedState
: typeof initialState === 'function'
? // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
initialState()
: initialState;Lines 1 to 7 are nextHook: take the hook at currentHook and move along next, the same walk as updateWorkInProgressHook. Lines 9 to 19 are its useState: line 12 takes the next hook, and line 15 uses that hook's memoizedState as the state. The rest of the function records each call with a stack trace (line 307), which is how DevTools groups hooks under your custom hook's name, like useCart (see Custom hooks).
This tells you how to read a confusing value in DevTools: count places. DevTools numbers the hooks that keep something, so hook 4 is the fourth hook object, made by the fourth hook call that keeps something. A useContext call is listed too, but without a number, because it has no hook object. And if a component broke the rules, DevTools reads the chain in the new order and shows the mixed-up values too. Practise matching calls to what they keep:
useState("Green tea")useReducer(cartReducer, [])useRef(null)useMemo(() => qty * 120, [qty])useCallback(() => setQty(qty + 1), [qty])useEffect(() => connect(room), [room])useLayoutEffect(() => measure(), [])
Sort each hook call by what React keeps in its hook's memoizedState.
Common mix-ups
- "React keeps hooks in an array." No. It keeps a chain of objects, each pointing at the next through
next. A small model with an array teaches the same order rule, but the real code has no index to look up. - "useState(1) sets the state to 1 on every render." No. Only
mountStatereads it.updateStateignores it and works from the saved hook. - "useContext is in the chain like the others." No. It calls
readContextand makes no hook object. Only the development list of names,_debugHookTypes, includes it. - "React finds my state by the variable name." No. Hook calls carry no name. The place in the chain is the only link.
- "When an effect's dependencies don't change, React drops the effect." No. It still makes a new effect object, with tag 8 instead of 9, and the commit skips it.
- "The rules of hooks are only a style the linter enforces." No. The chain only works by order. Even production throws when a render calls more or fewer hooks than the last one.
- "Reading __reactFiber$ is a fine way to get a component's state." No. It is private, its name changes on every page load, and a flag already exists to remove it. Use it only to look and learn.
Practice exercises
Some exercises ask you to predict what the inspector shows; press Edit & run to check. Every answer is in a function this lesson showed.
SearchBox calls four hooks. How many hook objects are in its chain? Answer, then press Edit & run to check.
import { createContext, useContext, useMemo, useRef, useState } from "react";
import { HookList } from "./HookList.jsx";
const Theme = createContext("light");
function SearchBox() {
const [query, setQuery] = useState("");
const theme = useContext(Theme);
const input = useRef(null);
const results = useMemo(() => ["Green tea", "Mint tea"].filter((tea) => tea.includes(query)), [query]);
return (
<p className={theme}>
<input ref={input} placeholder="Search teas" value={query} onChange={(event) => setQuery(event.target.value)} />{" "}
{results.join(", ")}
</p>
);
}
export default function App() {
return (
<HookList>
<SearchBox />
</HookList>
);
}import { createContext, useContext, useMemo, useRef, useState } from "react";import { HookList } from "./HookList.jsx"; const Theme = createContext("light"); function SearchBox() { const [query, setQuery] = useState(""); const theme = useContext(Theme); const input = useRef(null); const results = useMemo(() => ["Green tea", "Mint tea"].filter((tea) => tea.includes(query)), [query]); return ( <p className={theme}> <input ref={input} placeholder="Search teas" value={query} onChange={(event) => setQuery(event.target.value)} />{" "} {results.join(", ")} </p> );} export default function App() { return ( <HookList> <SearchBox /> </HookList> );}Three. useState, useRef, and useMemo each add a hook object, and the list shows three rows. useContext adds none, though _debugHookTypes lists all four names.
A component calls useRef(null). On its second render, which function in ReactFiberHooks.js does that call reach? (Production build.)
// HooksDispatcherOnUpdate in ReactFiberHooks.js
useRef: updateRef,
function updateRef<T>(initialValue: T): {current: T} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}On the second render the slot holds the update dispatcher, whose useRef is updateRef. It copies the next saved hook and returns the same { current } object mountRef made.
The cart keeps its addOne function with useCallback. Predict what the inspector shows for hook 2 after one click on Teas: 1, then press Edit & run and click.
import { useCallback, useState } from "react";
import { HookList } from "./HookList.jsx";
function Cart() {
const [qty, setQty] = useState(1);
const addOne = useCallback(function addOne() {
setQty(qty + 1);
}, [qty]);
return <button onClick={addOne}>Teas: {qty}</button>;
}
export default function App() {
return (
<HookList>
<Cart />
</HookList>
);
}import { useCallback, useState } from "react";import { HookList } from "./HookList.jsx"; function Cart() { const [qty, setQty] = useState(1); const addOne = useCallback(function addOne() { setQty(qty + 1); }, [qty]); return <button onClick={addOne}>Teas: {qty}</button>;} export default function App() { return ( <HookList> <Cart /> </HookList> );}Hook 2 shows memoizedState: [ƒ addOne, [2]]: your function and its dependencies. The click changed qty to 2, so updateCallback kept the new function with the new deps.
Badge only calls useContext. Press Dark: which dispatcher answers Badge's first call of that render? Predict, then press Edit & run and click.
import { createContext, useContext, useState, __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as internals } from "react";
import { dispatcherName } from "./dispatcher.js";
const Theme = createContext("light");
const badgeCalls = [];
function Badge() {
badgeCalls.push(dispatcherName(internals.H));
const theme = useContext(Theme);
return <p>Badge in {theme}: {badgeCalls.join(", ")}</p>;
}
export default function App() {
const [theme, setTheme] = useState("light");
return (
<Theme value={theme}>
<button onClick={() => setTheme("dark")}>Dark</button>
<Badge />
</Theme>
);
}import { createContext, useContext, useState, __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as internals } from "react";import { dispatcherName } from "./dispatcher.js"; const Theme = createContext("light");const badgeCalls = []; function Badge() { badgeCalls.push(dispatcherName(internals.H)); const theme = useContext(Theme); return <p>Badge in {theme}: {badgeCalls.join(", ")}</p>;} export default function App() { const [theme, setTheme] = useState("light"); return ( <Theme value={theme}> <button onClick={() => setTheme("dark")}>Dark</button> <Badge /> </Theme> );}After Dark, Badge's list ends with HooksDispatcherOnMountWithHookTypesInDEV, then HooksDispatcherOnRerenderInDEV (StrictMode's second call). Badge has rendered before, but saved no hook objects, so React uses mount functions with the order check.
Click Use a coupon. What does the page show, and why? Then fix the component so it shows TEA10.
import { useRef, useState } from "react";
export default function Coupon() {
const [open, setOpen] = useState(false);
let code;
if (open) {
code = useRef("TEA10");
} else {
code = useState("none");
}
return (
<p>
<button onClick={() => setOpen(true)}>Use a coupon</button> Coupon: {String(code.current)}
</p>
);
}import { useRef, useState } from "react"; export default function Coupon() { const [open, setOpen] = useState(false); const code = useRef("TEA10"); return ( <p> <button onClick={() => setOpen(true)}>Use a coupon</button> Coupon: {open ? code.current : "none"} </p> );}Before the fix, the page shows Coupon: undefined and React warns that the order changed. useRef copied the saved useState hook and returned its state, the string "none", whose .current is undefined. The fix calls one hook, always, at the top level, and decides what to show with open.
This is a small model of the chain, written for this lesson. Write useHook so the first render builds a chain and the second copies it. What does the second render print?
// A small model of React's chain of hooks, written for this lesson.
let fiber = { memoizedState: null };
let savedChain = null; // the chain the last render left
let currentHook = null; // the saved hook we're at
let workInProgressHook = null; // the last hook of the new chain
function useHook(initialValue) {
// Your code. Make a hook object { memoizedState, next: null }:
// on the first render from initialValue, later copied from the next saved hook.
// Hang it on the new chain, and return it.
}
function render(component) {
savedChain = fiber.memoizedState;
fiber = { memoizedState: null };
currentHook = null;
workInProgressHook = null;
component();
}
function Card() {
const tea = useHook("Green tea");
const qty = useHook(1);
console.log(tea.memoizedState, qty.memoizedState);
}
render(Card);
fiber.memoizedState.next.memoizedState = 2; // pretend setQty(2) ran
render(Card);// A small model of React's chain of hooks, written for this lesson.
let fiber = { memoizedState: null };
let savedChain = null; // the chain the last render left
let currentHook = null; // the saved hook we're at
let workInProgressHook = null; // the last hook of the new chain
function useHook(initialValue) {
let hook;
if (savedChain === null) {
hook = { memoizedState: initialValue, next: null };
} else {
currentHook = currentHook === null ? savedChain : currentHook.next;
if (currentHook === null) throw new Error("Rendered more hooks than during the previous render.");
hook = { memoizedState: currentHook.memoizedState, next: null };
}
if (workInProgressHook === null) fiber.memoizedState = workInProgressHook = hook;
else workInProgressHook = workInProgressHook.next = hook;
return hook;
}
function render(component) {
savedChain = fiber.memoizedState;
fiber = { memoizedState: null };
currentHook = null;
workInProgressHook = null;
component();
}
function Card() {
const tea = useHook("Green tea");
const qty = useHook(1);
console.log(tea.memoizedState, qty.memoizedState);
}
render(Card);
fiber.memoizedState.next.memoizedState = 2; // pretend setQty(2) ran
render(Card);The first render prints Green tea 1 and builds two hooks. The line before the second render changes hook 2's value, as an update would. The second render copies each saved hook in order and prints Green tea 2. If the second render called the two useHooks in the other order, each would get the other's value: the model, like React, only knows places.
Check your understanding
For each question, ask which function runs for the call, and which hook object in the chain it reads or makes.
Question 1 of 7In a function component's fiber, what does
memoizedStatepoint at?Choose an answer to see the explanation.
Question 2 of 7How many hook objects does React keep for this component?
Read the code, then predictfunction Profile() { const [name, setName] = useState("Asha"); const theme = useContext(Theme); const avatar = useRef(null); const initials = useMemo(() => name.slice(0, 1), [name]); return <p className={theme}>{initials}</p>; }Choose an answer to see the explanation.
Question 3 of 7In development with StrictMode, the first render calls a component twice. Which dispatcher answers the second call?
Choose an answer to see the explanation.
Question 4 of 7What does
useRefreturn on the second render?Choose an answer to see the explanation.
Question 5 of 7Search first renders with
advancedfalse, then withadvancedtrue. Which call makes React throw?Read the code, then predictfunction Search({ advanced }) { const [query, setQuery] = useState(""); if (advanced) { const box = useRef(null); } const [page, setPage] = useState(1); return <p>{query} {page}</p>; }Choose an answer to see the explanation.
Question 6 of 7An effect's dependencies are the same as last render. What does
updateEffectImpldo?Choose an answer to see the explanation.
Question 7 of 7Why can't React find a hook's state by the variable name, like
count?Choose an answer to see the explanation.
Key takeaways
- A function component's hooks are a chain of plain objects:
fiber.memoizedStatepoints at the first, and each hook'snextat the one after. - The dispatcher in the slot
Hdecides what a hook call does:HooksDispatcherOnMountmakes hooks withmountWorkInProgressHook,HooksDispatcherOnUpdatecopies them withupdateWorkInProgressHook. Development adds dispatchers that check the order. - Each hook keeps its own thing in
memoizedState: the state and a queue, a{ current }object, a[value, deps]pair, or an effect object that is also in the fiber'supdateQueuering.useContextkeeps nothing. - A render must call the same hooks in the same order. React warns when a place changes kind, and throws when a render calls more or fewer hooks than the last one, in production too.
Remember the one-liner.
Hooks are a chain on the fiber, read by place: the dispatcher decides whether each call makes the next hook or copies it.
Coming next: What setState really does. It follows dispatchSetState, the function behind every set function, from your click to the queue this lesson showed.