Three.js From Zero · Article s13-05

R3F + Zustand State

R3F + Zustand State is Article s13-05 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS13-05 · R3F Mastery

Season 13 · Article 05 · R3F Mastery

React's useState triggers re-renders. In a 3D scene with 100 entities, that's a perf cliff. Zustand gives you a global store with surgical subscriptions: only the components that read a changed slice re-render. The state library R3F devs reach for first.

Code-walkthrough article

Install: npm install zustand. Pairs with any R3F project.

Why not useState

// BAD: every Enemy re-renders when score changes
function Game() {
  const [score, setScore] = useState(0);
  return (
    <>
      <Hud score={score} />
      {enemies.map(e => <Enemy key={e.id} onHit={() => setScore(s => s + 1)} />)}
    </>
  );
}

When score changes, Game re-renders. All 100 Enemies re-render too. They're remounted as React components — their refs survive (good), but the reconciliation cost is real (bad).

Zustand: one store, surgical subscriptions

import { create } from 'zustand';

const useGame = create((set) => ({
  score: 0,
  enemies: [],
  health: 100,
  addScore: (n) => set(s => ({ score: s.score + n })),
  takeDamage: (n) => set(s => ({ health: Math.max(0, s.health - n) })),
}));

That's the entire store. No reducer, no provider, no boilerplate. useGame is both a hook and a callable getter (useGame.getState()).

The selector pattern

// GOOD: only re-renders when score changes
function Hud() {
  const score = useGame(s => s.score);
  return <Html>{score}</Html>;
}

// Multiple slices: subscribe-with-selector + shallow comparison
import { shallow } from 'zustand/shallow';
function Player() {
  const { health, takeDamage } = useGame(
    s => ({ health: s.health, takeDamage: s.takeDamage }),
    shallow,
  );
}

Selector functions are the key. useGame(s => s.score) means "subscribe to changes in s.score only." When other state changes, this component does NOT re-render. That's the whole point.

Read outside React

function useFrameLogic() {
  useFrame(() => {
    // Reading state inside useFrame without subscribing
    const speed = useGame.getState().speed;
    mesh.current.position.x += speed * delta;
  });
}

getState() reads the current value without subscribing. Use it in useFrame to avoid React re-renders for per-frame reads. The component subscribes via the hook for things that affect render; useFrame uses getState for things that don't.

Actions

// Actions are functions on the store; call them anywhere
useGame.getState().addScore(10);

// Or via the hook + selector
function Coin() {
  const addScore = useGame(s => s.addScore);
  return <mesh onClick={() => addScore(10)} />;
}

Actions never change between renders (stable reference), so they're safe to use in dependency arrays without infinite-loop risk.

Middleware: immer for nested updates

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

const useGame = create(immer((set) => ({
  player: { position: [0,0,0], inventory: [] },
  addItem: (item) => set(s => { s.player.inventory.push(item); }),  // mutate freely
})));

Immer lets you "mutate" state directly inside set; under the hood it produces a new immutable object. Worth the dependency when your state is deeply nested.

Common first-time pitfalls

"My selector returns an object — re-renders every time." Object literals are a new reference each render. Use shallow from zustand/shallow as second arg, or split into multiple selectors.
"State updates inside useFrame stutter." You're calling a hook setter, which triggers a React render. Use useGame.getState().action() instead, OR debounce the writes to React state (e.g., only sync to React every 10 frames).
"Store survives hot reload." Zustand creates the store at module load; HMR replaces the module but not the store instance. For dev, dispose explicitly on HMR (import.meta.hot).

Exercises

  1. Add a store to your game. Move score, health, level from local useState to a zustand store. Profile before/after — fewer commits per second.
  2. Per-frame writes. Player position in zustand. Write via getState() inside useFrame. Read in useFrame for other entities. No React renders during normal play.
  3. DevTools integration. Wrap the store with devtools middleware. Inspect state changes in Redux DevTools — game state on a timeline.