State Management
Lattice uses a single Zustand store for all application state. This page documents the store structure, key actions, and the reasoning behind the state management architecture.
Why Zustand
Three.js applications have a specific state management challenge: the render loop reads state 60 times per second, and state updates can originate from UI interactions, animations, or external events (API responses). Traditional React state (useState, useContext) causes unnecessary re-renders in this environment.
Zustand solves this in three ways:
- No re-renders on read: The Three.js animation loop reads state via
getState()(a synchronous function call), not hooks. This means reading state does not subscribe the component to re-renders. - Selective subscriptions: React components subscribe to specific state slices via selectors (
useGraphStore(s => s.selectedNodeId)), so they only re-render when their slice changes. - External updates: The animation loop can update state (e.g., activation levels) without causing React to re-render the entire component tree.
Other state management options (Redux, Jotai, MobX) would work, but Zustand has the smallest API surface, requires no providers or wrappers, and is the most common choice in the Three.js React ecosystem.
Store Structure
The store lives in store/graphStore.ts. Its state can be grouped into several categories:
Graph Data
nodes: NodeData[] // All 700 nodes with positions
edges: EdgeData[] // All 2,796 edges
nodeMap: Map<string, NodeData> // Fast lookup by IDLoaded once at startup from the JSON files. Never modified at runtime.
Selection State
selectedNodeId: string | null // Currently clicked node
hoveredNodeId: string | null // Currently hovered node
navigationHistory: string[] // Stack of previously selected nodesselectedNodeId drives the InfoPanel, edge highlighting, and activation animation. hoveredNodeId drives the tooltip and hover highlight. navigationHistory enables the back button and Backspace key navigation.
Activation State
activations: Map<string, ActivationState> // Active firing nodesEach entry tracks:
startTime: When the activation began (performance.now() timestamp).level: Current activation level (0.0 to 1.0), computed from elapsed time.source: What triggered the activation (click, oracle, idle, cascade).
The animation loop reads this map every frame and uses it to compute node colors and bloom intensity.
Discipline State
activeDisciplines: Set<string> // Currently toggled disciplines
highlightedEdgeType: string | null // Spotlighted edge typeOracle State
oracleResults: OracleResult | null // Current Oracle response
oracleLoading: boolean // Whether a query is in progress
oracleError: string | null // Error message, if anyCamera State
flyToNodeId: string | null // Node to fly camera toward
synapseMode: boolean // Whether Synapse Mode is active
synapseFlyTo: string | null // Next node in Synapse Mode navigationKey Actions
selectNode(id: string)
- Sets
selectedNodeIdto the given ID. - Pushes the ID onto
navigationHistory. - Triggers an activation for the node (adds entry to
activations). - Triggers secondary activations for connected nodes (lower intensity, slight delay).
navigateToNode(id: string)
Like selectNode, but also sets flyToNodeId to trigger a camera transition. Used by the InfoPanel when clicking a connected model.
goBack()
- Pops the last entry from
navigationHistory. - Sets
selectedNodeIdto the new top of the stack (or null if empty). - Sets
flyToNodeIdto trigger camera return.
fireNode(id: string)
Triggers the activation sequence for a node without selecting it. Used by idle firing and cascade effects.
toggleDiscipline(name: string)
Adds or removes a discipline from activeDisciplines. When added, all nodes in that discipline receive a visual overlay in their discipline color.
highlightEdgeType(type: string | null)
Sets highlightedEdgeType. When set, only edges of that type are visually emphasized across the entire graph.
setOracleResults(results: OracleResult)
Stores the Oracle response and triggers activations for all 15 recommended models.
Reading State in the Render Loop
The Three.js animation loop (in LatticeScene.tsx) reads state differently from React components. Instead of using the useGraphStore hook (which subscribes to re-renders), it calls:
const state = useGraphStore.getState();This returns a snapshot of the current state synchronously, without subscribing to changes. The animation loop calls this once per frame and uses the snapshot throughout the frame's computations:
function animate() {
const state = useGraphStore.getState();
// Update activation levels
for (const [id, activation] of state.activations) {
const elapsed = performance.now() - activation.startTime;
const level = computeDecay(elapsed);
// ... update instance colors
}
// Update particles
// ... read edge data and particle positions
// Render
composer.render();
requestAnimationFrame(animate);
}Consolidating getState() into a single call per frame is a performance optimization. Each getState() call has trivial overhead individually, but calling it hundreds of times per frame (once per node, once per edge, etc.) adds up.
Reading State in React Components
UI components (InfoPanel, OraclePanel, SearchBar, etc.) use the standard Zustand hook with selectors:
// Good: subscribes only to selectedNodeId changes
const selectedId = useGraphStore(state => state.selectedNodeId);
// Bad: subscribes to ALL state changes (causes excessive re-renders)
const store = useGraphStore();Selectors ensure that a component only re-renders when the specific state it depends on changes. The InfoPanel re-renders when selectedNodeId changes, but not when hoveredNodeId changes (the Tooltip handles that separately).
No Prop Drilling
In a typical Three.js React application, state flows through many layers: Canvas > Scene > Group > Mesh > Material. Passing callbacks and state through all these layers via props creates tight coupling and verbose code.
Lattice avoids this entirely. Any component at any depth can read or update state by importing useGraphStore. The Zustand store acts as a global state bus.
This is not the same as unstructured global state (the kind that causes bugs in large applications). The store has a defined shape, typed actions, and selectors that limit subscription scope. It is global in access but structured in design.
State Persistence
The Zustand store is ephemeral -- it resets on page reload. Only a few pieces of state persist across sessions:
- Force layout positions: Stored in localStorage by the layout computation module (not by Zustand).
- API key: Stored in localStorage by the Oracle hook.
- Onboarding dismissed: Stored in localStorage by the OnboardingHints component.
There is no Zustand persistence middleware. The store always starts fresh, and the graph data is re-fetched from static files on each page load.
TypeScript Types
All state types are defined in lib/graph/types.ts:
interface NodeData {
id: string;
label: string;
discipline: string;
summary: string;
degree: number;
position: [number, number, number];
}
interface EdgeData {
source: string;
target: string;
type: string;
weight: number;
}
interface ActivationState {
startTime: number;
level: number;
source: 'click' | 'oracle' | 'idle' | 'cascade';
}The store itself is typed with a single interface that covers all state and actions, ensuring type safety across all consumers.