@ilha/store 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,8 @@ store.setState({ count: 1 });
38
38
  store.getState(); // → { count: 1 }
39
39
  ```
40
40
 
41
+ For consuming a store inside an island, jump to [Usage with Ilha Islands](#usage-with-ilha-islands).
42
+
41
43
  ---
42
44
 
43
45
  ## API
@@ -87,7 +89,7 @@ store.setState((s) => ({ count: s.count + 1 }));
87
89
 
88
90
  ### `store.getState()`
89
91
 
90
- Returns the current state snapshot.
92
+ Returns the current state snapshot. Non-reactive — use `select` for reactive reads inside an island.
91
93
 
92
94
  ```ts
93
95
  store.getState(); // → { count: 5 }
@@ -105,9 +107,32 @@ store.getInitialState(); // → { count: 0 }
105
107
 
106
108
  ---
107
109
 
110
+ ### `store.select(selector)` — reactive accessor
111
+
112
+ Projects a slice of state into a **signal-shaped accessor**: a `() => S` function that reads the current value when called, and tracks reactivity automatically inside any ilha tracking scope (`.render()`, `.derived()`, `.effect()`).
113
+
114
+ ```ts
115
+ const store = createStore({ count: 0, name: "Ada" });
116
+
117
+ const count = store.select((s) => s.count);
118
+ const name = store.select((s) => s.name);
119
+
120
+ count(); // → 0 (read)
121
+ store.setState({ count: 5 });
122
+ count(); // → 5 (reflects the change)
123
+ ```
124
+
125
+ The returned accessor has the same shape as `signal()` and `context()` from `ilha` core, so it composes naturally with `html\`\``interpolation and`.bind()`. See [Usage with Ilha Islands](#usage-with-ilha-islands) below for the full pattern.
126
+
127
+ The slice is memoized — accessors only notify dependents when the selected value changes (compared with `Object.is`). Setting `count` to its current value, or mutating an unrelated field, does not trigger downstream re-runs.
128
+
129
+ > **Hoist `select` calls out of render functions.** Each call allocates a fresh `computed`. Define selectors at module scope or inside an island's closure setup — not inside the `.render()` callback itself.
130
+
131
+ ---
132
+
108
133
  ### `store.subscribe(listener)`
109
134
 
110
- Subscribes to all state changes. The listener receives the next and previous state. Returns an unsubscribe function.
135
+ Subscribes to all state changes. The listener receives the next and previous state. Returns an unsubscribe function. Use this for **imperative** subscriptions outside an island scope (e.g. a WebSocket handler, `localStorage` sync); inside an island, prefer `select`.
111
136
 
112
137
  ```ts
113
138
  const unsub = store.subscribe((state, prev) => {
@@ -161,13 +186,13 @@ store.bind(
161
186
 
162
187
  ## Usage with Ilha Islands
163
188
 
164
- The most common pattern is reading the store inside an island's `.effect()` and calling `store.subscribe()` to drive reactive re-renders:
189
+ `select` returns the same `() => T` accessor shape as `signal()` and `context()` from `ilha` core. That means store-backed state composes with islands the same way context signals do — read it inside `html\`\``and the surrounding render scope subscribes automatically. No`.effect()`plumbing, no manual`subscribe` wiring.
165
190
 
166
191
  ```ts
167
192
  import { createStore } from "@ilha/store";
168
193
  import ilha, { html } from "ilha";
169
194
 
170
- export const cartStore = createStore({ items: [] as string[] }, (set, get) => ({
195
+ const cartStore = createStore({ items: [] as string[] }, (set, get) => ({
171
196
  add(item: string) {
172
197
  set({ items: [...get().items, item] });
173
198
  },
@@ -176,23 +201,53 @@ export const cartStore = createStore({ items: [] as string[] }, (set, get) => ({
176
201
  },
177
202
  }));
178
203
 
179
- export const CartIsland = ilha
180
- .state("items", cartStore.getState().items)
181
- .effect(({ state }) => {
182
- return cartStore.subscribe(
183
- (s) => s.items,
184
- (items) => state.items(items),
185
- );
204
+ const items = cartStore.select((s) => s.items);
205
+ const itemCount = cartStore.select((s) => s.items.length);
206
+
207
+ export const CartBadge = ilha.render(() => html`<span>${itemCount()}</span>`);
208
+
209
+ export const CartList = ilha.render(
210
+ () => html`
211
+ <ul>
212
+ ${items().map((item) => html`<li>${item}</li>`)}
213
+ </ul>
214
+ `,
215
+ );
216
+ ```
217
+
218
+ Both islands stay in sync automatically. `CartBadge` only re-renders when `items.length` changes; `CartList` only re-renders when the array itself changes.
219
+
220
+ ### Inside `.derived()` and `.effect()`
221
+
222
+ `select` accessors work in any tracking scope — the same dependency tracking that powers `.state()` reads applies:
223
+
224
+ ```ts
225
+ const userStore = createStore({ id: 1, name: "Ada" });
226
+ const userId = userStore.select((s) => s.id);
227
+
228
+ const Profile = ilha
229
+ .derived("user", async ({ signal }) => {
230
+ const res = await fetch(`/api/users/${userId()}`, { signal });
231
+ return res.json();
186
232
  })
187
- .render(
188
- ({ state }) => html`
189
- <ul>
190
- ${state.items().map((item) => html`<li>${item}</li>`)}
191
- </ul>
192
- `,
193
- );
233
+ .effect(() => {
234
+ document.title = `User: ${userStore.select((s) => s.name)()}`;
235
+ // ^ inline is fine here — `.effect()` runs once per dep change,
236
+ // not on every render. For hot paths, hoist the `select`.
237
+ })
238
+ .render(({ derived }) => html`<p>${derived.user.value?.name ?? "…"}</p>`);
194
239
  ```
195
240
 
241
+ When `userId()` changes, the derived re-fetches automatically.
242
+
243
+ ### When to use `select` vs `subscribe`
244
+
245
+ | Use `select` | Use `subscribe` |
246
+ | ----------------------------------------------------- | ------------------------------------------------------ |
247
+ | Reading store state inside an island | Imperative side effects outside an island |
248
+ | Driving `.derived()` or `.effect()` dependencies | Syncing to `localStorage`, WebSockets, analytics |
249
+ | Anywhere you'd reach for `context()` from `ilha` core | Anywhere you'd reach for `effect()` from alien-signals |
250
+
196
251
  ---
197
252
 
198
253
  ## Forms — `@ilha/store/form`
@@ -271,37 +326,38 @@ const formStore = createStore({ errors: {} as FormErrors }, (set) => ({
271
326
  },
272
327
  }));
273
328
 
329
+ const errors = formStore.select((s) => s.errors);
330
+
274
331
  export default ilha
275
332
  .on("form@submit", ({ event }) => {
276
333
  event.preventDefault();
277
334
  formStore.getState().submit(event);
278
335
  })
279
- .render(() => {
280
- const errors = formStore.getState().errors;
281
- return html`
336
+ .render(
337
+ () => html`
282
338
  <form>
283
339
  <label>
284
340
  Name
285
341
  <input name="name" />
286
- ${errors.name ? html`<p role="alert">${errors.name.join(", ")}</p>` : ""}
342
+ ${errors().name ? html`<p role="alert">${errors().name.join(", ")}</p>` : ""}
287
343
  </label>
288
344
  <label>
289
345
  Email
290
346
  <input name="email" type="email" />
291
- ${errors.email ? html`<p role="alert">${errors.email.join(", ")}</p>` : ""}
347
+ ${errors().email ? html`<p role="alert">${errors().email.join(", ")}</p>` : ""}
292
348
  </label>
293
349
  <label>
294
350
  Message
295
351
  <textarea name="message"></textarea>
296
- ${errors.message ? html`<p role="alert">${errors.message.join(", ")}</p>` : ""}
352
+ ${errors().message ? html`<p role="alert">${errors().message.join(", ")}</p>` : ""}
297
353
  </label>
298
354
  <button type="submit">Send</button>
299
355
  </form>
300
- `;
301
- });
356
+ `,
357
+ );
302
358
  ```
303
359
 
304
- The store holds errors, the schema drives types, and `extractFormData` + `validateWithSchema` + `issuesToErrors` form a straight pipeline from DOM to error state.
360
+ The store holds errors, the schema drives types, and `extractFormData` + `validateWithSchema` + `issuesToErrors` form a straight pipeline from DOM to error state. The `errors` accessor is reactive — when `submit` writes new errors, the island re-renders automatically.
305
361
 
306
362
  ---
307
363
 
package/dist/index.d.ts CHANGED
@@ -20,6 +20,31 @@ interface StoreApi<T extends object> {
20
20
  subscribe<S>(selector: (state: T) => S, listener: SliceListener<T, S>): Unsub;
21
21
  bind(el: Element, render: (state: T) => RenderResult): Unsub;
22
22
  bind<S>(el: Element, selector: (state: T) => S, render: (slice: S) => RenderResult): Unsub;
23
+ /**
24
+ * Project a reactive slice of state into a signal-shaped accessor.
25
+ *
26
+ * The returned function reads the current value when called. When called
27
+ * inside an ilha tracking scope (`.render()`, `.derived()`, `.effect()`)
28
+ * or any other alien-signals reactive context, that scope subscribes to
29
+ * the slice and re-runs whenever the selector's output changes.
30
+ *
31
+ * Hoist the call out of render functions — each `.select()` allocates
32
+ * a fresh `computed`, so calling it inside a render that re-runs leaks
33
+ * one per render until the scope is collected. Define selectors at
34
+ * module scope or in `.onMount()`/closure setup.
35
+ *
36
+ * @example
37
+ * const cartStore = createStore({ items: [] as Item[], total: 0 });
38
+ * const itemCount = cartStore.select(s => s.items.length);
39
+ *
40
+ * const Badge = ilha.render(() => html`<span>${itemCount()}</span>`);
41
+ *
42
+ * @example
43
+ * // Stable identity for downstream comparisons:
44
+ * const items = cartStore.select(s => s.items);
45
+ * cartStore.setState({ total: 99 }); // items() returns the same array
46
+ */
47
+ select<S>(selector: (state: T) => S): () => S;
23
48
  }
24
49
  type ActionsCreator<TState extends object, TActions extends object> = (set: SetState<TState>, get: GetState<any>, getInitialState: () => TState) => TActions;
25
50
  declare function createStore<TState extends object>(initialState: TState): StoreApi<TState>;
package/dist/index.js CHANGED
@@ -48,13 +48,17 @@ function createStore(initialState, actionsCreator) {
48
48
  }
49
49
  });
50
50
  }
51
+ function select(selector) {
52
+ const sliceComputed = computed(() => selector(stateSignal()));
53
+ return () => sliceComputed();
54
+ }
51
55
  function bind(el, renderOrSelector, maybeRender) {
52
56
  if (maybeRender === void 0) return effect(() => {
53
57
  el.innerHTML = unwrap(renderOrSelector(stateSignal()));
54
58
  });
55
- const sliceComputed = computed(() => renderOrSelector(stateSignal()));
59
+ const slice = select(renderOrSelector);
56
60
  return effect(() => {
57
- el.innerHTML = unwrap(maybeRender(sliceComputed()));
61
+ el.innerHTML = unwrap(maybeRender(slice()));
58
62
  });
59
63
  }
60
64
  let resolvedInitialState;
@@ -63,7 +67,8 @@ function createStore(initialState, actionsCreator) {
63
67
  getState,
64
68
  getInitialState: () => resolvedInitialState,
65
69
  subscribe,
66
- bind
70
+ bind,
71
+ select
67
72
  };
68
73
  const resolvedActions = actionsCreator ? actionsCreator(setState, getState, () => resolvedInitialState) : {};
69
74
  resolvedInitialState = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/store",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Typed store.",
5
5
  "license": "MIT",
6
6
  "author": "Ryuz <ryuzer@proton.me>",
@@ -29,9 +29,9 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "alien-signals": "3.1.2",
32
- "ilha": "0.4.0"
32
+ "ilha": "0.4.1"
33
33
  },
34
34
  "devDependencies": {
35
- "zod": "^4.4.1"
35
+ "zod": "^4.4.3"
36
36
  }
37
37
  }