State & the Store
Ephemeral state is just Solid. Persistent state is createSkalStore — a reactive deep-object store backed by a log-structured native engine, hydrated before your first render.
Ephemeral: plain Solid
const [query, setQuery] = createSignal(''); const results = createMemo(() => search(query()));
Signals, memos, effects, resources — everything from solid-js works untouched. There is no bridge-specific state API to learn for in-memory state.
Persistent: createSkalStore
import { createSkalStore } from 'skal/store'; export const db = createSkalStore({ name: 'store', version: 1, initial: { counter: 0, settings: { theme: 'dark', locale: 'en' }, todos: [], // arrays are stable-id collections scratch: { note: '' }, }, config: { scratch: { persist: false }, // memory-only subtree todos: { lazy: true }, // hydrate on first read, LRU-evicted }, });
Reads and writes are direct object access — the store is a deep reactive proxy, so any widget reading db.counter re-renders when it changes:
<Text label={`Count: ${db.counter}`} /> <Button label="+1" onClick={() => db.counter++} /> db.settings.theme = 'light'; // persisted on write — one delta frame db.todos.push({ text: 'ship it' }); // collection ops persist per element
The native engine
Persistence is not JSON-to-disk. Writes append per-leaf delta frames to a log-structured store implemented inside libskal (segments, compaction, a keydir index) — a write is sub-millisecond and never blocks a frame. On launch, the engine pre-warms on a background thread while your bundle evaluates, so hydration is already done when the first render reads the store.
| Property | Behavior |
|---|---|
| Write | persist-on-write, per-leaf delta frames, batched fsync |
| Read | in-memory reactive proxy — native only on hydrate |
| Arrays | stable-id collections — push/splice persist element ops, not the array |
| Big values | auto-blob: large subtrees serialize as one frame instead of leaf recursion |
| Migration | version + migrate(old) hook runs before hydration completes |
| Web | same API, in-memory backend (persistence lands with the IndexedDB backend) |
Schema versioning
createSkalStore({ name: 'store', version: 2, initial: { ... }, migrate(old, fromVersion) { if (fromVersion < 2) old.settings.locale ??= 'en'; return old; }, });
The control handle
Data lives on the proxy; operations on the store itself live behind the STORE symbol — deliberately off to the side so they can never collide with your keys:
import { createSkalStore, STORE } from 'skal/store'; const ctl = db[STORE];
| Method | Returns |
|---|---|
ctl.backendKind() | 'native' on device · 'memory' on web/tests — branch UI on it if you must |
ctl.version() | the schema version currently on disk |
ctl.pending() | writes staged but not yet flushed (watch it settle to 0) |
ctl.flushes() | flush count this session — a cheap write-amplification gauge |
ctl.engineStats() | { records, segments } from the native engine |
ctl.initTiming() | { total, open, migrate, hydrate, records } in ms — how boot was spent |
Recipes
Collections with <For>
import { For } from 'solid-js'; <Column gap={8}> <For each={db.todos}> {(todo) => ( <ListTile title={todo.text} onTap={() => todo.done = !todo.done} /> // mutate the element directly )} </For> </Column> db.todos.push({ text: 'ship it', done: false }); // one element frame persisted db.todos.splice(i, 1); // deletion persisted by stable id
Elements have stable identities, so <For> reconciles moves without re-rendering rows, and each element persists independently — pushing item 10,001 doesn't rewrite the other 10,000.
Derived values
const remaining = createMemo(() => db.todos.filter(t => !t.done).length); <Text label={`${remaining()} left`} />
Memos subscribe with the same fine granularity as widgets — derive freely, persist only source data.
Drafts that shouldn't outlive a crash
config: { scratch: { persist: false } } // in createSkalStore
db.scratch.note = draft(); // reactive, shared, never on disk
Resetting a subtree
db.todos.splice(0); // clear a collection Object.assign(db.settings, { theme: 'dark', locale: 'en' }); // reset leaves
Guarantees & limits
- Crash safety: the engine is log-structured — a write appends; recovery replays the log. A crash can lose at most the last unflushed batch (
ctl.pending()), never corrupt what's on disk. - Compaction is automatic. Segments fold in the background; you never vacuum.
- Big values are fine, big blobs aren't. Large subtrees auto-serialize as single frames, but the store is for app state — put images and downloads in files and store the path.
- It's a store, not a database. No queries, no indexes, no joins — reads are plain object access at memory speed. If you need SQL, wrap a pub.dev database and keep the store for state.
What survives what
| State kind | Hot reload | App restart |
|---|---|---|
createSignal | resets | resets |
createHotState | survives | resets |
| Router stack | survives | resets |
createSkalStore | survives | survives |