@ilha/store 0.2.2 → 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 +83 -27
- package/dist/index.d.ts +25 -0
- package/dist/index.js +8 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
59
|
+
const slice = select(renderOrSelector);
|
|
56
60
|
return effect(() => {
|
|
57
|
-
el.innerHTML = unwrap(maybeRender(
|
|
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 = {
|