`useLocalNodes`: inline creator rebuilds the graph every render, and any uniform registration invalidates every consumer

Author: DennisSmolekCreated Aug 30, 2026Updated Sep 16, 2026
Labelsv10

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:

javascript
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:

typescript
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 (useMutableCallback already 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), mirroring useMemo.
  • Either way the examples need useCallback if 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.

typescript
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() is UniformNode<unknown>, nodes.scope() is any).
  • #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