Types: the ScopedStore surface (`useLocalNodes`, `uniforms.scope`, `nodes.scope`) — uniforms need a double cast, nodes are `any`

Author: DennisSmolekCreated Aug 30, 2026Updated Sep 9, 2026
LabelsTypescriptv10

The ScopedStore surface: uniforms need a double cast, nodes are any

Version: @react-three/[email protected], [email protected], [email protected], React 19.2.

Follow-on to #3886 (store uniforms vs material node slots) and #3769 (useUniforms inference). Both of those are about what comes out of the hook. This one is about the other consumption path — the CreatorState wrappers that useLocalNodes and useNodes creators receive — where the same unknown shows up again, plus two problems specific to the wrapper types.

Everything below was checked with tsc --noEmit against alpha.4. It came out of porting one workshop scene to Vite, Next and Astro (R3F-Workshop/v10-starter), so each case is something we actually had to write a cast for.

What each path infers today

Expression Inferred type Usable in TSL without a cast
useUniforms({ uColor: '#fafafa' }, 'cubes').uColor UniformNode<unknown> no
useUniforms({ uAmount: 0.5 }).uAmount UniformNode<unknown> no
useUniform('uSolo', 0) UniformNode<number> no — value type kept, node type still unknown
useLocalNodes(({ uniforms }) => uniforms.scope('cubes').uColor) UniformNode<unknown> no
useNodes(() => ({ wobble: vec3(uniform(0), 0, 0) }), 'probe').wobble VarNode<"vec3", JoinNode<"vec3">> yes
useNodes('probe').wobble TSLNodeLike no
useLocalNodes(({ nodes }) => nodes.scope('probe').wobble) any compiles, but unchecked

The creator path of useNodes is the one place the precise node type survives. Everything read back out of the store loses it.

1. Uniforms reached through uniforms.scope()

Same root cause as #3886 — the node type is pinned to unknown — but it lands on TSL operator arguments rather than material slots:

typescript
const build = useCallback(({ uniforms }: CreatorState) => {
  const cubes = uniforms.scope('cubes')
  return { colorNode: mix(cubes.uBaseColor, color('#000'), 0.5) }
}, [])
TS2769: No overload matches this call.
  Argument of type 'UniformNode<unknown>' is not assignable to parameter of type 'Vec3 | Vec4OrFloat'.
    Type 'UniformNode<unknown>' is not assignable to type 'Node<"color">'.
      Type 'UniformNodeClass<unknown> & ... & { __TypeScript_NODE_TYPE__: unknown; } & InputNodeInterface<unknown>'
      is missing the following properties from type 'NodeExtensions<"color">':
      context, uniformFlow, builtinShadowContext, builtinAOContext, and 6 more.

Method chaining fails differently, which is worth calling out because it's the form most TSL code is written in:

typescript
uHoverColor.mul(0.4)
// TS2339: Property 'mul' does not exist on type 'UniformNode<unknown>'.

2. The ScopedStore docblock example doesn't compile

dist/webgpu/index.d.ts:3745-3752 advertises exactly this, with an explicit promise:

typescript
useLocalNodes(({ uniforms }) => ({
  wobble: sin(uniforms.uTime.mul(2)),           // No cast needed!
  playerHealth: uniforms.scope('player').uHealth
}))

Pasted verbatim into a project it fails with the TS2339 above — .mul does not exist on UniformNode<unknown>. Whichever way the fix goes, this example should compile or come out.

3. The nodes side of the wrapper is any

CreatorState types nodes as ScopedStoreType<TSLNodeType> (index.d.ts:3791, TSLNodeType at :1244). Property access on it resolves to any:

typescript
const { n } = useLocalNodes(({ nodes }: CreatorState) => ({ n: nodes.scope('probe').wobble }))

const isNever: never = n     // TS2322: Type 'any' is not assignable to type 'never'
const s: string = n          // compiles
const x: number = n.whatever // compiles

So the uniforms half of the wrapper is wrong-but-checked, and the nodes half is unchecked. This is the worse of the two failure modes: a typo in a node name is a runtime undefined with nothing flagged at build time. I haven't chased down which member of the Node | ShaderCallable<Node> | ShaderNodeObject<Node> union brings the any in, but a union with one any member collapses to any, which fits what we see.

4. Creator return vs read-back

typescript
// full type preserved
const { wobble } = useNodes(() => ({ wobble: vec3(uniform(0), 0, 0) }), 'probe')
//      ^? VarNode<"vec3", JoinNode<"vec3">>

// same node, read back
const { wobble } = useNodes('probe')
//      ^? TSLNodeLike

Since the store is keyed by scope and name at runtime, the read-back can't recover what the creator knew. Worth deciding whether reads are meant to be typed by the caller (a generic on useNodes/.scope()) or to stay opaque — right now they're opaque in three different ways (TSLNodeLike, UniformNode<unknown>, any) depending on which door you come through.

What this looks like in real code

Every store read that touches TSL gets laundered. From the starter:

typescript
// the cast has to go through `unknown` — a direct `as` is rejected (TS2352)
const cubes = uniforms.scope('cubes') as unknown as {
  uBaseColor: ColorUniform
  uHoverColor: ColorUniform
}

and, for a material slot:

typescript
/** R3F aliases UniformNode with three's nodeType pinned to `unknown`, so store
 *  uniforms satisfy none of the material's `Node<'color'>`-style slots and won't
 *  even direct-cast. Launder it in one place; delete once r3f types land. */
const asNode = <T extends string>(u: unknown) => u as Node<T>

Suggested direction

  1. Fixing the root (#3769 / #3886) — letting a real TNodeType flow instead of unknown — resolves 1 and 2 as a side effect, since the wrapper just re-exposes the store's element type.
  2. ScopedStoreType could take a caller-supplied map so scopes can be typed at the call site, e.g. uniforms.scope<CubeUniforms>('cubes'). That is the one place the caller genuinely knows more than the store does.
  3. For the nodes half, anything is better than any — even Node alone would catch typos.
  4. Drop or fix the // No cast needed! example.

Happy to open a PR for any of these if that's useful — say which shape you'd want.

Repro

Single file, tsc --noEmit against alpha.4:

typescript
import { useCallback } from 'react'
import { useUniforms, useNodes, useLocalNodes, type CreatorState } from '@react-three/fiber/webgpu'
import { color, mix, sin, uniform, vec3 } from 'three/tsl'

export function Probe() {
  const { uColor } = useUniforms({ uColor: '#fafafa' }, 'cubes')

  const a = mix(uColor, color('#000'), 0.5)   // TS2769
  const b = uColor.mul(0.4)                   // TS2339

  const build = useCallback(({ uniforms }: CreatorState) => ({
    c: mix(uniforms.scope('cubes').uColor, color('#000'), 0.5),   // TS2769
    d: sin(uniforms.uTime.mul(2)),                                // TS2339 — the docblock example
  }), [])
  useLocalNodes(build)

  const { wobble } = useNodes(() => ({ wobble: vec3(uniform(0), 0, 0) }), 'probe')
  const e = useNodes('probe').wobble          // TSLNodeLike
  const { n } = useLocalNodes(({ nodes }: CreatorState) => ({ n: nodes.scope('probe').wobble }))
  const g: string = n                         // compiles — `any`

  return { a, b, wobble, e, g }
}

Source: pmndrs/react-three-fiber