---
title: Persistence and query
description: 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):

1. **Hydrates** on call: reads `key` and merges the stored patch via `setState` — middleware runs and schema stores validate, so corrupt or stale-shaped payloads are rejected instead of applied.
2. **Writes through** on every commit.
3. **Cross-tab sync**: mirrors writes from other tabs via `storage` events (default `localStorage` backend only; disable with `crossTab: false`).

Returns an unsubscribe. No-op on the server. Import from `@ilha/store` or `@ilha/store/persist`.

```ts twoslash
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.

```ts twoslash
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 });
```

```tsx
// The search input binds to the store; persistQuery debounces the URL write.
<input type="search" bind:value={filters.q} />
```

```ts
// 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:

1. **URL is the source of truth on init**: owned params are parsed from `location.search` and written through `setState`, so schema stores coerce and validate (`?page=3` → `3`); invalid values (`?page=banana`) degrade to the key's default — never throw.
2. **Writes through the router**: store commits navigate via `@ilha/router`'s `navigate()` (auto-detected; **not** raw `history.replaceState`), so loaders re-run and route signals stay live. Params it doesn't own are preserved — several stores can share one URL.
3. **Syncs back**: back/forward and link navigations that change owned params write into the store, without echoing another navigation.
4. **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()`](/guide/island/derived). The host envelope is unchanged.

```ts twoslash
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.

:::warning[SSR and module-level stores]
Do not build a `query()`-backed derived keyed on request-scoped data inside a module-level singleton store on the server. Async deriveds start their first run at `.build()`, and `defaultQueryCache` is a module singleton — the first request's result would leak into later requests.

For request-scoped data, prefer `@ilha/router` loaders and feed results into the store via `hydrate()` on the client. Pass a fresh `QueryCache` via `query({ cache })` when you need request-isolated server caching.
:::

## Related

| Topic                  | Guide                                                               |
| ---------------------- | ------------------------------------------------------------------- |
| Transfer to the client | [SSR](/guide/store/ssr)                                             |
| Derived async fetches  | [Derived state and actions](/guide/store/derived-state-and-actions) |
| Island query cache     | [Derived](/guide/island/derived)                                    |
