React 19.3: useTransition() inside the R3F tree throws "Cannot read properties of undefined (reading 'length')"

Author: cadesCreated Sep 10, 2026Updated Sep 10, 2026

Summary

@react-three/fiber 9.7.0 declares react: ">=19 <19.3", so React 19.3.0 (released 2026-09-09) cannot be installed alongside it. That upper bound turns out to be load-bearing rather than precautionary: with React 19.3 forced in, useTransition() called from inside the R3F tree throws an uncaught error.

I wanted to check whether the bound was just a conservative guard before working around it, so I forced the install and probed. It is not — there is a real incompatibility, and I think the interesting part is where it comes from.

What happens

Uncaught: Cannot read properties of undefined (reading 'length')

It arrives through onUncaughtError, so in an app it trips an error boundary rather than failing quietly.

Root cause

React 19.3 changed the shape of the transition object. 19.2 mints {_updatedFibers}; 19.3 mints {types, _updatedFibers}.

[email protected] reads that new field guarded against null, not against undefined (cjs/react-dom-client.development.js, on the path that schedules a root after a transition is started):

javascript
newEventTime = transition.types;
if (null !== newEventTime) {
  for (newEventType = firstScheduledRoot; null !== newEventType; )
    queueTransitionTypes(newEventType, newEventTime),
      (newEventType = newEventType.next);
  // …

and queueTransitionTypes immediately does:

javascript
function queueTransitionTypes(root, transitionTypes) {
  if (0 !== (root.pendingLanes & 4194048)) {
    var queued = root.transitionTypes;
    null === queued && (queued = root.transitionTypes = []);
    for (root = 0; root < transitionTypes.length; root++) {
      // ^ throws when transitionTypes is undefined

R3F bundles its own reconciler into dist/events-*.js rather than depending on the react-reconciler package, and that bundled copy is React-19.2 vintage — it mints transition objects with no types field. undefined passes the null !== … guard, and queueTransitionTypes then reads undefined.length.

So the failure needs a transition minted by R3F's reconciler to reach a react-dom 19.3 root. That happens whenever a component inside the R3F tree calls useTransition() and updates state that lives in the surrounding DOM tree — which is an ordinary thing to do (click something in the scene, update a panel outside it).

Reproduction

Standalone, no bundler. Node 24, "type": "module".

bash
mkdir r3f-19-3-repro && cd r3f-19-3-repro
npm init -y && npm pkg set type=module
npm install --legacy-peer-deps [email protected] [email protected] @react-three/[email protected] three jsdom
# --legacy-peer-deps is required: npm otherwise refuses with
#   peer react@">=19 <19.3" from @react-three/[email protected]

repro.mjs:

javascript
// Repro: useTransition() inside the R3F tree throws on React 19.3.
//
//   node repro.mjs control   -> uses React's own startTransition (baseline)
//   node repro.mjs r3f       -> uses useTransition() from inside the R3F tree
//
// Both arms update the same DOM-root state. Only the mint site of the
// transition object differs.

import { JSDOM } from "jsdom";
import { createRequire } from "node:module";
import * as three from "three";

const MODE = process.argv[2] ?? "r3f";

const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', {
    pretendToBeVisual: true,
});
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator, configurable: true });
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLCanvasElement = dom.window.HTMLCanvasElement;
globalThis.Element = dom.window.Element;
globalThis.Node = dom.window.Node;
globalThis.self = dom.window;
globalThis.MessageChannel = dom.window.MessageChannel;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

const require = createRequire(import.meta.url);
const React = require("react");
const ReactDOMClient = require("react-dom/client");
const R3F = require("@react-three/fiber");

const errors = [];

// A normal react-dom root holding the state both arms will update.
let domSetter;
function DomApp() {
    const [n, setN] = React.useState(0);
    domSetter = setN;
    return React.createElement("span", null, String(n));
}
const domRoot = ReactDOMClient.createRoot(document.getElementById("app"));
await R3F.act(async () => {
    domRoot.render(React.createElement(DomApp));
});

// A component inside the R3F tree, i.e. what a <Canvas> child is.
let r3fStart;
function CanvasChild() {
    const [, start] = React.useTransition();
    r3fStart = start;
    return null;
}
const fiberRoot = R3F.reconciler.createContainer(
    { scene: new three.Scene(), get: () => ({}), set: () => {} },
    1,
    null,
    false,
    null,
    "",
    (e) => errors.push("uncaught: " + e.message),
    (e) => errors.push("caught: " + e.message),
    (e) => errors.push("recoverable: " + e.message),
    null,
);
await R3F.act(async () => {
    R3F.reconciler.updateContainer(React.createElement(CanvasChild), fiberRoot, null, () => {});
});

// Identical for both arms: leave a pending transition lane on the DOM root.
React.startTransition(() => domSetter((v) => v + 1));

if (MODE === "control") {
    React.startTransition(() => domSetter((v) => v + 1));
} else {
    r3fStart(() => domSetter((v) => v + 1));
}

await new Promise((r) => setTimeout(r, 50));

console.log(`react ${React.version} | r3f ${require("@react-three/fiber/package.json").version} | arm: ${MODE}`);
console.log("errors:", errors.length ? errors : "none");
process.exit(errors.length ? 1 : 0);

Run both arms, then downgrade React and run them again:

bash
node repro.mjs control
node repro.mjs r3f
npm install --legacy-peer-deps [email protected] [email protected]
node repro.mjs control
node repro.mjs r3f

Results

Same harness, same R3F 9.7.0, only the installed React differs:

arm React 19.3.0 React 19.2.3
React.startTransition (control) no error no error
useTransition() inside the R3F tree uncaught: Cannot read properties of undefined (reading 'length') no error

The control arm staying clean on 19.3 is the part that rules out the harness: React's own transitions are fine under 19.3 with R3F loaded. Only transitions minted by the bundled reconciler break.

Environment

  • @react-three/fiber 9.7.0 (also reproduced on 9.5.0)
  • react / react-dom 19.3.0 vs 19.2.3
  • three 0.186.0
  • Node 24.18.1, macOS

Why I am filing rather than just waiting

The peer range does the right thing for anyone installing normally — npm refuses, and that is a clear signal. But a project that pins React through overrides or resolutions (or uses --legacy-peer-deps) installs 19.3 with no warning at all: npm ls react exits 0 and nothing surfaces the conflict. In that situation the app looks fine until someone adds a useTransition under <Canvas>, and then it is an uncaught error in the 3D view with nothing pointing back at the React version.

Two questions:

  1. Is there a plan or timeline for React 19.3 support? I could not find an existing issue or discussion for it.
  2. Is refreshing the bundled reconciler the intended fix here, or is something else planned for how R3F tracks React's internals?

Happy to test a canary against the repro above, or to open a PR if the fix is a mechanical reconciler refresh and you would like the help.

Source: pmndrs/react-three-fiber