State Management Architecture¶
Complete guide to Zustand state management in FiberPath GUI.
Overview¶
FiberPath GUI uses Zustand for centralized state management with a single store architecture. All project data, UI state, and metadata are managed through projectStore.ts.
Why Single Store?¶
Rationale:
- GUI is project-centric (one project open at a time)
- No complex cross-entity relationships
- Simpler reasoning about state flow
- No store coordination overhead
Alternative Considered: Split stores (projectStore, uiStore, streamStore) were analyzed but rejected because:
- Increased synchronization complexity
- No performance benefit at current scale
- Harder to reason about state dependencies
See fiberpath_gui/docs-old/STORE_SPLITTING_ANALYSIS.md for historical analysis.
Store Structure¶
State Schema¶
interface ProjectState {
// Project data
project: FiberPathProject;
// Project management
loadProject: (project: FiberPathProject) => void;
newProject: () => void;
// Mandrel & Tow
updateMandrel: (mandrel: Partial<Mandrel>) => void;
updateTow: (tow: Partial<Tow>) => void;
// Machine settings
updateDefaultFeedRate: (feedRate: number) => void;
setAxisFormat: (format: "xab" | "xyz") => void;
// Layer operations
addLayer: (type: LayerType) => string;
removeLayer: (id: string) => void;
updateLayer: (id: string, props: Partial<Layer>) => void;
reorderLayers: (startIndex: number, endIndex: number) => void;
duplicateLayer: (id: string) => string;
// UI state
setActiveLayerId: (id: string | null) => void;
// Dirty state
markDirty: () => void;
clearDirty: () => void;
// File metadata
setFilePath: (path: string | null) => void;
}
FiberPathProject Type¶
interface FiberPathProject {
schemaVersion: "1.0";
mandrel: Mandrel;
tow: Tow;
defaultFeedRate: number;
axisFormat: "xab" | "xyz";
layers: Layer[];
activeLayerId: string | null;
isDirty: boolean;
filePath: string | null;
}
Store Creation¶
Definition (src/state/projectStore.ts)¶
import { create } from "zustand";
import { devtools } from "zustand/middleware";
export const useProjectStore = create<ProjectState>()(
devtools(
(set, get) => ({
project: createEmptyProject(),
loadProject: (project) => {
set({ project });
},
newProject: () => {
set({ project: createEmptyProject() });
},
updateMandrel: (mandrel) => {
set((state) => ({
project: {
...state.project,
mandrel: { ...state.project.mandrel, ...mandrel },
isDirty: true,
},
}));
},
// ...more actions
}),
{ name: "ProjectStore" } // DevTools name
)
);
Key Points:
create()()double-call syntax for middlewaredevtools()enables Redux DevTools integrationset()accepts updater function for derived stateget()available for accessing current state in actions
Usage Patterns¶
Component Access¶
❌ Bad: Entire State¶
function PlanForm() {
const state = useProjectStore(); // Re-renders on ANY state change
return <input value={state.project.mandrel.diameter} />;
}
Problem: Component re-renders when unrelated state changes (e.g., active layer).
✅ Good: Shallow Selector¶
import { shallow } from "zustand/shallow";
function PlanForm() {
const mandrel = useProjectStore(
(state) => state.project.mandrel,
shallow
);
return <input value={mandrel.diameter} />;
}
Benefit: Re-renders only when mandrel object changes (reference equality).
✅ Better: Primitive Selector¶
function DiameterInput() {
const diameter = useProjectStore(
(state) => state.project.mandrel.diameter
);
return <input value={diameter} />;
}
Benefit: Re-renders only when diameter value changes.
✅ Best: Multiple Selectors¶
function PlanForm() {
const diameter = useProjectStore((s) => s.project.mandrel.diameter);
const windLength = useProjectStore((s) => s.project.mandrel.windLength);
const updateMandrel = useProjectStore((s) => s.updateMandrel);
return (
<>
<input value={diameter} onChange={(e) => updateMandrel({ diameter: +e.target.value })} />
<input value={windLength} onChange={(e) => updateMandrel({ windLength: +e.target.value })} />
</>
);
}
Benefit: Each input re-renders independently.
Action Patterns¶
Update Partial State¶
updateMandrel: (mandrel: Partial<Mandrel>) => {
set((state) => ({
project: {
...state.project,
mandrel: { ...state.project.mandrel, ...mandrel },
isDirty: true,
},
}));
};
Pattern: Spread existing state + new values. Always mark isDirty.
Add to Array¶
addLayer: (type: LayerType) => {
const newLayer = createLayer(type);
set((state) => ({
project: {
...state.project,
layers: [...state.project.layers, newLayer],
activeLayerId: newLayer.id,
isDirty: true,
},
}));
return newLayer.id;
};
Pattern: Spread existing array + new item. Return new ID for UI.
Remove from Array¶
removeLayer: (id: string) => {
set((state) => {
const layers = state.project.layers.filter((l) => l.id !== id);
const activeLayerId =
state.project.activeLayerId === id
? layers.length > 0
? layers[0].id
: null
: state.project.activeLayerId;
return {
project: {
...state.project,
layers,
activeLayerId,
isDirty: true,
},
};
});
};
Pattern: Filter array + update related state (active selection).
Reorder Array¶
reorderLayers: (startIndex: number, endIndex: number) => {
set((state) => {
const layers = [...state.project.layers];
const [removed] = layers.splice(startIndex, 1);
layers.splice(endIndex, 0, removed);
return {
project: {
...state.project,
layers,
isDirty: true,
},
};
});
};
Pattern: Clone array, mutate clone, replace in state.
Computed Values¶
In Component¶
function LayerCount() {
const layerCount = useProjectStore((s) => s.project.layers.length);
return <div>Total Layers: {layerCount}</div>;
}
When: Simple derivation, used in one place.
In Selector¶
const useLayerCount = () =>
useProjectStore((s) => s.project.layers.length);
function LayerCount() {
const count = useLayerCount();
return <div>Total Layers: {count}</div>;
}
When: Reused across multiple components.
In Store (if complex)¶
getHelicalLayers: () => {
const { project } = get();
return project.layers.filter((l) => l.type === "helical");
};
When: Complex computation, needs store access.
Testing Store¶
Reset Before Each Test¶
import { beforeEach } from "vitest";
import { useProjectStore } from "./projectStore";
beforeEach(() => {
useProjectStore.setState({
project: createEmptyProject(),
});
});
Test Actions¶
it("should update mandrel diameter", () => {
const store = useProjectStore.getState();
store.updateMandrel({ diameter: 200 });
expect(store.project.mandrel.diameter).toBe(200);
expect(store.project.isDirty).toBe(true);
});
Test Derived State¶
it("should update active layer on removal", () => {
const store = useProjectStore.getState();
const layer1Id = store.addLayer("hoop");
const layer2Id = store.addLayer("helical");
store.setActiveLayerId(layer1Id);
store.removeLayer(layer1Id);
expect(store.project.activeLayerId).toBe(layer2Id);
});
DevTools Integration¶
Enable DevTools¶
import { devtools } from "zustand/middleware";
export const useProjectStore = create<ProjectState>()(
devtools(
(set, get) => ({
/* state */
}),
{ name: "ProjectStore" }
)
);
Use in Browser¶
- Install Redux DevTools extension
- Run
npm run tauri dev - Open DevTools → Redux panel
- See all actions and state changes
Benefits:
- Time-travel debugging
- Action replay
- State inspection
- Performance monitoring
Performance Optimization¶
Shallow Comparison¶
import { shallow } from "zustand/shallow";
const mandrel = useProjectStore((s) => s.project.mandrel, shallow);
When: Selecting object/array that recreates on every render.
Memoization¶
import { useMemo } from "react";
function LayerList() {
const layers = useProjectStore((s) => s.project.layers);
const sortedLayers = useMemo(
() => [...layers].sort((a, b) => a.index - b.index),
[layers]
);
return <div>{sortedLayers.map(/* render */)}</div>;
}
When: Expensive computation on store data.
Splitting Selectors¶
// ❌ Bad: One selector for multiple values
const { mandrel, tow } = useProjectStore(
(s) => ({
mandrel: s.project.mandrel,
tow: s.project.tow,
}),
shallow
);
// ✅ Good: Separate selectors
const mandrel = useProjectStore((s) => s.project.mandrel, shallow);
const tow = useProjectStore((s) => s.project.tow, shallow);
Benefit: Independent re-render triggers.
Migration from Redux¶
If coming from Redux:
| Redux | Zustand |
|---|---|
useSelector |
useStore(selector) |
useDispatch |
Store action directly |
mapStateToProps |
Multiple selectors |
combineReducers |
Single store |
| Actions | Methods on store |
| Reducers | set() calls |
| Middleware | Zustand middleware |
| DevTools | devtools() wrapper |
Common Pitfalls¶
❌ Mutating State¶
updateLayer: (id, props) => {
set((state) => {
const layer = state.project.layers.find((l) => l.id === id);
layer.props = { ...layer.props, ...props }; // Mutation!
return state;
});
};
Fix: Create new objects/arrays.
❌ Selecting Too Much¶
const project = useProjectStore((s) => s.project); // Entire project
Fix: Select only what you need.
❌ Missing Dirty Flag¶
updateMandrel: (mandrel) => {
set((state) => ({
project: {
...state.project,
mandrel: { ...state.project.mandrel, ...mandrel },
// Missing isDirty: true
},
}));
};
Fix: Always set isDirty: true on mutations.
Next Steps¶
- CLI Integration - Store → CLI bridge
- Schema Validation - Zod integration
- Testing Guide - Store testing patterns