How to properly synchronize parameter updates in DirectionalLightControls with Leva?

Greetings everyone

I am developing a DirectionalLightControls component to control directional light in Three.js via @react-three/fiber using Leva to change settings in real time.

The following situation has arisen:

  • When I use the original version of the component, where shadow updates are done directly in onChange-collections, parameter updates (e.g. resizing the shadow camera via the size(top,bottom,left,right) or near/far parameter) are delayed: the first change is not applied, and the next trigger shows already the previous value.
    Initial code:

import React, { useRef, useEffect, useMemo, useLayoutEffect } from ‘react’
import {
Color,
DirectionalLight,
DirectionalLightHelper,
CameraHelper,
Camera,
PCFShadowMap,
} from ‘three’
import { useControls, folder } from ‘leva’
import { useThree } from ‘@react-three/fiber’

function DirectionalLightControls() {
const { gl, scene } = useThree()
const directionalLightRef = useRef(null!)
const cameraHelperRef = useRef(null)
const helperRef = useRef(null!)
const camHelperRef = useRef(null!)

gl.shadowMap.type = PCFShadowMap

const updateHelpers = () => {
	helperRef.current?.update()
	camHelperRef.current?.update()
}

const updateCamera = () => {
	if (directionalLightRef.current?.shadow.camera) {
		directionalLightRef.current.shadow.camera.updateProjectionMatrix()
	}
}

const updateShadow = () => {
	updateHelpers()
	updateCamera()
}

const options = useMemo(
	() => ({
		visible: true,
		color: 'white',
		position: folder({
			x: { value: -4.2, min: -10, max: 10, step: 0.1 },
			y: { value: 0, min: -10, max: 10, step: 0.1 },
			z: { value: 0.2, min: -10, max: 10, step: 0.1 },
		}),
		scale: { value: 1, min: 0, max: 10, step: 0.1 },
		'Camera Settings': folder({
			size: {
				value: 10,
				min: 0,
				max: 20,
				step: 0.5,
				onChange: (v: number) => {
					if (!directionalLightRef.current) return

					const near = directionalLightRef.current.shadow.camera.near
					const far = directionalLightRef.current.shadow.camera.far

					directionalLightRef.current.shadow.camera.top = v
					directionalLightRef.current.shadow.camera.bottom = -v
					directionalLightRef.current.shadow.camera.left = -v
					directionalLightRef.current.shadow.camera.right = v

					directionalLightRef.current.shadow.camera.near = near
					directionalLightRef.current.shadow.camera.far = far

					updateShadow()
				},
			},
			'near/far': {
				value: [1, 20],
				min: 1,
				max: 100,
				step: 1,
				onChange: (v: number[]) => {
					if (!directionalLightRef.current) return

					const size = directionalLightRef.current.shadow.camera.top

					directionalLightRef.current.shadow.camera.near = v[0]
					directionalLightRef.current.shadow.camera.far = v[1]

					directionalLightRef.current.shadow.camera.top = size
					directionalLightRef.current.shadow.camera.bottom = -size
					directionalLightRef.current.shadow.camera.left = -size
					directionalLightRef.current.shadow.camera.right = size

					updateShadow()
				},
			},
			radius: {
				value: 6,
				onChange: (v: number) => {
					directionalLightRef.current.shadow.radius = v
					updateCamera()
				},
			},
			bias: {
				value: -0.0005,
				step: 0.0001,
				onChange: (v: number) => {
					directionalLightRef.current.shadow.bias = v
					updateCamera()
				},
			},
			mapSize: {
				value: 1024,
				min: 256,
				max: 2048,
				step: 128,
				onChange: (v: number) => {
					directionalLightRef.current.shadow.mapSize.set(v, v)
					updateCamera()
				},
			},
		}),
	}),
	[]
)

const props = useControls('Directional Light', options)

useEffect(() => {
	if (helperRef.current) helperRef.current.visible = props.visible
	if (directionalLightRef.current)
		directionalLightRef.current.visible = props.visible
	if (camHelperRef.current) camHelperRef.current.visible = props.visible
}, [props.visible])

const position = useMemo(
	() => ({ x: props.x, y: props.y, z: props.z }),
	[props.x, props.y, props.z]
)
useLayoutEffect(() => {
	if (directionalLightRef.current) {
		directionalLightRef.current.position.set(
			position.x,
			position.y,
			position.z
		)
		updateShadow()
	}
}, [position])

useLayoutEffect(() => {
	if (directionalLightRef.current && cameraHelperRef.current) {
		helperRef.current = new DirectionalLightHelper(
			directionalLightRef.current,
			3,
			'red'
		)
		scene.add(helperRef.current)

		camHelperRef.current = new CameraHelper(cameraHelperRef.current)
		scene.add(camHelperRef.current)

		if (helperRef.current) {
			directionalLightRef.current.shadow.camera.lookAt(0, 0, 0)
			directionalLightRef.current.shadow.radius = 6
			directionalLightRef.current.shadow.bias = -0.0005
			directionalLightRef.current.shadow.mapSize.set(1024, 1024)

			directionalLightRef.current.shadow.camera.top = 10
			directionalLightRef.current.shadow.camera.bottom = -10
			directionalLightRef.current.shadow.camera.left = -10
			directionalLightRef.current.shadow.camera.right = 10
			updateShadow()
		}
	}
	return () => {
		scene.remove(helperRef.current)
		scene.remove(camHelperRef.current)
		helperRef.current.dispose()
		camHelperRef.current.dispose()
	}
}, [])

return (
	<directionalLight
		ref={directionalLightRef}
		scale={props.scale}
		color={props.color}
		position={[-4.2, 0, 0.2]}
		castShadow
	>
		<orthographicCamera ref={cameraHelperRef} attach='shadow-camera' />
	</directionalLight>
)

}

export default DirectionalLightControls

Video:

To “synchronize” the updates, I tried wrapping the shadow update calls in a requestAnimationFrame. An example of the modified updateShadow:
import React, {
useRef,
useEffect,
useMemo,
useLayoutEffect,
useCallback,
} from ‘react’
import {
Color,
DirectionalLight,
DirectionalLightHelper,
CameraHelper,
Camera,
PCFShadowMap,
} from ‘three’
import { useControls, folder } from ‘leva’
import { useThree } from ‘@react-three/fiber’

function DirectionalLightControls() {
const { gl, scene } = useThree()
const directionalLightRef = useRef(null!)
const cameraHelperRef = useRef(null)
const helperRef = useRef(null!)
const camHelperRef = useRef(null!)

gl.shadowMap.type = PCFShadowMap
const updateShadow = useCallback(() => {
	requestAnimationFrame(() => {
		if (
			helperRef.current &&
			camHelperRef.current &&
			directionalLightRef.current
		) {
			helperRef.current.update()
			camHelperRef.current.update()
		}
	})
}, [])

const options = useMemo(
	() => ({
		visible: true,
		color: 'white',
		position: folder({
			x: { value: -4.2, min: -10, max: 10, step: 0.1 },
			y: { value: 0, min: -10, max: 10, step: 0.1 },
			z: { value: 0.2, min: -10, max: 10, step: 0.1 },
		}),
		scale: { value: 1, min: 0, max: 10, step: 0.1 },
		'Camera Settings': folder({
			size: {
				value: 10,
				min: 0,
				max: 20,
				step: 0.5,
				onChange: (v: number) => {
					if (!directionalLightRef.current) return

					const camera = directionalLightRef.current.shadow.camera
					camera.top = v
					camera.bottom = -v
					camera.left = -v
					camera.right = v
					camera.updateProjectionMatrix()

					updateShadow()
				},
			},
			'near/far': {
				value: [1, 20],
				min: 1,
				max: 100,
				step: 1,
				onChange: (v: number[]) => {
					if (!directionalLightRef.current) return

					const camera = directionalLightRef.current.shadow.camera
					camera.near = v[0]
					camera.far = v[1]
					camera.updateProjectionMatrix()

					updateShadow()
				},
			},
			radius: {
				value: 6,
				onChange: (v: number) => {
					if (!directionalLightRef.current) return
					directionalLightRef.current.shadow.radius = v
					directionalLightRef.current.shadow.camera.updateProjectionMatrix()
					updateShadow()
				},
			},
			bias: {
				value: -0.0005,
				step: 0.0001,
				onChange: (v: number) => {
					if (!directionalLightRef.current) return
					directionalLightRef.current.shadow.bias = v
					directionalLightRef.current.shadow.camera.updateProjectionMatrix()
					updateShadow()
				},
			},
			mapSize: {
				value: 1024,
				min: 256,
				max: 2048,
				step: 128,
				onChange: (v: number) => {
					requestAnimationFrame(() => {
						if (!directionalLightRef.current) return
						directionalLightRef.current.shadow.mapSize.set(v, v)
						directionalLightRef.current.shadow.camera.updateProjectionMatrix()
						updateShadow()
					})
				},
			},
		}),
	}),
	[]
)

const props = useControls('Directional Light', options)

useEffect(() => {
	if (helperRef.current) helperRef.current.visible = props.visible
	if (directionalLightRef.current)
		directionalLightRef.current.visible = props.visible
	if (camHelperRef.current) camHelperRef.current.visible = props.visible
}, [props.visible])

const position = useMemo(
	() => ({ x: props.x, y: props.y, z: props.z }),
	[props.x, props.y, props.z]
)

useLayoutEffect(() => {
	if (directionalLightRef.current) {
		directionalLightRef.current.position.set(
			position.x,
			position.y,
			position.z
		)
		updateShadow()
	}
}, [position])

useLayoutEffect(() => {
	let isSetup = false

	if (directionalLightRef.current && cameraHelperRef.current && !isSetup) {
		helperRef.current = new DirectionalLightHelper(
			directionalLightRef.current,
			3,
			'red'
		)
		scene.add(helperRef.current)

		camHelperRef.current = new CameraHelper(cameraHelperRef.current)
		scene.add(camHelperRef.current)

		if (!directionalLightRef.current) return

		directionalLightRef.current.shadow.camera.lookAt(0, 0, 0)
		directionalLightRef.current.shadow.radius = 6
		directionalLightRef.current.shadow.bias = -0.0005
		directionalLightRef.current.shadow.mapSize.set(1024, 1024)

		directionalLightRef.current.shadow.camera.top = 10
		directionalLightRef.current.shadow.camera.bottom = -10
		directionalLightRef.current.shadow.camera.left = -10
		directionalLightRef.current.shadow.camera.right = 10

		updateShadow()
		isSetup = true
	}

	return () => {
		if (helperRef.current) {
			scene.remove(helperRef.current)
			helperRef.current.dispose()
		}
		if (camHelperRef.current) {
			scene.remove(camHelperRef.current)
			camHelperRef.current.dispose()
		}
	}
}, [scene])

return (
	<directionalLight
		ref={directionalLightRef}
		scale={props.scale}
		color={props.color}
		position={[-4.2, 0, 0.2]}
		castShadow
		intensity={1}
	>
		<orthographicCamera ref={cameraHelperRef} attach='shadow-camera' />
	</directionalLight>
)

}

export default DirectionalLightControls

Video

It works in general, but I’m not sure it’s logical and correct in terms of how other more experienced programmers approach it.

Thanks to those who read this, I look forward to your responses. :hugs:

Your issue—with the first parameter change appearing “one step behind”—is a common timing challenge when updating Three.js camera and shadow parameters directly in Leva’s onChange callbacks.

What’s happening?
When you update parameters (such as the shadow camera’s top, bottom, left, and right, or its near/far values) and then immediately call updateProjectionMatrix and helper update methods, those changes can be batched or delayed due to the way Three.js (and even React’s render loop) handles updates. As a result, the new value isn’t visible until the next frame.

Why does wrapping in requestAnimationFrame help?
By scheduling your updateShadow calls with requestAnimationFrame, you defer the update until the next render cycle. This delay gives Three.js and the React Three Fiber render loop time to process the new values so that by the time the frame is rendered, the projection matrix and helpers reflect the latest settings.

Is this the best approach?
Using requestAnimationFrame in your onChange handlers is a practical workaround and can “synchronize” your updates effectively. However, more experienced developers might consider a few alternative strategies for an even more idiomatic solution:

  1. Use the useFrame Hook:
    Instead of manually wrapping updates in requestAnimationFrame, you could leverage react‑three‑fiber’s useFrame hook to continuously check for changes and update your shadow camera and helpers on every frame. This approach integrates your updates directly into the render loop and can prevent timing issues.
  2. Centralize Updates via State or Refs:
    Rather than handling each parameter change separately inside Leva’s onChange callbacks, store your camera parameters in state (or refs) and use a useLayoutEffect to update the shadow camera and helpers when those values change. This ensures that updates are applied as soon as possible in the render cycle.
  3. Debounce or Batch Updates:
    If rapid consecutive changes are causing the lag, debouncing your onChange callbacks might also help ensure that the updateProjectionMatrix and helper updates occur only once per frame.

In summary, wrapping your update calls in requestAnimationFrame is a valid solution that addresses the timing issue. For a cleaner approach, you might integrate updates into the main render loop using useFrame or centralize parameter state so that all changes are applied consistently in a useLayoutEffect.

1 Like

Hello,thanks for your full response.With regard to the useFrame matter, I gave it some thought, but I had the impression that it would place too much of a strain on the system if I performed this check on every iteration without the appropriate case and updated the light helper.Perhaps I am mistaken in this regard.However, your subsequent two points clarified the optimal approach.

I must admit that I am still somewhat unfamiliar with r3f, as prior to this, I had attempted to implement the project in three.js. However, I realised that there are more implementations of what I had already done, which meant that I had to move some of it.Nevertheless, working with dat.GUI and animate function instead of useFrame seemed to be a more familiar approach.

In any case, I would like to thank you again for your reply .