Three.js From Zero · Article s8-01
S8-01 State Management
Season 8 · Article 01
State Management for 3D Apps
UI state and scene state have different needs. UI wants reactivity. Scene wants 60fps mutations. Use Zustand/Jotai for UI + imperative refs for scene — don't force React's reconciler to touch your Object3Ds.
1. Two worlds
| UI state | Scene state |
|---|---|
| Selected tool, modal open, count | Object positions, rotations |
| 10s of updates/sec max | 60-120 updates/sec |
| Drives React render | Drives Three.js loop |
| Zustand/Jotai/Redux | Direct mutation of Object3D |
2. The anti-pattern
// DON'T: React-controlled Three.js position
function Cube() {
const [pos, setPos] = useState({ x: 0, y: 0, z: 0 });
useEffect(() => { /* update every frame */ setPos({...}); }, []);
return <mesh position={[pos.x, pos.y, pos.z]}>...</mesh>;
}
// Every frame triggers setState → React render → commit. 60Hz of React work.
3. The right way (R3F idiom)
function Cube() {
const ref = useRef();
useFrame((_, dt) => {
ref.current.position.x = Math.sin(clock.time); // direct mutation
});
return <mesh ref={ref}>...</mesh>;
}
// Zero React re-renders. 60fps clean.
4. Zustand for UI state
import { create } from 'zustand';
const useStore = create((set) => ({
tool: 'select',
isModalOpen: false,
selectedCount: 0,
setTool: (t) => set({ tool: t }),
toggleModal: () => set((s) => ({ isModalOpen: !s.isModalOpen })),
}));
// UI component
function Toolbar() {
const { tool, setTool } = useStore();
return <button onClick={() => setTool('move')}>Move</button>;
}
// 3D component subscribes only to slices
function Selector() {
const tool = useStore((s) => s.tool);
useFrame(() => {
if (tool === 'select') /* behavior */;
});
}
5. Jotai for atomic state
import { atom, useAtom } from 'jotai';
const cameraAtom = atom({ mode: 'orbit', fov: 45 });
const selectedAtom = atom([]);
// Per-component subscription — no unnecessary re-renders
const [camera, setCamera] = useAtom(cameraAtom);
6. Redux Toolkit for big apps
When you have: multiple teams, undo/redo, persistence, dev tools, middleware — Redux Toolkit is still king. Slices per feature area.
7. Bridging the worlds
// Scene state lives on Object3Ds (imperative).
// When user saves/shares, snapshot it into Zustand:
function serialize() {
return scene.children.map(o => ({
name: o.name,
pos: o.position.toArray(),
rot: o.rotation.toArray(),
}));
}
// On load, hydrate:
function hydrate(snapshot) {
for (const e of snapshot) {
const o = scene.getObjectByName(e.name);
o.position.fromArray(e.pos);
o.rotation.fromArray(e.rot);
}
}
8. Selectors to avoid re-renders
// BAD: subscribes to whole store
const store = useStore();
// GOOD: selector slice
const tool = useStore((s) => s.tool);
// Only re-renders when `tool` changes.
9. Demo — counting state updates
Imperative mutation (green): thousands of updates, zero React renders. React-state mutation (red): same updates all trigger React re-renders.
–
Update counters
Scene mutations:0
React renders:0
FPS:60
10. Takeaways
- UI state: Zustand/Jotai/Redux. Reactive.
- Scene state: direct Object3D mutation. Imperative.
- Subscribe via selectors to avoid extra re-renders.
- Bridge with serialize/hydrate on save/load.
- Never put 60Hz-changing values in React state.