04core concepts
Persistence
The PersistenceAdapter contract, the localStorage default, and the versioned payload migration.
Uncontrolled persistence is one prop: omit it for localStorage, pass false
for ephemeral, or bring an adapter. Either way, save fires only on committed
changes — the drag engine never touches the store mid-gesture, so persistence
hangs off exactly the commit boundary.
The adapter contract
The semantics are precise. load runs once, from a layout effect after mount —
never on the server — and may return a promise; the seeds render until it
resolves, then the stored copy swaps in. Returning null means "no stored
copy", which keeps the seeds rather than blanking the calendar. save receives
the full next array on every committed change — one write per commit, and a
batch (a paste, a bulk delete) is one commit. clear is optional: reset
calls it when present and falls back to save(seeds) when not.
Hydration safety is why load is deferred: the server snapshot is always the
seeds, so server and first client render agree byte-for-byte, and the
persisted copy arrives in the first effect — sync or async, same path.
The localStorage default
Omit persistence and you get localStorageAdapter() — exported, so you can
also call it yourself with a custom key. It writes a versioned payload
({ v: 2, events }) under STORAGE_KEY ("kloq:events:v2") — or under
<prefix>:events:v2 when the storage prop names another prefix
(eventsStorageKey() returns the key in use). With storage={false} it reads
and writes nothing.
Behind <Kloq> the default adapter also batches its writes: a burst of commits
becomes one write of the latest state about 300 ms after the last of them (and
at least every 2 s while commits keep coming), and anything still queued is
written when the page is hidden or unloaded and when the calendar unmounts. A
custom persistence adapter is never batched — its save is called
synchronously, once per commit.
Every failure path degrades to the seeds: corrupted JSON, private mode, quota. Writes that fail are non-fatal — the change still applies in memory.
Versioned payloads
The payload version lives in the data, not the key. readPayload accepts the
current { v: 2, events } shape and a bare array (the v1 format), and returns
null for anything else — corrupt JSON, a future version written by a newer
build — so the caller keeps its seeds rather than rendering nonsense. A v1
payload had no dates, only week-relative offsets, so migrateV1 maps it onto
the Monday-start week containing the migration date and the result is written
back as v2 immediately — the reinterpret happens once, not sliding forward a
week at a time.
One guard worth knowing about: when a non-empty payload survives validation
with nothing left, readPayload returns null instead of []. "We couldn't
read any of your data" is not the same as "you have no data" — returning an
empty array would render a blank calendar that then saves itself over the
original on the next commit. A genuinely emptied calendar round-trips fine.
| export | type · description |
|---|---|
localStorageAdapter(key?) | (key?: string) => PersistenceAdapterThe default adapter. Omit |
STORAGE_KEY | "kloq:events:v2"Where the default adapter writes under the default prefix. |
eventsStorageKey(prefix?) | (prefix?: string) => stringThe events key for a prefix; the current one when omitted. |
PAYLOAD_VERSION / PayloadV2 | 2The versioned envelope: |
readPayload(raw) | ReadResult | nullReads any known version; |
migrateV1(raw, now?) | CalEvent[]The lossy-but-deliberate v1 mapping onto the current week. |
isV2Event(x) | x is CalEventPer-event validation — id present, both instants parseable, all-day instants date-only. |
buildSeedEvents(now?) / SEED_EVENTS | CalEvent[]The sample week, anchored on the week containing today. |
newEventId() | () => stringThe id generator the built-in create paths use. |
An adapter is not a sync engine
An adapter is deliberately dumb — full-array in, full-array out. That is
exactly right for localStorage, an ephemeral demo, or a single-user document.
For a real backend you usually want the change-level seam instead: onCommit
hands you each committed change with enough context to make an API call and
reverts the UI if you reject it. The two compose — the next page is about that
seam.