.md

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.

PropertyBehavior
Writepersist-on-write, per-leaf delta frames, batched fsync
Readin-memory reactive proxy — native only on hydrate
Arraysstable-id collections — push/splice persist element ops, not the array
Big valuesauto-blob: large subtrees serialize as one frame instead of leaf recursion
Migrationversion + migrate(old) hook runs before hydration completes
Websame 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];
MethodReturns
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
The kitchen-sink's Store tab renders all of these live — it's the fastest way to build intuition for what a write actually costs.

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 kindHot reloadApp restart
createSignalresetsresets
createHotStatesurvivesresets
Router stacksurvivesresets
createSkalStoresurvivessurvives