Command Palette

Search for a command to run...

Hectal
Course outline
Topic 11.3Intermediate ~20 min · step 3 of 7

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.

0/7 · 0%

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), or never (you call advance).
invalidate
A function that tells a demand Canvas 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. 1

    Switch to always

    In the second example, change frameloop="demand" to "always". The output becomes frames drawn: 30 and then frames drawn: 40: every animation frame is drawn.

  2. 2

    Change the store

    In the first example, change speed: 2 to speed: 0. The right box stops, because useFrame reads the new value each frame. The counts stay 60 and 1.

Code & diagrams

Re-render 60 times, or once New tab

Same picture, very different work. The counts are printed on frame 60: `SlowSpinner` renders on every frame.

idle

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 1
frameloop demand and invalidate() New tab

The Canvas draws when asked. Delta is the time since the last drawn frame, so it can be large after a pause.

idle

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: 4

Break 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.

terminal
$ Run
── what you'll see ──
after 60 frames: SlowSpinner rendered 60 times, FastSpinner rendered 60

Break #2

A demand Canvas that never moves

In the second example, delete the line if (n >= 30 && n <= 32) invalidate();.

terminal
$ Run
── what you'll see ──
30 animation frames passed, frames drawn: 1
after 3 invalidate() calls, frames drawn: 1

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.

  • ▸

    delta is 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.

  • ▸

    useFrame callbacks 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. Guard ref.current (it is undefined before mount and after unmount).

  • ▸

    zustand's subscribe lets 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. 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. 2

    **useFrame.** useFrame((state, delta) => { ... }) runs before every draw. state holds the Canvas store (gl, scene, camera, clock...). delta is the time since the previous frame in seconds. Move things by speed * delta so they take the same real time on a 60 Hz and a 144 Hz screen (Phase 9).

  3. 3

    Refs, not state. In the first example, SlowSpinner sets state each frame and FastSpinner writes mesh.current.rotation.y. After 60 frames, SlowSpinner has rendered 60 times and FastSpinner 1 time. The visible result is the same: two turning boxes.

  4. 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().speed reads it inside useFrame without subscribing, so no render happens. A component that does useSpin((s) => s.speed) re-renders only when that value changes, which is rare.

  5. 5

    **useThree.** useThree((state) => state.camera) gives the live camera, gl, scene, size, viewport, invalidate and more. Always pass a selector: useThree() alone subscribes to everything and re-renders on every resize or camera change.

  6. 6

    Drawing only when needed. <Canvas frameloop="demand"> draws once at start and then only when something calls invalidate() (R3F also does this when props change). In the second example, 30 animation frames pass with 1 frame drawn, and three invalidate() calls add 3 more. A scene that sits still (a configurator waiting for a click) then uses almost no battery. With frameloop="always" (the default) the same run draws 30 frames by the time 30 have passed. Anything that animates on its own must call invalidate() itself, or use "always".

  7. 7

    Order. Several useFrame calls run in order of their priority argument (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

01

What is wrong with useFrame(() => setAngle((a) => a + 0.01)), and what replaces it?

02

When would you choose frameloop="demand", and what do you need to remember to do?

Practice

01

Turn a mesh at 90 degrees per second using delta, with no state. How far has it turned after 2 seconds?

02

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