`useLocalNodes`: inline creator rebuilds the graph every render, and any uniform registration invalidates every consumer
useLocalNodes rebuilds the graph on every render when the creator is inline — which is how every docblock example writes it
Version: @react-three/[email protected], [email protected], React 19.2.
Two separate invalidation problems in the same hook. Both measured against alpha.4 in a six-cube TSL scene (R3F-Workshop/v10-starter); numbers below are counter increments read out of the page, with StrictMode on (hence the 2× on render counts).
1. The creator is a dependency of its own memo
dist/webgpu/index.mjs:15856:
function useLocalNodes(creator) {
const store = usePrimaryStore();
const uniforms = usePrimaryThree((s) => s.uniforms);
const nodes = usePrimaryThree((s) => s.nodes);
const textures = usePrimaryThree((s) => s.textures);
const hmrVersion = usePrimaryThree((s) => s._hmrVersion);
return useMemo(() => {
const wrappedState = createLazyCreatorState(store.getState(), store);
return creator(wrappedState);
}, [store, creator, uniforms, nodes, textures, hmrVersion]);
}creator is in the dep array, so an inline arrow — a new identity every render — defeats the memo entirely.
Measured, hovering one cube five times (each hover toggles a useState, so the component re-renders):
| creator form | renders | creator runs |
|---|---|---|
useCallback(({ uniforms }) => …, []) |
+20 | +0 |
({ uniforms }) => … inline |
+20 | +30 |
Every one of those runs allocates a fresh TSL graph with new node uuids. In our case the material is keyed on colorNode.uuid, so an inline creator remounts the material on every pointer enter and leave; without such a key you still get graph churn and a new object identity for anything downstream that memoizes on it.
The trap is that the documented form is the broken one. Every example in the docblock (dist/webgpu/index.d.ts:3941-3960) passes an inline arrow:
const { wobble, uTime } = useLocalNodes(({ uniforms, nodes }) => ({
wobble: sin(uniforms.uTime.mul(2)),
uTime: uniforms.uTime,
}))Options, roughly in order of how much they ask of callers:
- Hold the creator in a ref (
useMutableCallbackalready exists in the codebase) and drop it from the deps — the creator is meant to be a pure function of the state it's handed, so its identity shouldn't be semantically meaningful. - Or accept an explicit dep array,
useLocalNodes(creator, deps), mirroringuseMemo. - Either way the examples need
useCallbackif the deps stay as they are, and a line in the docblock saying so.
2. Registering any uniform anywhere re-renders and rebuilds every consumer
uniforms, nodes and textures are whole store objects, so any component registering into any scope replaces them and invalidates every useLocalNodes in the tree — plus re-renders each consumer, since they're read through usePrimaryThree.
Repro: a component that mounts 2s in and registers one uniform in an unrelated scope, touching nothing the cubes use.
function Register() {
useUniforms({ uUnrelated: 1 }, 'unrelated-scope')
return null
}| moment | renders | creator runs |
|---|---|---|
| before the unrelated registration | 48 | 48 |
| after | 60 | 60 |
| delta | +12 (6 cubes × StrictMode) | +12 |
All six cubes re-rendered and rebuilt their graphs because an unrelated component registered an unrelated uniform. In a real app that's a model finishing loading, a panel mounting, a route transition.
The consequence worth flagging: anything created inside a creator is recreated on those rebuilds. We hit this with per-instance mutable uniforms — a hover damp value and a click-accent amount — which reset to their initial values when an unrelated useUniforms fired. We now keep them in a plain useMemo(…, []) outside the creator and only build the derived graph inside it, but nothing in the API signals that this is required, and the docblock's uTime: uniforms.uTime example reads like the creator is a fine place to hold things.
Scoping the subscription would fix it: a creator that only reads uniforms.scope('cubes') doesn't need to re-run when 'unrelated-scope' changes. Subscribing per scope, or letting callers pass the scopes they read, would cut this to nothing for most components.
Related
- #3887 — the types on the same wrappers (
uniforms.scope()isUniformNode<unknown>,nodes.scope()isany). - #3885 —
useUniform's memo is keyed on a constant, the mirror image of this: never re-runs instead of always.
Happy to PR whichever direction you prefer for part 1 — the ref-based creator is a small change.
Source: pmndrs/react-three-fiber