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.
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
shallow from zustand/shallow as second arg, or split into multiple selectors.useGame.getState().action() instead, OR debounce the writes to React state (e.g., only sync to React every 10 frames).import.meta.hot).Exercises
- Add a store to your game. Move score, health, level from local useState to a zustand store. Profile before/after — fewer commits per second.
- Per-frame writes. Player position in zustand. Write via getState() inside useFrame. Read in useFrame for other entities. No React renders during normal play.
- DevTools integration. Wrap the store with
devtoolsmiddleware. Inspect state changes in Redux DevTools — game state on a timeline.
UP NEXT
S13-06 — Suspense & Progressive Loading → Splash, preload, LOD swaps, perceived-perf tricks.