Course outline
useFrame and State
In one line
Animate through refs inside useFrame, not through setState: a state change re-renders the component every frame. Use delta for speed, invalidate with frameloop="demand" to draw only when needed, and zustand for shared state.
Think of it like this
A clock on a wall. To move its hands you reach out and turn them. You do not take the clock down, rebuild it with new hands and hang it up again 60 times a second. A ref is the hand you reach out for. State is the rebuild.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- delta
- The time since the last frame, in seconds (about 0.0167 at 60 Hz). Multiply speeds by it to get frame-rate independent motion.
- selector
- A small function that picks one value out of a store, so a component only re-renders when that value changes.
- zustand
- A small state library whose store lives outside React. You can read it anywhere with
getState()and subscribe with a hook. - frameloop
- How the Canvas decides when to draw:
always(every frame),demand(when invalidated), ornever(you calladvance). - invalidate
- A function that tells a
demandCanvas to draw one more frame.
Step by step
01Count the renders
Each component adds one to a counter every time React renders it. Report waits for frame 60 and prints both counters.
02Two ways to turn a box
SlowSpinner keeps the angle in useState and passes rotation-y={angle}. FastSpinner writes to the mesh through a ref.
03Read the store without subscribing
useSpin.getState().speed inside useFrame reads the latest value with no hook and no re-render.
04Demand mode
In the second example, frameloop="demand" keeps the screen still. A Probe calls invalidate() on animation frames 30, 31 and 32, and the counter goes from 1 to 4.
Try it yourself
- 1
Switch to always
In the second example, change
frameloop="demand"to"always". The output becomesframes drawn: 30and thenframes drawn: 40: every animation frame is drawn. - 2
Change the store
In the first example, change
speed: 2tospeed: 0. The right box stops, becauseuseFramereads the new value each frame. The counts stay60and1.
Code & diagrams
Same picture, very different work. The counts are printed on frame 60: `SlowSpinner` renders on every frame.
Console
Run the scene to see its console.Expected console output
THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.
after 60 frames: SlowSpinner rendered 60 times, FastSpinner rendered 1The Canvas draws when asked. Delta is the time since the last drawn frame, so it can be large after a pause.
Console
Run the scene to see its console.Expected console output
THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.
30 animation frames passed, frames drawn: 1
after 3 invalidate() calls, frames drawn: 4Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Animate with setState
Replace the ref in FastSpinner with a useState angle updated in useFrame, as SlowSpinner does.
Break #2
A demand Canvas that never moves
In the second example, delete the line if (n >= 30 && n <= 32) invalidate();.
Myth vs fact
Myth
State is the React way, so animation should use state.
Fact
State is for what the UI shows. A value that changes 60 times a second and is read only by three.js belongs in a ref or a store read with getState().
Myth
useFrame runs on a timer.
Fact
It runs once per drawn frame, so in a hidden tab (the browser stops requestAnimationFrame) it stops too, and in demand mode only when invalidated.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
deltais the raw difference from the clock (R3F does not clamp it), so after a long pause such as a hidden tab it can be seconds. Clamp it yourself (Math.min(delta, 0.1)) for physics and anything that must not jump. - ▸
useFramecallbacks run inside R3F's loop, not React's. An error thrown in one is not caught by an error boundary around the component: it goes to the console. Guardref.current(it isundefinedbefore mount and after unmount). - ▸
zustand's
subscribelets non-React code react to changes:useSpin.subscribe((s) => ...). Use it to connect a store to a three.js object directly, bypassing React for good.
Remember this
- 1
The problem this solves: the most common R3F performance mistake is
useFrame(() => setAngle(a + 0.01)). Each call tells React to run your component again. At 60 frames per second that is 60 renders of the component and its children, every second, for something three.js can do with one assignment. - 2
**
useFrame.**useFrame((state, delta) => { ... })runs before every draw.stateholds the Canvas store (gl,scene,camera,clock...).deltais the time since the previous frame in seconds. Move things byspeed * deltaso they take the same real time on a 60 Hz and a 144 Hz screen (Phase 9). - 3
Refs, not state. In the first example,
SlowSpinnersets state each frame andFastSpinnerwritesmesh.current.rotation.y. After 60 frames,SlowSpinnerhas rendered 60 times andFastSpinner1 time. The visible result is the same: two turning boxes. - 4
Shared state without re-rendering. State that other components also use (a speed, a selected part) can live in a store such as zustand.
useSpin.getState().speedreads it insideuseFramewithout subscribing, so no render happens. A component that doesuseSpin((s) => s.speed)re-renders only when that value changes, which is rare. - 5
**
useThree.**useThree((state) => state.camera)gives the live camera,gl,scene,size,viewport,invalidateand more. Always pass a selector:useThree()alone subscribes to everything and re-renders on every resize or camera change. - 6
Drawing only when needed.
<Canvas frameloop="demand">draws once at start and then only when something callsinvalidate()(R3F also does this when props change). In the second example, 30 animation frames pass with 1 frame drawn, and threeinvalidate()calls add 3 more. A scene that sits still (a configurator waiting for a click) then uses almost no battery. Withframeloop="always"(the default) the same run draws 30 frames by the time 30 have passed. Anything that animates on its own must callinvalidate()itself, or use "always". - 7
Order. Several
useFramecalls run in order of theirpriorityargument (default 0, lower first), then in mount order. A call with a positive priority takes over rendering: R3F no longer draws for you. Topic 11.5 shows why that matters.
Explain it without notes
What is wrong with useFrame(() => setAngle((a) => a + 0.01)), and what replaces it?
When would you choose frameloop="demand", and what do you need to remember to do?
Practice
Turn a mesh at 90 degrees per second using delta, with no state. How far has it turned after 2 seconds?
A store holds { color: "#ff6b2c" }. Write a component that reads it as state for a button, and a useFrame that reads it without re-rendering.
Trade-offs
- ↔
frameloop="demand"saves battery but forgets animation you did not invalidate. For most showrooms, "always" is simpler; use "demand" when the scene is mostly still.
Done when you can
Animate with a ref in useFrame and delta
Show that setState per frame re-renders every frame
Read a store without subscribing
Use frameloop demand and invalidate