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 stateScene state
Selected tool, modal open, countObject positions, rotations
10s of updates/sec max60-120 updates/sec
Drives React renderDrives Three.js loop
Zustand/Jotai/ReduxDirect 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.