On This Page
Invalid hook call. Hooks can only be called inside of the body of a function component.
This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.If that's sitting in your console right now, and your hooks genuinely look fine, skip to "Options, best first." If you want to know why React prints a warning and then crashes with something completely different one line later, keep reading, because that's the part almost nobody explains correctly.
The console shows two errors, not one
Open dev tools right after this fires and you won't see one error. You'll see two, stacked, and they don't look related at first glance.
First, the console.error above, in full. Then, immediately under it, an uncaught exception:
Uncaught TypeError: Cannot read properties of null (reading 'useState')
at useState (react.development.js:1622:21)
at MyComponent (MyComponent.tsx:8:22)
at renderWithHooks (react-dom-client.development.js:5560:22)
at updateFunctionComponent (react-dom-client.development.js:8897:19)Firefox phrases the same crash differently. Same root cause, different wording, because it's V8 versus SpiderMonkey describing "you tried to read a property off null," not React choosing different words:
Uncaught TypeError: can't access property "useState", dispatcher is null
useState react.development.js:1622
MyComponent MyComponent.tsx:8I didn't check Safari for this one. If you're on WebKit, expect JSC's own null-property phrasing, not the two above, but the same underlying story.
Under both stacks React also prints a component stack (The above error occurred in the <MyComponent> component:), and on React 19.1 and later an owner stack too, naming whichever component actually rendered <MyComponent>. That second stack matters more than people give it credit for — more on that later.
Here's the detail that trips people up: the friendly three-reason message is dev-only. It lives behind a console.error call in react.development.js. The production bundle strips that check out entirely, same as every other dev-only invariant in React. So in production you get none of the three-reasons text — just the bare Cannot read properties of null. (If the crash happens somewhere React swallows into a minified rethrow, you'll see Minified React error #321 instead, which decodes to the same invalid-hook-call warning text on react.dev/errors/321.) If your only signal is a Sentry ticket with a minified stack, that's why it looks so much scarier than the dev version.
Four repros, four causes
Four different mistakes produce the exact same console output above. Here's a minimal repro for each, on React 19.3.0.
1. A hook after an early return (Rules of Hooks violation).
tsx
// Broken.tsx
import { useState } from 'react';
export function Broken({ skip }: { skip: boolean }) {
if (skip) {
return null;
}
const [count, setCount] = useState(0); // after a conditional early return
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}This one doesn't always throw the null-dispatcher crash. It depends on hook order shifting between renders, which is a separate warning ("Rendered more hooks than during the previous render"). But calling a hook from inside a branch that sometimes doesn't run, when it's the first hook call React ever sees for that fiber, reliably produces the invalid-hook-call warning too, because there was no dispatcher assigned when the call landed.
2. Calling the component as a function instead of rendering it.
tsx
// index.tsx
import { createRoot } from 'react-dom/client';
import { Broken } from './Broken';
createRoot(document.getElementById('root')!).render(
Broken({ skip: false }) // called directly, not rendered as JSX (this is the bug)
);This is the cause react.dev doesn't list, and it's the one that took me the longest to place the first time I saw it, because the offending line is nowhere near the component that appears in the stack trace.
3. Two copies of React, via npm link.
bash
mkdir repro && cd repro
npm create vite@latest app -- --template react-ts
npm create vite@latest ui-lib -- --template react-ts
cd ui-lib && npm install react@19.3.0 react-dom@19.3.0
npm link
cd ../app && npm link ui-libjson
// app/package.json (relevant slice)
{
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"ui-lib": "file:../ui-lib"
}
}bash
npm ls react
# app@0.0.0 /repro/app
# ├── react@19.3.0
# └─┬ ui-lib@0.0.0 -> ../ui-lib
# └── react@19.3.0 deduped <- looks fine hereThat deduped line is a lie in the specific case where ui-lib was linked rather than installed as a normal dependency, because npm link makes ui-lib's own node_modules/react resolve first for anything required from inside ui-lib's own directory, regardless of what npm ls reports for the app. Any hook ui-lib calls resolves against its copy of react's internals object, not the app's.
4. Mismatched react and react-dom versions.
json
{
"dependencies": {
"react": "19.3.0",
"react-dom": "18.3.1"
}
}react-dom@18.3.1 writes hook state onto its own dispatcher object under its own copy of ReactSharedInternals. react@19.3.0's useState reads from a differently-versioned internals export. They're not the same object, so from react's point of view, nothing ever set .H.
Does this look different on React 19?
| Version range | What changed | Still throws this way? |
|---|---|---|
| 16.8 – 17.x | Hooks shipped. Same three-reason console.error text. | Yes |
| 18.0 – 18.3 | createRoot and automatic batching land; internals still exported as __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED. Libraries that reached into that object (headlessui, some early Relay builds) worked as long as only one copy of React existed. | Yes |
| 19.0 | Internals export renamed to __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE. ReactDOM.render, string refs, and propTypes removed. Any library still importing the old __SECRET_INTERNALS... name from a peer copy of React 19 gets its own flavor of "cannot read properties of undefined" on top of whatever hook errors were already happening. | Yes, mechanism unchanged |
| 19.1 | Owner stacks added to the dev overlay, so the crash now names both the failing component and whoever rendered it, which makes it much faster to spot a duplicate-copy problem, since the owner stack often lives in a different node_modules path than you'd expect. | Yes |
| 19.2 – 19.3 | No change to this warning's text or mechanism. useEffectEvent, <Activity>, and cacheSignal shipped in 19.2 but don't touch dispatcher resolution. | Yes |
Framework note, one line: Next.js 15 and 16 both pin react and react-dom as matched peer dependencies, so cause 4 (version mismatch) mostly shows up in custom setups, monorepos, or after a partial npm update, not in a fresh create-next-app.
If you're on a Canary or Experimental React build chasing a hook error, treat it as unsupported for this write-up; the dispatcher machinery there can change release to release.
So what's actually null?
The npm-link repro above is close to how I actually lost a day on this. I was pulling a shared Button component out of one app into its own package so a second app could use it, wired it up with npm link because publishing felt like overkill for a two-day spike, and the button worked perfectly in its own Storybook. Dropped into the host app, clicking it threw the exact stack from section one. Nothing about the component had changed.
The short version: dispatcher is null because whoever is executing useState right now is not, as far as React's currently-rendering copy is concerned, inside a render. That's true literally (you called a hook from an event handler, outside any component) and it's true in the sneaky way too: you are inside a render, but the useState you called belongs to a react module instance whose ReactSharedInternals.H was never written, because a completely different instance of the react-dom package is the one actually doing the rendering and setting that field.
Inside resolveDispatcher
Every hook exported by react is a thin wrapper. Here's the actual useState export from react@19.3.0's development build:
js
exports.useState = function (initialState) {
return resolveDispatcher().useState(initialState);
};And resolveDispatcher:
js
function resolveDispatcher() {
var dispatcher = ReactSharedInternals.H;
null === dispatcher &&
console.error(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.'
);
return dispatcher;
}Read that closely and the two-errors-not-one behavior from section one stops being mysterious. resolveDispatcher doesn't throw. It logs a warning and then, on the very next line, hands back null anyway. The crash happens one statement later, back in useState, when the wrapper calls .useState on whatever resolveDispatcher() returned. If that's null, JavaScript itself throws the property-access error, not React. React's own contribution to the crash is exactly one console.error call and nothing else.
That was the tell, once I actually read the source instead of guessing: the warning and the crash are two separate statements a few lines apart in React's own code, and both blame the same call site, but only one of them explains anything.
ReactSharedInternals (react@19.3.0, unminified) looks like this:
js
var ReactSharedInternals = {
H: null, // hooks dispatcher
A: null, // async/cache dispatcher
T: null, // current transition
S: null, // transition status listener
actQueue: null,
asyncTransitions: 0,
// ...
};react-dom sets .H to a real dispatcher object at the start of rendering a fiber (inside renderWithHooks) and clears it back to null once that fiber finishes. That object is a module-level singleton — one per loaded copy of the react package, not one per app. Load react twice (two node_modules/react directories, two entries in the module graph, doesn't matter how it happened) and you get two ReactSharedInternals objects. react-dom writes .H on the copy it imported. Your component, if it imported the other copy, reads .H off an object nobody ever touched. That's why it's null, every time, no matter how correctly you wrote the hook.
This is also exactly why the internals rename in React 19.0, from __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED to __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE, broke a handful of libraries (headlessui and Relay both had open issues about it) independently of any hook-call bug: those packages were reading the internals object by its old export name, got undefined back on React 19, and threw a different error one property access earlier in the same chain.
The wrong turns
First theory: version mismatch. It's reason #1 in React's own list, so I checked app/package.json and ui-lib/package.json side by side. Both pinned react@19.3.0 and react-dom@19.3.0, exact match, no carets. Ruled out in about two minutes — and wrongly, because matching declared versions in two package.json files says nothing about which physical copy on disk actually gets required at runtime, which is exactly what npm link scrambles.
Second theory: stale Vite cache. The crash appeared right after I'd been editing vite.config.ts for something unrelated, so I assumed a stale pre-bundle. Killed the dev server, deleted node_modules/.vite, restarted with --force. Same crash, same line. Dead end, and it cost me the better part of an hour before I noticed the timing was a coincidence, not a cause.
The path that actually worked, in order:
bash
# 1. Are there really two copies, physically, not just in package.json?
npm ls reactLook for deduped next to any entry that came in through a file: or linked dependency. Don't trust it. deduped means npm's resolver intends one copy; it doesn't guarantee every require('react') call from every directory in the tree resolves there.
bash
# 2. Where does resolution actually point, physically?
node -e "console.log(require.resolve('react'))"
# then, from inside the linked package's own directory:
cd ui-lib && node -e "console.log(require.resolve('react'))"Two different absolute paths printed here is the real tell, more reliable than anything npm ls prints. In my case: app/node_modules/react/index.js from the app, ui-lib/node_modules/react/index.js from inside the linked package. Two files, two module instances, two ReactSharedInternals objects.
bash
# 3. Confirm it's not a version drift issue instead
cat app/node_modules/react/package.json | grep version
cat ui-lib/node_modules/react/package.json | grep versionSame version number here doesn't help you; even two identical copies of react@19.3.0 on disk are still two separate module instances to Node's or your bundler's resolver, and cause the exact same crash. This step just tells you whether you're also fighting cause 4 on top of cause 3.
Ranked by how often each one is actually the culprit, in my experience and in the GitHub issues I could find real detail on: duplicate copies from a monorepo, workspace hoisting, or npm link first by a wide margin; version mismatch between react and react-dom second; a genuine Rules of Hooks violation third (these usually show a different, more specific warning before they ever reach this one); direct invocation of a component as a plain function last, rare, but the one that eats the most debugging time relative to how simple the fix is, because nobody thinks to check their own entry point.
Options, best first
If it's a linked local package: stop using npm link for anything you'll touch more than once. Use your package manager's workspace protocol instead, which resolves through a single node_modules/react at the workspace root instead of two independent trees:
json
// pnpm-workspace.yaml
packages:
- "app"
- "packages/*"json
// app/package.json
{
"dependencies": {
"ui-lib": "workspace:*"
}
}If you're stuck with a real duplicate and can't restructure right now, force the bundler to collapse to one copy. Vite:
ts
// vite.config.ts
export default {
resolve: {
dedupe: ['react', 'react-dom'],
},
};Webpack:
js
// webpack.config.js
export default {
resolve: {
alias: {
react: new URL('./node_modules/react', import.meta.url).pathname,
'react-dom': new URL('./node_modules/react-dom', import.meta.url).pathname,
},
},
};Package-manager level, works even outside a bundler (Node scripts, SSR, tests):
json
// npm 8.3+, package.json
{
"overrides": {
"react": "19.3.0",
"react-dom": "19.3.0"
}
}json
// pnpm, package.json
{
"pnpm": {
"overrides": {
"react": "19.3.0"
}
}
}json
// yarn
{
"resolutions": {
"react": "19.3.0"
}
}If it's a version mismatch, pin both packages to the same exact version, not just compatible ranges — "react": "19.3.0", not "^19.3.0" sitting next to "react-dom": "^19.2.0". A caret on only one of the pair is how this creeps back in after your next npm install.
If it's a Rules of Hooks violation, move the hook above the early return and compute the conditional logic inside the hook instead of around it:
tsx
export function Fixed({ skip }: { skip: boolean }) {
const [count, setCount] = useState(0);
if (skip) return null;
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}If it's direct invocation, use JSX or createElement, not a bare function call:
tsx
createRoot(document.getElementById('root')!).render(<Broken skip={false} />);What I'd actually ship: workspace protocol over npm link, always, and overrides pinning a single React version as a permanent line in every app's package.json rather than something I delete once the bug's gone. I don't love the overrides approach on its own, since it papers over a dependency tree that's still capable of drifting. Paired with a CI check (section nine), though, it's honest enough to leave in place indefinitely.
Writing it so it can't happen
The actual fix for cause 3 isn't a config flag, it's not publishing shared UI code as a linked filesystem package in the first place. Declare react and react-dom as peerDependencies on any package meant to be consumed by an app, never as a regular dependency:
json
// ui-lib/package.json
{
"name": "ui-lib",
"peerDependencies": {
"react": ">=19.0.0",
"react-dom": ">=19.0.0"
},
"peerDependenciesMeta": {
"react-dom": { "optional": true }
},
"devDependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
}
}peerDependencies tells the package manager "the consumer supplies this, don't install your own copy," which is the actual, structural fix. devDependencies keeps the package buildable and testable on its own without ever letting that copy ship or get resolved by a consuming app. This is the difference between a bug you fix once and a bug that can't exist in the dependency graph to begin with.
For the Rules of Hooks class of cause, the fix that scales isn't discipline, it's the compiler-backed lint rule catching it before the code runs at all (next section). And for cause 2, direct invocation, there's no lint rule that reliably catches "someone called a component like a function." TypeScript will happily let you call any function that returns ReactNode. The only real guard is convention plus code review: components get rendered, never called.
Making CI catch it for you
eslint-plugin-react-hooks is at 7.1.1 as of this writing. The flat-config setup, straight from the package's own README:
js
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
export default defineConfig([
reactHooks.configs.flat.recommended,
]);recommended covers the stable rules, including rules-of-hooks (catches cause 1 in this article at lint time, before you ever run the app) and exhaustive-deps. recommended-latest opts into the newer, compiler-powered rules: purity, set-state-in-render, immutability, static-components, among others — which catch a wider class of Rules-of-React violations than the classic two rules ever could, at the cost of being newer and stricter.
For the duplicate-copy class specifically, lint can't see it. It's a resolution-time problem, not a syntax problem, so put a real check in CI instead:
bash
# fails the build if more than one react is resolved anywhere in the tree
npm ls react --depth=Infinity --json | node -e "
const data = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const versions = new Set();
(function walk(deps) {
if (!deps) return;
for (const [name, info] of Object.entries(deps)) {
if (name === 'react' && info.version) versions.add(info.version);
walk(info.dependencies);
}
})(data.dependencies);
if (versions.size > 1) {
console.error('Multiple react versions resolved:', [...versions]);
process.exit(1);
}
"pnpm users get most of this for free from pnpm why react, and pnpm's default strict node_modules layout makes silent duplicates rarer than npm's hoisting does — it's part of why the pnpm project has its own open issue thread about exactly this class of bug from users who fight it anyway via public-hoist-pattern.
Round it out with: a committed lockfile and npm ci (or pnpm install --frozen-lockfile) in CI, so nobody's local npm install quietly resolves a second copy that the lockfile doesn't reflect; Renovate or Dependabot configured to bump react and react-dom together in one PR, never independently; and, if you're watching production, an error-tracking rule that groups on the message text Cannot read properties of null (reading ' so a spike in this specific shape of crash pages someone instead of blending into general noise.
If you remember nothing else
Four things carry to any future React version, not just 19.3.
The three-reason message is honest but incomplete — calling a component as a plain function produces the identical crash and isn't on React's own list.
npm ls react reporting deduped is not proof of a single module instance; require.resolve('react') run from inside the suspect package is proof.
The crash you see isn't React's error. It's a console.error warning followed one line later by a completely unrelated TypeError that JavaScript itself throws against a null dispatcher, and only the first one explains anything.
Structural fixes beat local fixes: peerDependencies on shared packages and workspace protocol instead of npm link prevent this bug from existing, where resolve.dedupe and overrides only clean up after it.
Related: this same duplicate-module mechanism is most of what's behind peer-dependency fights in monorepos generally, so if you're chasing a different "two copies of X" bug outside of React hooks specifically, the diagnostic path in "The wrong turns" above transfers directly.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.