Skip to content

State Management Architecture

Complete guide to state management in FiberPath GUI using Svelte 5 runes.

Overview

FiberPath GUI manages state with Svelte 5 reactive classes (runes), not a store library. Each concern is a class in src/state/*.svelte.ts exported as an app-wide singleton. Components import a singleton and read its $state/$derived fields directly; reads inside a component or a $derived subscribe to exactly the values touched, so updates are fine-grained without selectors.

The state modules

Singleton Module Responsibility
projectSession project-session.svelte.ts The open project: persisted ProjectDocument + transient session
uiState ui-state.svelte.ts Shell UI (active workspace, panel/drawer visibility, open dialog)
machineSession machine-session.svelte.ts Marlin connection, streaming, manual control, log
previewSession preview-session.svelte.ts Toolpath preview generation (plan → PNG) with stale-request guard
notifications notifications.svelte.ts Transient toast notifications
theme theme.svelte.ts Theme preference + system resolution
cliHealth cli-health.svelte.ts Backend/CLI health polling

Logic that used to live in React custom hooks now lives in these modules, in plain service functions (src/services/*), or in component event handlers.

Persisted vs. transient: ProjectSession

The biggest design point is the split between what round-trips to a .wind file and what is session-only.

ProjectDocument (persisted)

ProjectDocument (src/types/document.ts) is a plain domain type — no Svelte — so the tsc-checked modules (converters, services) and the runes session can share it. It is exactly the .wind payload:

export interface ProjectDocument {
  mandrel: Mandrel; // { diameter, wind_length }
  tow: Tow; // { width, thickness }
  layers: Layer[];
  defaultFeedRate: number;
}

No filePath, isDirty, or selection lives in the document.

Session wrapper (transient)

export class ProjectSession {
  document = $state<ProjectDocument>(createEmptyDocument());
  filePath = $state<string | null>(null);
  selectedLayerId = $state<string | null>(null);
  validationErrors = $state<UiValidationErrors>({});

  /** Bumped on every document mutation; compared against savedRevision. */
  revision = $state(0);
  savedRevision = $state(0);

  readonly isDirty = $derived(this.revision !== this.savedRevision);

  readonly selectedLayer = $derived(
    this.document.layers.find((l) => l.id === this.selectedLayerId) ?? null,
  );
}

export const projectSession = new ProjectSession();

Revision-based dirty tracking

Instead of setting isDirty: true in every action (easy to forget), dirtiness is derived: each document mutation bumps revision, and isDirty is revision !== savedRevision. Saving calls markSaved(), which sets savedRevision = revision. Loading a document resets both to 0.

markSaved() {
  this.savedRevision = this.revision;
}

loadDocument(document: ProjectDocument, filePath: string | null = null) {
  this.document = document;
  this.filePath = filePath;
  this.selectedLayerId = null;
  this.validationErrors = {};
  this.revision = 0;
  this.savedRevision = 0;
}

Mutating state

Because $state is deeply reactive, methods mutate in place and bump the revision — no spread-and-replace dance.

Update partial state

updateMandrel(patch: Partial<Mandrel>) {
  Object.assign(this.document.mandrel, patch);
  this.revision++;
}

Add to array

addLayer(type: LayerType): string {
  const layer = createLayer(type);
  this.document.layers.push(layer);
  this.selectedLayerId = layer.id;
  this.revision++;
  return layer.id;
}

push is observed directly. The new ID is returned for the UI. Selection is session-only, so changing it alone does not bump the revision.

Remove from array

removeLayer(id: string) {
  const index = this.document.layers.findIndex((l) => l.id === id);
  if (index === -1) return;
  this.document.layers.splice(index, 1);
  if (this.selectedLayerId === id) {
    this.selectedLayerId = this.document.layers[0]?.id ?? null;
  }
  this.revision++;
}

Reorder array

reorderLayers(from: number, to: number) {
  const layers = this.document.layers;
  if (from === to) return;
  const [moved] = layers.splice(from, 1);
  layers.splice(to, 0, moved);
  this.revision++;
}

Usage in components

A component reads the singleton's reactive fields. Wrapping a read in $derived makes it track that value and nothing else.

<script lang="ts">
  import { projectSession } from "../../state/project-session.svelte";

  const mandrel = $derived(projectSession.document.mandrel);
</script>

<input
  value={mandrel.diameter}
  oninput={(e) => projectSession.updateMandrel({ diameter: +e.currentTarget.value })}
/>

Two inputs reading two different fields update independently — the compiler tracks the dependencies, so there is no selector and no shallow-comparison helper to reach for. Derived values like projectSession.selectedLayer and projectSession.isDirty recompute only when their inputs change.

Transient UI state: UiState

Shell chrome that should never be saved lives in its own singleton — what was a scatter of useState flags in the React App:

export class UiState {
  workspace = $state<WorkspaceId>("prepare"); // "prepare" | "machine"
  leftCollapsed = $state(false);
  rightCollapsed = $state(false);
  drawerOpen = $state(false);
  activeDialog = $state<"about" | "diagnostics" | null>(null);

  setWorkspace(id: WorkspaceId) {
    this.workspace = id;
  }
  toggleLeft() {
    this.leftCollapsed = !this.leftCollapsed;
  }
}

export const uiState = new UiState();

Derived state

Prefer $derived over recomputing in markup or methods:

readonly isHealthy = $derived(this.status === "ready");
readonly canStartStream = $derived(Boolean(this.filePath) && this.isConnected);

For one-off display values, deriving inline in the component is fine; for values reused by methods, declare a $derived field on the class.

Testing state

Each singleton's class is exported so tests can instantiate a fresh, isolated instance — no global reset hook needed:

import { describe, it, expect, beforeEach } from "vitest";
import { ProjectSession, createEmptyDocument } from "./project-session.svelte";

describe("ProjectSession", () => {
  let session: ProjectSession;
  beforeEach(() => {
    session = new ProjectSession();
  });

  it("is not dirty until a mutation, then dirty until saved", () => {
    expect(session.isDirty).toBe(false);
    session.updateMandrel({ diameter: 200 });
    expect(session.isDirty).toBe(true);
    session.markSaved();
    expect(session.isDirty).toBe(false);
  });
});

Component tests drive the shared singleton and reset it in beforeEach:

import { projectSession } from "../../state/project-session.svelte";
beforeEach(() => {
  projectSession.newDocument();
});

Common Pitfalls

Reading a singleton field non-reactively

Destructuring a $state field into a local const at the top of <script> captures a snapshot. Wrap reads you want to stay live in $derived (or read projectSession.document.x directly in markup).

Putting transient state in the document

ProjectDocument is the .wind payload. Selection, dirtiness, file path, and UI flags belong on the session/UiState, not in the document.

Forgetting the revision bump

Document mutations must this.revision++ so isDirty and any document-derived preview/validation react. Session-only changes (selection) intentionally do not.

Next Steps