Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs state.md
4.1 kB · 124 lines
Markdown
at main

State #

Two small state helpers. Neither is required — they remove boilerplate, they don't impose an architecture.


createEditorStore #

A reactive source of truth for your editor's settings: a Solid createStore under the hood, with optional debounced localStorage persistence and a typed bind helper that produces { value, onChange } pairs for fields.

interface Settings {
  name: string;
  transform: { x: number; y: number };
  snap: boolean;
}

const [state, setState, bind] = createEditorStore<Settings>({
  defaults: { name: 'Untitled', transform: { x: 0, y: 0 }, snap: true },
  persist: { key: 'my-editor', version: 1 },
});

It returns a tuple [state, setState, bind]:

  • state — a Solid store. Read reactively: state.name, state.transform.x.
  • setState — the store setter (Solid's SetStoreFunction): setState('name', 'New'), setState('transform', 'x', 10).
  • bind — a typed path binder (1–4 levels deep) returning a FieldBinding:
bind('name')              // → { value: () => state.name, onChange: (v) => setState('name', v) }
bind('transform', 'x')    // nested path, fully typed

Spread a binding straight into any field — it fills value + onChange:

<TextField name="Name" {...bind('name')} />
<NumberField name="X" {...bind('transform', 'x')} />
<BooleanField name="Snap" {...bind('snap')} />

Options #

Option Type Notes
defaults T Initial shape. You own T.
persist? { key, version, debounceMs? } Opt into localStorage.

persist:

  • key — localStorage key.
  • version — bump it to discard a stored blob whose shape changed (no migration — discard-on-mismatch).
  • debounceMs — write debounce (default 150ms).

On load, a stored blob is deep-merged over defaults, so adding a new field to defaults later fills in for old stored blobs without a version bump (the merge supplies the missing key). Bump version only when an existing field's shape changes incompatibly.

Notifying your world #

There's no subscribe API — it's Solid. React to changes with an effect:

createEffect(() => engine.setSnap(state.snap));
createEffect(() => engine.setPosition(state.transform.x, state.transform.y));

This is the whole point: one fact lives in the store, the field reads/writes it via bind, and your world reads it via createEffect — no hand fan-out.

It is a store + helper, not a schema or registry. Fields stay hand-composed; you own T's shape. bind only removes wiring boilerplate.


createPersistedToggle #

A createSignal-shaped [value, setValue] whose writes mirror to localStorage. For chrome/layout flags the library doesn't render itself — chiefly split-view stage visibility, where your app owns the mount decision but the state belongs with the other stage-* flags (not the versioned editor-content store).

const [secondaryShown, setSecondaryShown] = createPersistedToggle('split:secondary', false);

<Show when={secondaryShown()}>
  <SecondaryStage />
</Show>

The returned pair can also feed a controlled collapsed/onToggle slot directly:

<EditorPanels
  rightCollapsed={() => !secondaryShown()}
  onRightToggle={(next) => setSecondaryShown(!next)}
  ...
/>

It shares the same localStorage kernel as Section/panel collapse, and it never traps the user — a read/parse failure is swallowed and falls back to the default.


Why the split #

A panel collapse is library-owned (the panel stays mounted, shrinks to a strip), so the library persists it for you via persistKey. A stage's visibility is app-owned structure (a canvas tears down its GL context on unmount, so only your app can own that mount decision) — but the flag still belongs with the other layout state, which is what createPersistedToggle is for.

Editor content (the settings the user edits) goes in createEditorStore (it's versioned). Chrome/layout flags go in createPersistedToggle or the components' own persistKey.