Persistence and query
persist to localStorage, sync state to the URL with persistQuery, and cache async derived fetches with query().
Keep store state durable or addressable by URL, and cache and dedupe async derived fetches.
persist(store, key, options?)
Keeps a store in sync with localStorage (or any getItem/setItem backend):
- Hydrates on call: reads
keyand merges the stored patch viasetState— middleware runs and schema stores validate, so corrupt or stale-shaped payloads are rejected instead of applied. - Writes through on every commit.
- Cross-tab sync: mirrors writes from other tabs via
storageevents (defaultlocalStoragebackend only; disable withcrossTab: false).
Returns an unsubscribe. No-op on the server. Import from @ilha/store or @ilha/store/persist.
import { store, persist } from "@ilha/store";
// or: import { persist } from "@ilha/store/persist";
const cartStore = store({ items: [] as string[] }).build();
persist(cartStore, "cart");
| Option | Default | Description |
|---|---|---|
storage |
window.localStorage |
Any { getItem, setItem } backend |
crossTab |
true |
Apply storage events from other tabs |
serialize |
JSON.stringify |
State → string |
deserialize |
JSON.parse |
String → patch (validated on schema stores) |
Call the returned unsubscribe before store.dispose() for per-island stores.
persistQuery(store, options?)
persist’s sibling for the query string, from the @ilha/store/persist entry point. Instead of one serialized blob, each state key gets its own search param (?q=boots&page=2) — shareable, reload-safe, back/forward-friendly.
The canonical use case is a filter bar. UI state that drives data loading (search text, sort, page) belongs in the URL, because @ilha/router loaders re-run on every navigation and read ctx.url.searchParams. The cycle is one-way: store → URL → loader → render. The store is the write path for the controls; the loader stays the read path for data.
import { z } from "zod";
import { store } from "@ilha/store";
import { persistQuery } from "@ilha/store/persist";
export const filters = store(
z.object({
q: z.string().default(""),
page: z.coerce.number().int().min(1).default(1),
sort: z.string().default(""),
}),
).build();
persistQuery(filters, { debounce: 250 });
// The search input binds to the store; persistQuery debounces the URL write.
<input type="search" bind:value={filters.q} />
// The loader is the read path — it re-runs automatically on every query write.
export const load = ({ url }) =>
searchProducts(url.searchParams.get("q") ?? "");
What it does:
- URL is the source of truth on init: owned params are parsed from
location.searchand written throughsetState, so schema stores coerce and validate (?page=3→3); invalid values (?page=banana) degrade to the key’s default — never throw. - Writes through the router: store commits navigate via
@ilha/router’snavigate()(auto-detected; not rawhistory.replaceState), so loaders re-run and route signals stay live. Params it doesn’t own are preserved — several stores can share one URL. - Syncs back: back/forward and link navigations that change owned params write into the store, without echoing another navigation.
- Clean URLs: with
omitDefaults(default on), a param is removed when its value equals the store default — no?page=1&q=noise.
Returns an unsubscribe (flushes any pending debounced write). No-op on the server.
| Option | Default | Description |
|---|---|---|
params |
all state keys | Keys to persist: { q: "search" } renames, { tags: { serialize, deserialize } } adds a codec |
history |
"replace" |
"replace", "push", or (changedKeys) => "push" | "replace" |
debounce |
0 |
Coalesce URL writes (ms) — for per-keystroke bound inputs. |
omitDefaults |
true |
Drop params whose value equals the store default. |
navigate |
auto-detect | Injected URL writer; without @ilha/router, falls back to the History API with a dev warning. |
Values serialize with String() by default; deserialization hands the raw string to setState, so schema stores coerce it. For arrays, dates, etc., give the key a { serialize, deserialize } codec in params.
Rule of thumb: if the data is addressable by URL and should survive reload/back-forward, use persist / persistQuery plus a loader. If the data is cross-island shared state with no URL semantics, use query() inside a .derived().
query() — data-fetching cache
@ilha/store/query adds cache, deduplication, cross-store sharing, and invalidation for async .derived() fetches — on both store builders and island .derived(). The host envelope is unchanged.
import { ilha } from "ilha";
import { query } from "@ilha/store/query";
async function fetchUser(id: number, signal: AbortSignal) {
return { name: "Ada" };
}
// ---cut---
const UserCard = ilha
.state("id", 1)
.derived("user", async ({ state, signal }) =>
query({
key: ["user", state.id()],
fn: () => fetchUser(state.id(), { signal }),
staleTime: 30_000,
}),
)
.render(
({ derived }) =>
/* use derived.user.loading / derived.user() */ null,
);
Read the reactive inputs that should re-fetch before building key. query() itself does no tracking. Capture the derived signal in fn’s closure — query() does not thread the signal in.
| Option | Default | Description |
|---|---|---|
key |
required | Serialisable array identifying the query |
fn |
required | () => Promise<T> — perform the fetch |
staleTime |
0 |
Freshness window (ms); within it, re-runs return the cached value |
gcTime |
300_000 |
How long to retain an entry after its last subscriber drops |
cache |
defaultQueryCache |
Explicit QueryCache instance (e.g. per-request isolation on SSR) |
QueryCache exposes invalidate(keyParts), invalidatePrefix(prefix), clear(), and size. Prefer primitive key segments only. store.dispose() aborts in-flight runs and decrements subscribers.
Fetch failures land in the derived’s .error envelope. They do not route through .onError() (same as plain-Promise async deriveds). dehydrate() / hydrate() transfer raw state only — never QueryCache entries.
Related
| Topic | Guide |
|---|---|
| Transfer to the client | SSR |
| Derived async fetches | Derived state and actions |
| Island query cache | Derived |