Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/geist-mono": "^5.2.8",
"@ilha/router": "^0.8.6",
"@ilha/store": "^0.7.2",
"@ilha/store": "^0.7.3",
"areia": "^0.1.36",
"dedent": "^1.7.2",
"ilha": "^0.9.2",
Expand Down
52 changes: 52 additions & 0 deletions apps/website/src/pages/(content)/guide/libraries/store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,58 @@ persist(cartStore, "cart");

Call the returned unsubscribe before `store.dispose()` for per-island stores.

## URL persistence — `persistQuery(store, options?)`

`persist`'s sibling for the query string, from the `@ilha/store/query` 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. Prior art: [nuqs](https://nuqs.dev) for React.

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 — on the server and the client.

```ts
import { z } from "zod";
import { store } from "@ilha/store";
import { persistQuery } from "@ilha/store/query";

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"` (e.g. push for `page`, replace for `q`) |
| `debounce` | `0` | Coalesce URL writes (ms) — for per-keystroke bound inputs. A push flushes a pending replace first |
| `omitDefaults` | `true` | Drop params whose value equals the store default (`""` counts as equal to a `""` default) |
| `navigate` | auto-detect | Injected URL writer; without `@ilha/router` installed, 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 (`z.coerce.number()`). For arrays, dates, etc., give the key a `{ serialize, deserialize }` codec in `params`.

## SSR — `dehydrate()` / `hydrate()`

Stores are module-level singletons, so on a concurrent SSR server they must **not** be written during a render — request A's data would leak into request B. Instead, state travels the same way ilha island state does: serialized into the HTML, then seeded on the client.
Expand Down
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions packages/store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,35 @@ persist(cartStore, "cart");

Call the returned unsubscribe before `store.dispose()` for per-island stores.

## URL persistence — `persistQuery(store, options?)`

`persist`'s sibling for the query string ([nuqs](https://nuqs.dev)-style), from the `@ilha/store/query` entry point. Each state key maps to **its own search param** (`?q=boots&page=2`) — shareable, reload-safe, back/forward-friendly. Writes go through `@ilha/router`'s `navigate()` (auto-detected, or injected via `options.navigate`) so loaders re-run; the URL seeds the store on init (schema-coerced/validated, invalid params degrade to defaults); back/forward syncs back into the store without echo loops. With `omitDefaults` (default), default-valued params are dropped from the URL.

```ts
import { z } from "zod";
import { store } from "@ilha/store";
import { persistQuery } from "@ilha/store/query";

const filters = store(
z.object({
q: z.string().default(""),
page: z.coerce.number().int().min(1).default(1),
}),
).build();

persistQuery(filters, { debounce: 250 });
```

| 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); a push flushes a pending debounced replace first |
| `omitDefaults` | `true` | Drop params whose value equals the store default |
| `navigate` | auto-detect | Injected URL writer; without `@ilha/router` installed, falls back to the History API with a dev warning |

Returns an unsubscribe (flushes any pending debounced write). No-op on the server — loaders reading `ctx.url.searchParams` remain the way to consume query state there. See the [store guide](https://ilhajs.dev/guide/libraries/store) for the full filter-bar walkthrough.

## SSR — `dehydrate()` / `hydrate()`

Stores are module-level singletons, so on a concurrent SSR server they must **not** be written during a render — request A's data would leak into request B. Instead, state travels the same way ilha island state does: serialized into the HTML, then seeded on the client.
Expand Down
2 changes: 1 addition & 1 deletion packages/store/happydom.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";

GlobalRegistrator.register();
GlobalRegistrator.register({ url: "http://localhost/" });
11 changes: 10 additions & 1 deletion packages/store/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ilha/store",
"version": "0.7.2",
"version": "0.7.3",
"description": "Shared reactive store for Ilha islands.",
"keywords": [
"forms",
Expand Down Expand Up @@ -41,6 +41,10 @@
"./form": {
"types": "./dist/form.d.ts",
"import": "./dist/form.js"
},
"./query": {
"types": "./dist/query.d.ts",
"import": "./dist/query.js"
}
},
"publishConfig": {
Expand All @@ -52,15 +56,20 @@
"cleanup": "rimraf dist node_modules"
},
"devDependencies": {
"@ilha/router": "0.8.6",
"alien-signals": "3.2.1",
"ilha": "0.9.2",
"zod": "4.4.3"
},
"peerDependencies": {
"@ilha/router": ">=0.8.6",
"alien-signals": "^3.2.1",
"ilha": ">=0.9.2"
},
"peerDependenciesMeta": {
"@ilha/router": {
"optional": true
},
"ilha": {
"optional": true
}
Expand Down
Loading
Loading