Support effect-only useNodes/useLocalNodes callbacks without synthetic returns

Author: DennisSmolekCreated Sep 1, 2026Updated Sep 1, 2026
Labelsenhancementrequest for commentsv10WebGPU

Problem

useNodes creators must return a Record<string, TSLNodeLike>, and useLocalNodes creators must return a record as well. That is useful when another consumer needs the resulting nodes, but it forces a synthetic return object when the creator installs a node directly onto Three.js state and nothing needs to consume the result.

For example, a custom scene fog graph can naturally be expressed as:

typescript
const fogValues = useControls('sprites fog', {
  fogColor: '#0000ff',
  near: 1500,
  far: 2100,
})

useUniforms(fogValues)

useNodes(({ scene, uniforms }) => {
  scene.fogNode = fog(
    uniforms.fogColor,
    rangeFogFactor(uniforms.near, uniforms.far),
  )
})

Today this fails overload resolution because the callback returns void:

Type 'void' is not assignable to type 'Record<string, TSLNodeLike>'

The callback has to return an otherwise-unused object solely to satisfy the hook contract:

typescript
useNodes(({ scene, uniforms }) => {
  scene.fogNode = fog(
    uniforms.fogColor,
    rangeFogFactor(uniforms.near, uniforms.far),
  )

  return { fogNode: scene.fogNode }
})

The same friction applies to useLocalNodes when its result is intentionally not consumed.

Proposal

Allow useNodes and useLocalNodes to accept effect-only creators that return nothing. The hooks should still provide CreatorState, execute with their existing create/rebuild semantics, and avoid requiring a fake node record when there is no external consumer.

Possible overload shape:

typescript
useNodes(creator: (state: CreatorState) => void): void
useLocalNodes(creator: (state: CreatorState) => void): void

Existing record-returning overloads and registration behavior would remain unchanged.

Lifecycle concern

This should be more than a TypeScript-only relaxation. Both creators currently execute during render (useNodes through resource creation and useLocalNodes through useMemo). Allowing callbacks intended purely for imperative assignment raises React lifecycle questions:

  • An aborted concurrent render should not leave mutations on scene, material, or another Three object.
  • An effect-only callback may need cleanup when the component unmounts or resources rebuild.
  • A void useNodes result has nothing to register in the node store.

If render-phase mutation is intentionally out of scope, a dedicated commit-phase API such as useNodeEffect may be safer than adding void overloads. It could receive the same CreatorState and optionally return an effect cleanup:

typescript
useNodeEffect(({ scene, uniforms }) => {
  const previousFogNode = scene.fogNode
  scene.fogNode = fog(uniforms.fogColor, rangeFogFactor(uniforms.near, uniforms.far))

  return () => {
    scene.fogNode = previousFogNode
  }
})

The main request is an ergonomic, lifecycle-safe path for building and installing TSL nodes directly without manufacturing a return value that no consumer uses.

Source: pmndrs/react-three-fiber