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'sSetStoreFunction):setState('name', 'New'),setState('transform', 'x', 10).bind— a typed path binder (1–4 levels deep) returning aFieldBinding:
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.bindonly 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.