A side effect in useMemo froze my scroll-driven 3D model
The canvas rendered, the captions followed the scroll, and the model never moved. One line in the wrong hook, found by StrictMode.
16 October 2026Tip3 min readEnglish
An earlier version of the 3D scene on this site scrubbed a baked animation from a GLB file: scroll down and the model plays forward, scroll up and it plays back. One day it stopped. The canvas rendered, the camera moved, the step captions changed, and every mesh stayed in its first pose. No error in the console.
Worse, it looked random. After a hot reload it worked. After a full page reload it did not.
The code that broke
The animation action was created once and armed in the same place:
const action = useMemo(() => {
const a = mixer.clipAction(clip)
a.play() // a side effect inside useMemo
a.paused = true
return a
}, [mixer, clip])
useEffect(() => () => void mixer.stopAllAction(), [mixer]) // the cleanup undoes itIt reads fine: useMemo builds the action once, and the effect stops it when the component unmounts.
What StrictMode does
The commit that broke it never touched the scene. It added a client entry that wraps the app in <StrictMode> for Sentry. In development, StrictMode mounts a component, runs every effect cleanup, then runs every effect again. It does not run useMemo again.
useMemo creates the action and calls play().
StrictMode runs the cleanup, and stopAllAction() stops the action.
StrictMode runs the effect again. Nothing calls play().
AnimationMixer.update() only loops over active actions, so a stopped action is skipped. Setting action.time still changes a number, but nothing applies it to the scene. A Vite hot reload remounts without the double pass, which is why it worked until the next full reload. Production builds never had the bug.
How to check
Log action.isScheduled() inside useFrame. If it prints false while you scroll, this is your bug.
The fix
Keep the memo pure, and re-arm the action right before you scrub it:
const action = useMemo(() => mixer.clipAction(clip), [mixer, clip])
useEffect(() => () => void mixer.stopAllAction(), [mixer])
useFrame(() => {
if (!action.isScheduled()) {
action.play()
action.paused = true // time comes from the scroll, never from the clock
}
action.time = MathUtils.clamp(progress.get(), 0, 1) * (clip.duration - 1e-3)
mixer.update(0)
})It survives StrictMode, hot reloads and anything else that stops the action.
What did not help
Removing StrictMode. It hides the bug and leaves the code fragile.
Snapping to each step without animating. Posing goes through the same stopped action, so it fails the same way.
Never put a side effect in useMemo or in a useState initialiser when an effect cleanup undoes it. The cleanup will run; the memo will not run again.
None of this is specific to three.js. Audio nodes, WebSocket subscriptions and GSAP timelines break the same way.
Filed under react, three.js, debugging