Architecture guide · React Three Fiber + Zustand
React Three Fiber game state management without rerendering every frame.
A 3D game has several kinds of state with different lifetimes and update rates. Put durable gameplay facts in headless stores, transient motion in refs, cross-system facts on a typed event bus, and React UI behind narrow selectors.
Classify state before choosing where it lives.
Durable domain state
Quest progress, inventory slots, dialogue position, unlocks, and save metadata belong in serializable stores.
Frame-local state
Interpolated transforms, animation time, camera smoothing, and reusable vectors belong in refs or Three.js objects.
UI state
Hovered controls and local panels can stay in React; shared HUD and modal state may use a focused UI store.
Authoritative state
Competitive movement, shared worlds, and economic facts remain server-owned even when the client renders a projection.
Update frequency is the useful dividing line. If a value changes sixty times per second only to move a mesh, routing it through React state creates work without adding meaning. If a value must survive a reload, drive a HUD, or be validated on a server, keeping it only inside a component ref makes it invisible to the rest of the game.
Make gameplay stores usable without React or Canvas.
Overworld systems expose vanilla Zustand stores. React can subscribe to them, but the store itself does not depend on hooks, a DOM, or WebGL.
import { useStore } from 'zustand'
import { createQuestEngine } from '@overworld-engine/quest'
export const quests = createQuestEngine({
quests: QUESTS,
conditions,
effects,
events,
persist: true,
})
// React HUD: subscribe only to the projection this component renders.
function QuestCounter() {
const activeCount = useStore(
quests.store,
(state) => Object.keys(state.active).length,
)
return <span>{activeCount} active quests</span>
}
// Test, server, command handler, or composition root:
quests.getState().startQuest('welcome')Keep actions beside the state they protect. A component should ask the inventory to add an item rather than replacing its slot array directly, because the engine owns stacking, capacity, effects, persistence, and emitted events.
See how HUD components bind to these stores without importing them →Read goals from stores; mutate visual objects inside useFrame.
const targetRef = useRef(new THREE.Vector3())
const meshRef = useRef<THREE.Group>(null)
useEffect(() => {
return movementStore.subscribe((state) => {
targetRef.current.set(
state.target.x,
state.target.y,
state.target.z,
)
})
}, [])
useFrame((_, delta) => {
if (!meshRef.current) return
const alpha = 1 - Math.exp(-12 * delta)
meshRef.current.position.lerp(targetRef.current, alpha)
})The store carries a meaningful target that changes when gameplay changes. The ref carries the continuously interpolated presentation. Use the frame delta so movement does not depend on display refresh rate, and reuse vectors instead of allocating Three.js objects in the hot path.
Avoid calling React setState or a broad Zustand action on every frame merely to feed the same value back into one mesh. When a frame-level threshold becomes a durable fact—an item was collected or a region was entered—emit that transition once and let domain systems update.
The type-safe TypeScript game event bus guide covers payload contracts, synchronous ordering, cleanup, testing, and which events may cross a multiplayer boundary.
For draw calls, instancing, LOD, adaptive quality, and loading budgets, continue with the R3F game performance guide.
Use typed events for facts, not as a hidden state database.
import {
EventBus,
type OverworldEventMap,
} from '@overworld-engine/core'
declare module '@overworld-engine/core' {
interface OverworldEventMap {
'combat:enemy-defeated': {
enemyId: string
xp: number
}
}
}
const events = new EventBus<OverworldEventMap>()
events.emit('combat:enemy-defeated', {
enemyId: 'slime-7',
xp: 25,
})
// Quests, achievements, audio, analytics, and UI may observe it
// without importing one another.An event answers “what happened?” A store answers “what is true now?” Do not replay an event log every time a component needs the current inventory, and do not make the combat system import quest, achievement, audio, and analytics stores just to notify them.
Create stores and connections in one application composition root.
const events = new EventBus<OverworldEventMap>()
export const game = {
inventory: createInventory({
items: ITEMS,
effects,
context,
events,
persist: true,
}),
quests: createQuestEngine({
quests: QUESTS,
conditions,
effects,
context,
events,
persist: true,
}),
dialogue: createDialogueEngine({
dialogues: DIALOGUES,
conditions,
effects,
context,
events,
}),
}
export function disposeGame() {
game.quests.dispose()
events.clear()
}Explicit construction makes test isolation, multiplayer rooms, editor previews, and hot reload easier. It also exposes lifecycle ownership: the application that creates subscriptions is responsible for disposing them.
Persist a selected schema, not the entire runtime.
Persist only serializable facts that a future build can migrate. Content definitions, React elements, Three.js instances, sockets, caches, and derived indexes should be reconstructed. Version the stored shape and test every supported migration path.
Client persistence is appropriate for settings and permitted single-player progress. In an authoritative multiplayer game, a client store is a local projection and prediction workspace. The server validates commands and owns canonical shared or economic state.
Test state transitions without mounting a 3D renderer.
- Create a fresh event bus and fresh stores for each test.
- Drive public actions or typed events, then assert plain store snapshots.
- Inject storage, clocks, and random sources when behavior must be reproducible.
- Test React selectors separately from frame interpolation and visual regression.
- Record event streams when diagnosing coordination without treating them as durable state.
React Three Fiber state-management questions
Should every Three.js object live in a Zustand store?
No. Store durable game facts and low-frequency goals. Keep meshes, materials, animation mixers, interpolated transforms, and other frame-local objects in refs or the scene layer.
Can a Zustand game store live outside the R3F Canvas?
Yes. A vanilla Zustand store is independent of React and Canvas. React components can subscribe with useStore while tests and server code use getState and subscribe directly.
When should game systems use an event bus instead of a store?
Use a typed event for a fact that happened and a store for state that must be queried later. Events coordinate systems; stores answer what is true now.
Should multiplayer authoritative state use the same client store?
The client may project authoritative snapshots into a local store, but the server remains the source of truth. Client persistence must not grant authority over competitive or economic state.
Place the state boundary inside a complete R3F architecture
Connect stores, events, domain engines, the scene, platform adapters, and tests.
Read the React Three Fiber RPG architecture guide →