facebook / react · Issue No. 37655
A component that suspends twice in one mount via two use() calls on two different pending promises ends up with a corrupted _debugHookTypes, and the next ordinary render logs
React has detected a change in the order of Hooks called by X. This will lead to bugs and errors if not fixed.
The component does not break any rule of hooks. The real hook chain is fine — only the DEV hook-type record is wrong.
The trigger requires the hooks between the two use() calls to all be non-stateful (useContext, useDebugValue), because those do not advance the work-in-progress hook cursor.
react / react-dom 19.2.8. The relevant code appears unchanged on main.
Dev build, no framework:
import React, { createContext, useContext, useState, useEffect, use, Suspense } from "react";
import { createRoot } from "react-dom/client";
// Settled before the first render, but not yet tracked by React,
// so each use() suspends once and React replays the component.
const userPromise = Promise.resolve("user");
const companyPromise = Promise.resolve("company");
const A = createContext("a");
const B = createContext("b");
const C = createContext("c");
const D = createContext("d");
const E = createContext("e");
function Page() {
useContext(A);
useContext(B);
useContext(C);
use(userPromise); // first suspend
useContext(D); // <-- gets recorded twice
use(companyPromise); // second suspend
useContext(E);
const [n, setN] = useState(0);
useEffect(() => { setN(1); }, []); // forces one update render
return <div>rendered {n}</div>;
}
createRoot(document.getElementById("root")).render(
<Suspense fallback={<div>loading</div>}><Page /></Suspense>
);
No warning. Page calls the same hooks in the same order on every render.
React has detected a change in the order of Hooks called by Page. ...
Previous render Next render
------------------------------------------------------
1. useContext useContext
2. useContext useContext
3. useContext useContext
4. useContext useContext
5. useContext useContext
6. useContext useState
Page has five useContext calls, but the recorded list holds six.
Traced by logging from mountHookTypesDev / updateHookTypesDev / renderWithHooks / finishRenderingHooks:
HooksDispatcherOnMountInDEV pushes A, B, C, then use(userPromise) throws. hookTypesDev has 3 entries.renderWithHooksAgain resets hookTypesUpdateIndexDev to -1 but leaves hookTypesDev as it is, and installs the rerender dispatcher. A, B, C verify. use(userPromise) resolves, and useThenable switches the dispatcher back to HooksDispatcherOnMountInDEV because the work-in-progress hook cursor has no next hook. D is then pushed (4 entries). use(companyPromise) throws.A, B, C verify again. use(userPromise) resolves; the cursor is still empty, because useContext is not a stateful hook and never advanced it — so useThenable switches to the mount dispatcher again and D is pushed a second time (5 entries). The rest of the component then mounts on top._debugHookTypes is one entry too long. The next ordinary render compares its real hooks against it and warns at the first useState.The root cause is that mountHookTypesDev appends to hookTypesDev without truncating it to hookTypesUpdateIndexDev + 1 — the number of entries actually re-verified in this pass. On a single suspend the leftover happens to end exactly where the mount resumes, so nothing is duplicated and the bug is invisible.
| shape | warns |
|---|---|
two use(), different pending promises, useContext between |
yes |
a single use() |
no |
two use() on the same promise |
no |
two use() with a useState between them |
no |
two use() back to back, nothing between |
no |
The useState row is the tell: a stateful hook advances the cursor, so useThenable does not switch dispatchers and nothing is re-pushed.
_debugHookTypes only feeds this warning and DevTools' hook inspection, so there is no runtime impact. But the warning tells people their code "will lead to bugs and errors", and the natural response is to go looking for a rules-of-hooks violation that is not there.
It also degrades the real diagnostic: warnOnHookMismatchInDev is guarded by didWarnAboutMismatchedHooksForComponent, which fires once per component name. A false positive consumes that component's only warning, so a genuine hook-order violation in the same component afterwards is silent.
This shape is easy to hit in application code — any two data hooks that each read a context and then use() their own promise, called one after the other. Related report against a data library: https://github.com/vercel/swr/issues/2849
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.