Skip to content

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 middleware
  • devtools() enables Redux DevTools integration
  • set() accepts updater function for derived state
  • get() 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

  1. Install Redux DevTools extension
  2. Run npm run tauri dev
  3. Open DevTools → Redux panel
  4. 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