Debugging

Cannot read properties of null (reading 'useMemo')

The two-React-instances bug

Your app worked a minute ago. You added a component, saved, and now every hook throws. The stack trace points at React internals and none of the usual advice works. Here is what is actually happening, how to confirm it in thirty seconds, and the one configuration change that removes the whole failure class.

Author
By Fabricate Team
Last updated
Updated July 26, 2026
Reading time
17 min read
Key takeaways
  • The error is a null hooks dispatcher. In a Vite dev server the usual cause is two React instances loaded from two different dependency-optimizer generations -- not two copies of React on disk.
  • Confirm it in the network panel: react and react-dom served with two different ?v= hashes in a single page load. That query parameter is the optimizer generation, and two values means two module graphs are live at once.
  • resolve.dedupe and resolve.alias pin one React on disk. They cannot merge two optimizer generations the browser has already loaded, which is why they do not fix this.
  • The fix is to declare every client dependency in optimizeDeps.include so the optimizer runs once and has nothing left to discover mid-session.
  • Derive that list from package.json rather than hardcoding it, but filter it: being listed in dependencies does not mean a package is importable browser JavaScript, and an unresolvable include entry aborts dev server startup.
  • vite build does not use the dependency optimizer at all, so a green CI pipeline proves nothing here. You have to boot the dev server to catch it.
  • Intermittent and 'it worked a second ago' points at the optimizer. Deterministic, and also broken in the production build, points at disk duplication or a rules-of-hooks violation instead.
Table of Contents

The fix, first

If a Vite dev server is throwing `Cannot read properties of null (reading 'useMemo')` and the app was working moments earlier, the most likely cause is that the browser has two copies of React loaded from two different runs of Vite's dependency optimizer. The fix is to declare every client dependency in `optimizeDeps.include` in your `vite.config.ts`, so the optimizer runs once at startup and has nothing left to discover later.

The hook name in the message is not meaningful. You will see `useMemo`, `useState`, `useRef`, `useEffect` or `useContext` depending on which hook happens to run first in the component that broke. They are all the same failure. Searching for the specific hook name is the single biggest time sink on this bug, because it sends you toward memoization advice that has nothing to do with the cause.

Apply the change below, then restart the dev server once with `--force` to discard the stale prebundle cache. If the error was intermittent and correlated with adding new imports, it should not come back.

The minimum viable fix in vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    // List EVERY package the browser bundle imports, not just React.
    // Anything omitted gets discovered lazily, and a late discovery
    // triggers a second optimizer run with a new browserHash.
    include: [
      'react',
      'react-dom',
      'react-dom/client',      // subpaths are optimized as separate entries
      'react-router-dom',
      'zod',
      'clsx',
      'tailwind-merge',
      'sonner',
      'lucide-react',
      'react-hook-form',
      '@hookform/resolvers/zod',
      // ...and every @radix-ui/* package you actually import
    ],
    // Prevents Vite shipping a partial dep bundle during the initial
    // crawl. Useful, but it only covers startup -- it does nothing
    // about a discovery that happens ten minutes into a session.
    holdUntilCrawlEnd: true,
  },
});

// Then, once:  rm -rf node_modules/.vite && npm run dev -- --force
The list has to be complete, not representative. One omitted package that is imported by a lazily loaded route is enough to re-trigger the whole failure. The next sections cover how to generate this list automatically instead of maintaining it by hand.

What the error actually means

React hooks are not stored on your component. When you call `useMemo`, React reads a shared internal dispatcher object and calls the implementation hanging off it. The renderer -- `react-dom` -- installs that dispatcher immediately before rendering a component tree and tears it down afterwards. When the error says it cannot read `useMemo` of null, it means the dispatcher slot was empty at the moment your component called a hook.

There are only three ways to get an empty dispatcher. First, a hook was called outside of rendering -- in an event handler, a callback, a plain function, or conditionally. Second, `react` and `react-dom` are at incompatible versions. Third, there are two separate React instances in memory: `react-dom` installed the dispatcher on its copy of React, and your component imported the other copy, which still has an empty slot.

The third case is the one with the distinctive signature: it is intermittent, it appears after a change that had nothing to do with hooks, and a hard refresh often makes it disappear until the next time. If your error behaves that way, you are debugging module identity, not React.

Note

A useful sanity check before you go further: does the error survive a hard refresh with an empty cache? If a clean reload consistently fixes it and it comes back later in the session, that is the optimizer. If it reproduces immediately on every fresh load, look at the disk-level causes further down this page.

Confirm it in thirty seconds

Open the browser network panel, filter for `react`, and look at the query strings. Vite serves prebundled dependencies from `/node_modules/.vite/deps/` with a `?v=` parameter. That parameter is the optimizer's `browserHash` -- the generation stamp of the prebundle run that produced the chunk. Every chunk from a single optimizer run carries the same value.

Two different `?v=` values on two React-related chunks is your confirmation. Two hashes means two generations, two generations means two module graphs, and two module graphs containing React means two React instances. The production trace behind this post held three at once in a single stack: the live `browserHash` was `899bb37f`, while a Radix alert-dialog chunk at `f1f58afe` and the `react-dom` reconciler at `0a88b0a2` were both stale survivors of earlier optimizer runs.

You can also read it straight from the console without hunting through the network panel, and you can cross-check against the optimizer's own metadata file on disk. Both are shown below.

Two ways to confirm two optimizer generations
// 1. Paste into the browser console on the broken page.
//    Any output with more than one distinct ?v= value confirms it.
const deps = performance
  .getEntriesByType('resource')
  .map((e) => e.name)
  .filter((n) => n.includes('/node_modules/.vite/deps/'));

console.table(deps.map((n) => n.split('/deps/')[1]));
console.log('generations:', new Set(deps.map((n) => n.split('?v=')[1])));

// Healthy output: generations = Set(1) { '899bb37f' }
// Broken output:  generations = Set(2) { '899bb37f', '0a88b0a2' }

// 2. On disk, the optimizer records what it produced.
//    cat node_modules/.vite/deps/_metadata.json
//    -> { "hash": "...", "browserHash": "899bb37f", "optimized": { ... } }
//    Compare browserHash against what the page actually loaded.
The `_metadata.json` check is the one to use when you want a regression test rather than a diagnosis. Record `browserHash`, perform the action that used to break things, and read it again. If it changed, the optimizer re-ran and your include list is incomplete.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Why dedupe, alias and deleting node_modules do not help

Almost every answer written about duplicate React assumes the duplication is on disk -- a nested `node_modules/some-lib/node_modules/react`, a linked local package, or a monorepo hoisting failure. The standard remedies target exactly that, and they are correct for that problem. They are simply aimed at a different layer than the one failing here.

`resolve.dedupe` and `resolve.alias` operate during module resolution: they guarantee that every import of `react` resolves to the same directory. Vite's dependency optimizer runs after resolution. It takes the resolved package and prebundles it into `node_modules/.vite/deps`, stamped with a generation hash. Two prebundles of the same on-disk React are still two distinct modules as far as the browser is concerned. Deduplicating the disk cannot merge two artifacts the browser already fetched under different URLs.

This is worth internalising because it explains the most demoralising part of the bug: you can have a perfectly deduplicated dependency tree, `npm ls react` returning exactly one entry, an explicit alias pointing at an absolute path, and still be looking at two React instances in the browser.

Common adviceWhat it actually fixesFixes this bug?
resolve.dedupe: [react, react-dom]Multiple React directories on diskNo
resolve.alias react to an absolute pathResolution picking the wrong copyNo
rm -rf node_modules && reinstallCorrupt or hoisted install treeNo
npm dedupe / pnpm overridesTransitive version duplicationNo
Restart dev server with --forceDiscards the stale prebundle cacheTemporarily -- it returns on the next late discovery
optimizeDeps.holdUntilCrawlEndPartial dep bundles during the initial crawlPartially -- startup only
Complete optimizeDeps.includeOur PickLeaves nothing for the optimizer to discover lateYes

Each row is correct advice for some duplicate-React problem. Only the last one addresses two optimizer generations.

Why Vite re-optimizes in the middle of a session

Vite's dependency optimizer does not know your dependency list. At startup it crawls from your entry HTML through the import graph, collects the bare-specifier imports it can reach, and prebundles those with esbuild into a single generation. Anything it could not reach during that crawl is discovered later -- at the moment a module that imports it is first requested.

A late discovery forces a new optimizer run. The new run gets a new `browserHash`, and Vite signals the page to reload so it picks up the new URLs. When that reload is clean, you never notice. When it is not clean -- an HMR boundary swallowed the update, a module was already evaluated, a route chunk was fetched before the reload landed -- the page ends up holding modules from both generations. If `react` came from one and the `react-dom` reconciler from the other, every hook in the affected subtree throws.

The triggers are ordinary: a route behind `React.lazy` that you visit for the first time, a component that only mounts after login, a charting library on a dashboard tab nobody clicked yet, a modal that pulls in a dialog primitive. Anything that puts an import behind a condition the initial crawl did not satisfy is a candidate.

Why AI-generated apps hit this harder

Code-generating agents make late discovery close to inevitable, because they write files in several passes while the preview is already open. A component written in a later pass imports a package that nothing referenced when the dev server first crawled, which re-optimizes a page the user is actively looking at. That is the worst possible timing: the page is live, has state, and will not necessarily do the clean reload the optimizer is counting on.

Fabricate's pipeline works exactly this way. `worker/agents/execution/phasicStateMachine.ts` plans a milestone, implements it, deploys, validates and repeats, so imports appear across phases rather than all at once. It is the same mechanism you would hit yourself by adding a dependency at 4pm to an app you started at 10am -- just compressed into a few minutes and repeated on every build.

What holdUntilCrawlEnd does and does not do

`optimizeDeps.holdUntilCrawlEnd: true` tells Vite not to serve a dependency bundle until the initial crawl has finished, which prevents a specific startup race where the first page load gets a partial bundle and then immediately needs a second one. It is worth setting and it genuinely removes a class of startup churn.

It does nothing for the case in this post. The crawl it waits for is the initial crawl. A dependency discovered ten minutes later, when the user navigates to a lazily loaded route, is outside its scope entirely. Setting it and considering the problem solved is a common and understandable mistake.

Deriving the include list without breaking startup

Maintaining `optimizeDeps.include` by hand does not survive contact with a real project. The application templates Fabricate generates from carry between sixty and seventy client dependencies each -- twenty-seven of them Radix packages in the full-stack template alone -- and every new package is one more chance to forget an entry and reopen the bug. The durable version is to derive the list from `package.json` at config-evaluation time.

There is a serious trap here, and it is the reason a naive derivation is worse than no derivation. Vite hard-fails at startup on an `include` entry it cannot resolve, with `Failed to resolve entry for package X`. A bad entry is not a slow path or a warning -- it takes the whole dev server down. So every candidate has to be checked against its installed manifest before it goes in the list.

Three filters matter, and each one corresponds to a real package that passes a naive check. First, non-JavaScript entries: `tw-animate-css` declares `main: "./dist/tw-animate.css"` and an exports map whose only condition is `style`, so a check for "does it declare an entry?" passes while the entry is a stylesheet. Second, command-line tooling: filter on the presence of a `bin` field rather than a name list, because `bin` is a property of the package. It catches `wrangler`, `eslint`, `typescript`, `vite`, `tailwindcss`, `autoprefixer` and `pino`, while `react`, Radix, `zod`, `sonner`, `clsx` and `tailwind-merge` ship no `bin` at all. A hardcoded name list needs extending forever; the `bin` check does not. Third, declared but absent: a manifest can promise an entry the published tarball never shipped, so the resolved target has to be verified on disk before it is trusted.

Two more details are easy to miss. Vite optimizes a subpath as its own entry, so importing the package root does not cover `react-dom/client` or `@hookform/resolvers/zod` -- they need explicit listing. And server-only packages must be held out: prebundling `hono`, `drizzle-orm`, `stripe`, `jose` or `bcryptjs` for a browser target is wrong at best and a hard esbuild failure at worst.

Deriving optimizeDeps.include from package.json, with the filters that matter
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';

const SERVER_ONLY = new Set(['hono', 'drizzle-orm', 'stripe', 'jose', 'bcryptjs']);
const NON_JS_ENTRY = /\.(?:css|s[ac]ss|json|wasm|svg|png|jpe?g|txt|md)$/i;

// Vite optimizes a subpath as its own entry, so the root is not enough.
const EXTRA_ENTRYPOINTS: Record<string, string[]> = {
  'react-dom': ['react-dom/client'],
  '@hookform/resolvers': ['@hookform/resolvers/zod'],
};

function manifestOf(dep: string): Record<string, unknown> | null {
  try {
    const p = path.resolve(__dirname, 'node_modules', dep, 'package.json');
    return JSON.parse(readFileSync(p, 'utf8'));
  } catch {
    return null;
  }
}

// A package that installs an executable is a CLI, not browser code.
// This is a property of the package, so it never needs updating.
function isCommandLineTooling(dep: string): boolean {
  return manifestOf(dep)?.bin !== undefined;
}

// A manifest can promise an entry the tarball never shipped, and it can
// point at a stylesheet. Require JavaScript that is actually on disk.
function hasResolvableJsEntry(dep: string): boolean {
  const manifest = manifestOf(dep);
  if (!manifest) return false;
  const dir = path.resolve(__dirname, 'node_modules', dep);
  const targets = collectEntryTargets(manifest); // walks exports / module / main
  return targets.some(
    (t) => !NON_JS_ENTRY.test(t) && existsSync(path.resolve(dir, t)),
  );
}

function clientDependencyEntrypoints(): string[] {
  try {
    const pkg = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf8'));
    const out: string[] = [];
    for (const dep of Object.keys(pkg.dependencies ?? {})) {
      if (SERVER_ONLY.has(dep) || dep.startsWith('@cloudflare/')) continue;
      if (isCommandLineTooling(dep)) continue;
      if (!hasResolvableJsEntry(dep)) continue;
      out.push(dep, ...(EXTRA_ENTRYPOINTS[dep] ?? []));
    }
    return [...new Set(out)].sort();
  } catch {
    // Never block startup on this. Fall back to a known-good base list.
    return ['react', 'react-dom', 'react-dom/client', 'react-router-dom'];
  }
}
The `try`/`catch` around the whole derivation is not defensive padding. If reading or parsing `package.json` throws, an uncaught error here takes down the dev server for a reason that has nothing to do with the app. Falling back to a small known-good list degrades to the old behaviour instead of to nothing.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Your CI pipeline cannot catch this

`vite build` does not use the dependency optimizer. The production build hands the whole graph to Rollup, which resolves and bundles every dependency itself. `optimizeDeps` is a dev-server concept and the build path never reads it in a way that would surface a broken configuration.

The practical consequence is severe: you can have a completely broken `optimizeDeps.include`, and typecheck passes, lint passes, the build passes, unit tests pass, and CI goes green. Then the dev server fails to start, or starts and serves an app that dies on the third interaction. Every signal you normally trust is silent on this specific failure.

We learned this the expensive way. A first attempt at the derivation above filtered only server packages and broke the dev server on seven of eight application templates. It had been verified by simulating the derivation function and parsing the resulting config with esbuild -- neither of which executes `optimizeDeps`. The regression was caught only when someone actually booted a template and loaded a page. The rule that came out of it: verify a configuration change by running the thing it configures.

Important

Do not treat a passing production build as evidence that the dev server works. They exercise different code paths for dependency handling, and this bug lives exclusively in the path CI does not run.

A check that actually proves something

Boot the dev server, load the page, confirm HTTP 200 and a clean console. That is the floor, not the test.

The real test is the late-import test. Add a throwaway component that imports a package no other file in the project imports -- a Radix primitive you have not used, `sonner`, a form resolver -- and mount it. Then read `node_modules/.vite/deps/_metadata.json` and compare `browserHash` against what it was before. Unchanged means the optimizer did not re-run, which means the package was already prebundled, which means your include list is complete. Changed means you have found a gap, and the bug is still live.

Clearing the cache correctly

Once you have fixed the configuration, the stale prebundle cache still has to go, and there is a wrong way to remove it that produces a second, confusingly different failure.

Deleting `node_modules/.vite` on its own leaves already-transformed modules holding references to chunk hashes that no longer exist on disk. The result is not a clean rebuild -- it is a wave of HTTP 504s as every framework dependency request resolves to a deleted hash. The deletion has to be paired with something that tells Vite its dependency state changed, so it performs a fresh optimize rather than serving from a half-invalidated index.

Touching `package.json` after the delete is the reliable pairing, and it is deliberately not `vite.config.ts`: writing to the config file triggers a full server restart, which in a Cloudflare Workers setup surfaces as an `Expected miniflare to be defined` error from `@cloudflare/vite-plugin`. That specific ordering is encoded in Fabricate's deploy path in `worker/agents/services/implementations/DeploymentManager.ts`, where the cache is only cleared when the dependency manifest or the Vite config actually changed, precisely to avoid paying for a full re-bundle on every deploy.

Cache clear that does not produce a 504 storm
# Wrong: leaves transformed modules pointing at deleted hashes.
rm -rf node_modules/.vite

# Right: delete, then signal Vite that dependency state changed.
rm -rf node_modules/.vite && if [ -f package.json ]; then touch package.json; fi

# Equivalent for a local session -- --force re-optimizes on startup.
npm run dev -- --force

# Note: do NOT write to vite.config.ts to trigger this. Writing the
# config restarts the server, which in a Cloudflare Workers setup
# surfaces as "Expected miniflare to be defined" from the Vite plugin.
Only clear the cache when the dependency manifest or the Vite config has genuinely changed. Doing it on every deploy is a large, avoidable cost -- a full re-bundle of sixty-plus dependencies on each build.

When it is not the optimizer

The same error message has several other causes, and picking the wrong one wastes hours. The discriminator is reproducibility: optimizer duplication is intermittent and session-dependent, while everything below is deterministic.

Two React copies on disk. Real, and the case all the standard advice targets. It usually comes from a nested install, an `npm link`ed local package that bundles its own React, or a monorepo that failed to hoist. Check with `npm ls react` or `pnpm why react`. If more than one version or path appears, fix it with `resolve.dedupe`, an alias to an absolute path, or a package-manager override. The tell is that it fails consistently, including in the production build.

Mismatched `react` and `react-dom` majors. Less common now that both are versioned in lockstep, but a partial upgrade -- `react` at 19 and `react-dom` still at 18 -- produces the same null dispatcher. Check `package.json` and the lockfile, not just `package.json`.

An actual rules-of-hooks violation. A hook called inside an event handler, a conditional branch, a loop, or a plain function that is not a component. The tell is that it is perfectly deterministic, always in the same component, and the stack trace points at your own file rather than into React internals. The React hooks ESLint plugin exists to catch exactly this; if it is not installed, install it before assuming you have a bundling problem.

A second React arriving from outside the bundle. An import map, a CDN script tag left over from a prototype, or a third-party widget that ships its own React. Check the network panel for any `react` request that is not coming from `/node_modules/.vite/deps/`.

Finally, if your dev server is not Vite -- Next.js on webpack or Turbopack, Create React App, Parcel -- the optimizer described in this post does not exist in your toolchain and the cause is one of the four above. Check what your framework actually runs before ruling it out, though: Remix and React Router v7 are Vite-based, so they do have the dependency optimizer and everything on this page applies to them. The confirmation step -- look for two distinct React modules in the loaded resource list -- applies either way.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

How Fabricate handles it

Fabricate generates React and TypeScript apps on Vite -- React 18 with TailwindCSS 3 in the current templates -- deployed to Cloudflare Workers with D1, KV or Durable Objects depending on the template. That puts it directly in the blast radius of this bug, and the phased generation pipeline means new imports appear while a preview is already open, which is the worst-case trigger.

The mitigation is entirely in the template configuration. The shared Vite config derives `optimizeDeps.include` from `package.json` using the filters described above, holds server-only packages out, adds the subpath entries the optimizer treats separately, sets `holdUntilCrawlEnd`, and falls back to a small known-good list if the derivation throws. It also keeps `resolve.dedupe` and the React aliases, because those still guard the genuinely different disk-duplication failure.

The honest limitation is that the pre-deploy safety gate cannot help here. `worker/agents/validators/preDeploySafetyGate.ts` is a 3,872-line AST-based validator with a named-rule catalog covering things like `missing_import_target`, `syntax_parse_failure`, `unstable_store_selector` and `effect_missing_dependency_array` -- but it inspects generated source files. Two optimizer generations is not a defect in any source file. There is nothing in the code for a static analyser to flag, which is exactly why the answer had to be configuration prevention rather than detection.

If you are building on your own Vite setup rather than on Fabricate, the takeaway is the same: derive the include list, filter it properly, and add the late-import check to whatever you run before shipping. The bug does not degrade gracefully and it does not announce itself in CI, so prevention is the only reliable position.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Frequently Asked Questions

Why does the error say useMemo specifically?
It does not mean anything. React reads hooks from a shared dispatcher object, so whichever hook runs first in the broken component is the one named in the message. The same underlying failure produces 'reading useState', 'reading useRef', 'reading useEffect' and 'reading useContext' depending on the component. Do not search for the specific hook name -- it will send you toward memoization advice that is unrelated to the cause.
Does resolve.dedupe fix two React instances?
Only when the duplication is on disk. dedupe and alias operate during module resolution and guarantee that every import of react resolves to the same directory. Vite's dependency optimizer runs after resolution, and two prebundles of the same on-disk React are still two distinct modules to the browser. You can have a perfectly deduplicated tree and still hit this.
Will deleting node_modules and reinstalling fix it?
Not durably. A reinstall fixes disk-level duplication, which is a different problem. It does clear the prebundle cache as a side effect, so the symptom disappears temporarily, which is exactly what makes this bug so hard to pin down -- the misleading fix appears to work until the next lazily discovered import.
Does this happen in the production build?
No. vite build hands the whole graph to Rollup, which resolves and bundles dependencies itself without using the dependency optimizer. That is why a green CI pipeline proves nothing here, and why the only way to catch a broken optimizeDeps configuration is to boot the dev server and load a page.
How do I know my optimizeDeps.include list is complete?
Record browserHash from node_modules/.vite/deps/_metadata.json, then add a component that imports a package nothing else in the project imports and mount it. Read browserHash again. If it is unchanged, the optimizer did not re-run and your list covers that package. If it changed, you have found a gap.
Is it safe to list every dependency in optimizeDeps.include?
Every client dependency, yes -- with filtering. An entry Vite cannot resolve aborts dev server startup with 'Failed to resolve entry for package X', so exclude packages with non-JavaScript entries such as CSS-only styling packages, packages that install a bin (CLI tooling like wrangler, eslint or typescript), packages whose declared entry is missing from the published tarball, and anything that only runs server-side.
Does optimizeDeps.force or --force solve this permanently?
No. It forces a fresh optimize on startup, which clears the current stale cache and makes the error go away right now. It does not stop a new late discovery from triggering a second generation later in the same session. It is the right thing to run once after fixing the include list, and the wrong thing to leave on as a workaround.
I am on Next.js or webpack. Does any of this apply?
The dependency optimizer described here is a Vite dev-server feature, so the specific cause and fix do not transfer. The diagnostic does: check the loaded resource list for more than one React module. If you find two, the cause on those toolchains is disk duplication, a react and react-dom version mismatch, or a React arriving from outside the bundle.