march-hare 0.8.0 → 0.10.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 +491 -211
- package/dist/actions/index.d.ts +46 -0
- package/dist/{hooks → actions}/utils.d.ts +0 -39
- package/dist/app/index.d.ts +132 -0
- package/dist/app/types.d.ts +82 -0
- package/dist/boundary/components/broadcast/utils.d.ts +1 -1
- package/dist/boundary/components/env/index.d.ts +26 -0
- package/dist/boundary/components/env/types.d.ts +11 -0
- package/dist/boundary/components/env/utils.d.ts +36 -0
- package/dist/boundary/components/scope/index.d.ts +1 -39
- package/dist/boundary/components/scope/types.d.ts +17 -13
- package/dist/boundary/components/scope/utils.d.ts +12 -8
- package/dist/boundary/components/sharing/index.d.ts +43 -0
- package/dist/boundary/components/tap/index.d.ts +36 -0
- package/dist/boundary/components/tap/types.d.ts +150 -0
- package/dist/boundary/components/tap/utils.d.ts +14 -0
- package/dist/boundary/index.d.ts +10 -10
- package/dist/boundary/types.d.ts +46 -14
- package/dist/cache/index.d.ts +4 -4
- package/dist/coalesce/index.d.ts +57 -0
- package/dist/context/index.d.ts +41 -0
- package/dist/context/types.d.ts +14 -0
- package/dist/error/index.d.ts +1 -1
- package/dist/error/types.d.ts +8 -19
- package/dist/index.d.ts +9 -13
- package/dist/march-hare.js +8 -5
- package/dist/march-hare.umd.cjs +1 -1
- package/dist/resource/index.d.ts +55 -78
- package/dist/resource/types.d.ts +87 -11
- package/dist/resource/utils.d.ts +1 -1
- package/dist/scope/index.d.ts +63 -0
- package/dist/scope/types.d.ts +55 -0
- package/dist/types/index.d.ts +108 -58
- package/dist/utils/index.d.ts +6 -5
- package/dist/with/index.d.ts +111 -0
- package/package.json +1 -1
- package/dist/boundary/components/store/index.d.ts +0 -41
- package/dist/boundary/components/store/types.d.ts +0 -11
- package/dist/boundary/components/store/utils.d.ts +0 -64
- package/dist/hooks/index.d.ts +0 -83
- /package/dist/{hooks → actions}/types.d.ts +0 -0
package/README.md
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
|
|
20
20
|
1. [Benefits](#benefits)
|
|
21
21
|
1. [Getting started](#getting-started)
|
|
22
|
+
1. [Async resources](#async-resources)
|
|
23
|
+
1. [Reactive data](#reactive-data)
|
|
24
|
+
1. [Broadcast actions](#broadcast-actions)
|
|
25
|
+
1. [Remote data with `Resource`](#remote-data-with-resource)
|
|
26
|
+
1. [Channeled actions](#channeled-actions)
|
|
27
|
+
1. [Multicast actions](#multicast-actions)
|
|
28
|
+
1. [Global data](#global-data)
|
|
29
|
+
1. [Toggling boolean state](#toggling-boolean-state)
|
|
22
30
|
|
|
23
31
|
For advanced topics, see the [recipes directory](./recipes/).
|
|
24
32
|
|
|
@@ -38,15 +46,35 @@ For advanced topics, see the [recipes directory](./recipes/).
|
|
|
38
46
|
- Granular async state tracking per model field.
|
|
39
47
|
- Declarative lifecycle hooks without `useEffect`.
|
|
40
48
|
- Centralised error handling via the global `Lifecycle.Fault` broadcast.
|
|
41
|
-
- View-side reactivity for the per-`<Boundary>`
|
|
49
|
+
- View-side reactivity for the per-`<app.Boundary>` Env via the global `Lifecycle.Env` broadcast.
|
|
50
|
+
- Observability hook via `<app.Boundary tap={...}>` – fires for every handler dispatch and its terminal (`success` or `error`). See the [tap recipe](./recipes/tap.md).
|
|
42
51
|
- React Native compatible – uses [eventemitter3](https://github.com/primus/eventemitter3) for cross-platform pub/sub.
|
|
43
52
|
|
|
44
53
|
## Getting started
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
Declare your app once via `App()` – the returned handle is the entrypoint for every typed primitive: `app.Boundary`, `app.useContext`, `app.useEnv`, `app.Resource`. Render `<app.Boundary>` once at the root and import `app` wherever you need it. Pass `{ env }` only when your app needs ambient state ([see Global data below](#global-data)):
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// app.ts
|
|
59
|
+
import { App } from "march-hare";
|
|
60
|
+
|
|
61
|
+
export const app = App();
|
|
62
|
+
```
|
|
47
63
|
|
|
48
64
|
```tsx
|
|
49
|
-
|
|
65
|
+
// index.tsx
|
|
66
|
+
import { app } from "./app";
|
|
67
|
+
|
|
68
|
+
<app.Boundary>
|
|
69
|
+
<Root />
|
|
70
|
+
</app.Boundary>;
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Inside a feature, define the model + actions, write a `useActions` hook that wires handlers, and destructure `[model, actions]` from it in the component. We dispatch the `Actions.Name` event on click, subscribe to it via `app.useContext`, and `With.Update` binds the payload directly to a model property:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { Action, With } from "march-hare";
|
|
77
|
+
import { app } from "./app";
|
|
50
78
|
|
|
51
79
|
type Model = {
|
|
52
80
|
name: string | null;
|
|
@@ -61,7 +89,7 @@ export class Actions {
|
|
|
61
89
|
}
|
|
62
90
|
|
|
63
91
|
function useActions() {
|
|
64
|
-
const context = useContext<Model, typeof Actions>();
|
|
92
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
65
93
|
const actions = context.useActions(model);
|
|
66
94
|
|
|
67
95
|
actions.useAction(Actions.Name, With.Update("name"));
|
|
@@ -84,151 +112,231 @@ export default function Profile(): React.ReactElement {
|
|
|
84
112
|
}
|
|
85
113
|
```
|
|
86
114
|
|
|
87
|
-
|
|
115
|
+
This shape – `useActions` hook, `[model, actions]` destructure in the component – is the canonical pattern used throughout this README.
|
|
116
|
+
|
|
117
|
+
## Async resources
|
|
118
|
+
|
|
119
|
+
When the handler needs to do more than assign the payload – an API call, for example – expand `useAction` to a full function. Remote data goes through `app.Resource` rather than a bare `fetch`: declare the resource at module scope, fetch via `context.actions.resource(...)`:
|
|
88
120
|
|
|
89
121
|
```ts
|
|
90
122
|
// resources.ts
|
|
91
|
-
import {
|
|
123
|
+
import { app } from "./app";
|
|
92
124
|
|
|
93
|
-
export const user = Resource((
|
|
94
|
-
ky.get(api.user(), { signal: controller.signal }).json<User>(),
|
|
125
|
+
export const user = app.Resource<User>((context) =>
|
|
126
|
+
ky.get(api.user(), { signal: context.controller.signal }).json<User>(),
|
|
95
127
|
);
|
|
96
128
|
```
|
|
97
129
|
|
|
98
130
|
```tsx
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
void (model.name = context.actions.annotate(model.name, Op.Update)),
|
|
103
|
-
);
|
|
131
|
+
import { Action, Op } from "march-hare";
|
|
132
|
+
import { app } from "./app";
|
|
133
|
+
import * as resource from "./resources";
|
|
104
134
|
|
|
105
|
-
|
|
106
|
-
|
|
135
|
+
type Model = { name: string | null };
|
|
136
|
+
const model: Model = { name: null };
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
export class Actions {
|
|
139
|
+
static Name = Action<string>("Name");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function useActions() {
|
|
143
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
144
|
+
const actions = context.useActions(model);
|
|
145
|
+
|
|
146
|
+
actions.useAction(Actions.Name, async (context) => {
|
|
147
|
+
context.actions.produce(
|
|
148
|
+
({ model }) =>
|
|
149
|
+
void (model.name = context.actions.annotate(model.name, Op.Update)),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Auto-threads context.task.controller and the live Env handle.
|
|
153
|
+
const user = await context.actions.resource(resource.user());
|
|
154
|
+
|
|
155
|
+
context.actions.produce(({ model }) => void (model.name = user.name));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return actions;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default function Profile(): React.ReactElement {
|
|
162
|
+
const [model, actions] = useActions();
|
|
163
|
+
return <p>Hey {model.name}</p>;
|
|
164
|
+
}
|
|
110
165
|
```
|
|
111
166
|
|
|
112
|
-
|
|
167
|
+
`annotate` is covered in the [Immertation documentation](https://github.com/Wildhoney/Immertation). Once the request resolves we update the model again with the fetched name. `app.Resource` caches the most recent successful payload and exposes typed params – the full API is covered [further down](#remote-data-with-resource).
|
|
168
|
+
|
|
169
|
+
## Reactive data
|
|
113
170
|
|
|
114
|
-
If you need to access external reactive values (
|
|
171
|
+
If you need to access external reactive values (props or `useState` from parent components) that always reflect the latest value even after `await` operations, pass a data callback as the second argument to `context.useActions`. The same snapshot is exposed as the third tuple element so JSX and handlers read from a single named source:
|
|
115
172
|
|
|
116
173
|
```tsx
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
() => ({
|
|
121
|
-
query: props.query,
|
|
122
|
-
}),
|
|
123
|
-
);
|
|
174
|
+
import { Action } from "march-hare";
|
|
175
|
+
import { app } from "./app";
|
|
176
|
+
import * as resource from "./resources";
|
|
124
177
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
search({ query: context.data.query }),
|
|
128
|
-
);
|
|
129
|
-
// context.data.query is always the latest value, even after await
|
|
130
|
-
console.log(context.data.query, results);
|
|
131
|
-
});
|
|
178
|
+
type Model = { results: Result[] };
|
|
179
|
+
const model: Model = { results: [] };
|
|
132
180
|
|
|
133
|
-
|
|
134
|
-
|
|
181
|
+
type Props = { query: string };
|
|
182
|
+
|
|
183
|
+
export class Actions {
|
|
184
|
+
static Search = Action<string>("Search");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function useActions(props: Props) {
|
|
188
|
+
const context = app.useContext<Model, typeof Actions, Props>();
|
|
189
|
+
const actions = context.useActions(model, () => ({ query: props.query }));
|
|
190
|
+
|
|
191
|
+
actions.useAction(Actions.Search, async (context) => {
|
|
192
|
+
const search = await context.actions.resource(
|
|
193
|
+
resource.search({ query: context.data.query }),
|
|
194
|
+
);
|
|
195
|
+
// context.data.query is always the latest value, even after await.
|
|
196
|
+
context.actions.produce(({ model }) => void (model.results = search));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return actions;
|
|
200
|
+
}
|
|
135
201
|
|
|
136
|
-
|
|
202
|
+
export default function Search(props: { query: string }): React.ReactElement {
|
|
203
|
+
const [, actions, data] = useActions(props);
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<input
|
|
207
|
+
value={data.query}
|
|
208
|
+
onChange={(event) => actions.dispatch(Actions.Search, event.target.value)}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
137
213
|
|
|
138
|
-
|
|
214
|
+
`data` is read-only from the view side – handlers read fresh values via `context.data` (Proxy delegating to a ref kept current across `await`s); JSX reads via the third tuple element (the same Proxy, refreshed synchronously each render). If a handler needs to _react_ to a change in `data`, subscribe to `Lifecycle.Update()` — it fires whenever `getData`'s result differs from the previous render. See the [referential equality recipe](./recipes/referential-equality.md) and the [React context in handlers recipe](./recipes/react-context-in-handlers.md) for more.
|
|
139
215
|
|
|
140
|
-
|
|
216
|
+
When an external library needs the dispatch callback at construction time (form libraries, animation engines) _and_ its return value must flow back into `context.useActions` via the data callback, there's a chicken-and-egg — each side wants the other to exist first. `app.useContext` resolves this by returning a stable handle up-front: `context.actions.dispatch` is callable from the first line, the external library closes over it, and `context.useActions(initialModel, getData?)` completes the binding once the external value is in scope:
|
|
141
217
|
|
|
142
218
|
```tsx
|
|
143
|
-
import {
|
|
219
|
+
import { Action } from "march-hare";
|
|
220
|
+
import { app } from "./app";
|
|
221
|
+
import * as resource from "./resources";
|
|
222
|
+
import { useForm } from "some-form-library";
|
|
144
223
|
|
|
145
|
-
|
|
146
|
-
|
|
224
|
+
type Model = { saving: boolean };
|
|
225
|
+
|
|
226
|
+
export class Actions {
|
|
227
|
+
static Submit = Action("Submit");
|
|
147
228
|
}
|
|
148
229
|
|
|
149
|
-
|
|
150
|
-
|
|
230
|
+
function useActions() {
|
|
231
|
+
// 1. The handle is ready immediately — `context.actions.dispatch` is callable
|
|
232
|
+
// before `context.useActions(...)` runs.
|
|
233
|
+
const context = app.useContext<Model, typeof Actions, { form: FormApi }>();
|
|
234
|
+
|
|
235
|
+
// 2. The external library closes over `context.actions.dispatch` at
|
|
236
|
+
// construction time. The form's `onSubmit` fires a Submit action.
|
|
237
|
+
const form = useForm({
|
|
238
|
+
onSubmit: () => void context.actions.dispatch(Actions.Submit),
|
|
239
|
+
});
|
|
151
240
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
});
|
|
241
|
+
// 3. The form value (`form`) flows back into the data callback so handlers
|
|
242
|
+
// read it via `context.data.form`.
|
|
243
|
+
const actions = context.useActions({ saving: false }, () => ({ form }));
|
|
244
|
+
|
|
245
|
+
actions.useAction(Actions.Submit, async (context) => {
|
|
246
|
+
context.actions.produce(({ model }) => void (model.saving = true));
|
|
247
|
+
await context.actions.resource(resource.save(context.data.form.values));
|
|
248
|
+
context.actions.produce(({ model }) => void (model.saving = false));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return actions;
|
|
252
|
+
}
|
|
155
253
|
```
|
|
156
254
|
|
|
157
|
-
|
|
255
|
+
See the [`useContext` recipe](./recipes/use-context.md) for the full pattern.
|
|
256
|
+
|
|
257
|
+
The model defaults to `void`, so a component that only coordinates via events — forwarding broadcasts, triggering side-effects, bridging external systems — can call `context.useActions()` with no initial model:
|
|
158
258
|
|
|
159
259
|
```tsx
|
|
160
|
-
import {
|
|
260
|
+
import { Action } from "march-hare";
|
|
261
|
+
import { app } from "./app";
|
|
161
262
|
|
|
162
263
|
export class Actions {
|
|
163
264
|
static Ping = Action("Ping");
|
|
164
265
|
}
|
|
165
266
|
|
|
166
|
-
|
|
167
|
-
const context = useContext<void, typeof Actions>();
|
|
267
|
+
function useActions() {
|
|
268
|
+
const context = app.useContext<void, typeof Actions>();
|
|
168
269
|
const actions = context.useActions();
|
|
169
270
|
|
|
170
271
|
actions.useAction(Actions.Ping, () => {
|
|
171
272
|
console.log("Pinged!");
|
|
172
273
|
});
|
|
173
274
|
|
|
275
|
+
return actions;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export default function Pinger(): React.ReactElement {
|
|
279
|
+
const [, actions] = useActions();
|
|
174
280
|
return <button onClick={() => actions.dispatch(Actions.Ping)}>Ping</button>;
|
|
175
281
|
}
|
|
176
282
|
```
|
|
177
283
|
|
|
178
|
-
|
|
284
|
+
You can still use lifecycle hooks, `context.data`, and `dispatch` as normal. See the [void model recipe](./recipes/void-model.md) for more.
|
|
179
285
|
|
|
180
|
-
|
|
286
|
+
## Broadcast actions
|
|
287
|
+
|
|
288
|
+
Each action should be responsible for its own data — in the `Profile` example above, the handler fetches the user but other components may want to consume the result. For that, use a broadcast action:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
// actions.ts (excerpt)
|
|
292
|
+
import { Action, Distribution } from "march-hare";
|
|
181
293
|
|
|
182
|
-
```tsx
|
|
183
294
|
class BroadcastActions {
|
|
184
295
|
static Name = Action<string>("Name", Distribution.Broadcast);
|
|
185
296
|
}
|
|
186
297
|
|
|
187
|
-
class Actions {
|
|
298
|
+
export class Actions {
|
|
188
299
|
static Broadcast = BroadcastActions;
|
|
189
300
|
static Profile = Action<string>("Profile");
|
|
190
301
|
}
|
|
191
302
|
```
|
|
192
303
|
|
|
193
|
-
|
|
304
|
+
Inside `useActions`, the handler fetches the user and then dispatches the broadcast so siblings see the result:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
194
307
|
actions.useAction(Actions.Profile, async (context) => {
|
|
195
308
|
context.actions.produce(
|
|
196
309
|
({ model }) =>
|
|
197
310
|
void (model.name = context.actions.annotate(model.name, Op.Update)),
|
|
198
311
|
);
|
|
199
312
|
|
|
200
|
-
const
|
|
313
|
+
const user = await context.actions.resource(resource.user());
|
|
201
314
|
|
|
202
|
-
context.actions.produce(({ model }) => void (model.name =
|
|
315
|
+
context.actions.produce(({ model }) => void (model.name = user.name));
|
|
203
316
|
|
|
204
|
-
context.actions.dispatch(Actions.Broadcast.Name,
|
|
317
|
+
context.actions.dispatch(Actions.Broadcast.Name, user.name);
|
|
205
318
|
});
|
|
206
319
|
```
|
|
207
320
|
|
|
208
|
-
|
|
321
|
+
Any component whose `useActions` subscribes to the broadcast receives it:
|
|
209
322
|
|
|
210
|
-
```
|
|
323
|
+
```ts
|
|
211
324
|
actions.useAction(Actions.Broadcast.Name, async (context, name) => {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
context.actions.produce(({ model }) => void (model.friends = data));
|
|
325
|
+
const friends = await context.actions.resource(resource.friends({ name }));
|
|
326
|
+
context.actions.produce(({ model }) => void (model.friends = friends));
|
|
215
327
|
});
|
|
216
328
|
```
|
|
217
329
|
|
|
218
|
-
|
|
330
|
+
`context.actions.final(...)` and `context.actions.peek(...)` access the latest cached broadcast value without subscribing via `useAction`. `final` waits for any pending annotations on the corresponding model field to settle; `peek` returns immediately:
|
|
219
331
|
|
|
220
|
-
```
|
|
332
|
+
```ts
|
|
221
333
|
actions.useAction(Actions.FetchFriends, async (context) => {
|
|
222
|
-
const name = await context.actions.
|
|
334
|
+
const name = await context.actions.final(Actions.Broadcast.Name);
|
|
223
335
|
if (!name) return;
|
|
224
|
-
const
|
|
225
|
-
context.actions.produce(({ model }) => void (model.friends =
|
|
336
|
+
const friends = await context.actions.resource(resource.friends({ name }));
|
|
337
|
+
context.actions.produce(({ model }) => void (model.friends = friends));
|
|
226
338
|
});
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
`peek` is useful for guard checks or synchronous reads where you don't need to wait for settled state:
|
|
230
339
|
|
|
231
|
-
```tsx
|
|
232
340
|
actions.useAction(Actions.Check, (context) => {
|
|
233
341
|
const name = context.actions.peek(Actions.Broadcast.Name);
|
|
234
342
|
if (!name) return;
|
|
@@ -236,9 +344,9 @@ actions.useAction(Actions.Check, (context) => {
|
|
|
236
344
|
});
|
|
237
345
|
```
|
|
238
346
|
|
|
239
|
-
Dispatch is awaitable – `context.actions.dispatch` returns a `Promise<void>` that resolves when
|
|
347
|
+
Dispatch is awaitable – `context.actions.dispatch` returns a `Promise<void>` that resolves when every triggered handler has completed. This prevents UI flashes where local state changes before upstream handlers finish:
|
|
240
348
|
|
|
241
|
-
```
|
|
349
|
+
```ts
|
|
242
350
|
actions.useAction(Actions.Mount, async (context) => {
|
|
243
351
|
// Wait for all PaymentSent handlers across the app to finish.
|
|
244
352
|
await context.actions.dispatch(Actions.Broadcast.PaymentSent);
|
|
@@ -253,13 +361,27 @@ Generator handlers are excluded from the await — they run in the backgroun
|
|
|
253
361
|
You can also render broadcast values declaratively in JSX with `actions.stream`. The renderer callback receives `(value, inspect)` and returns React nodes:
|
|
254
362
|
|
|
255
363
|
```tsx
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
364
|
+
import { app } from "./app";
|
|
365
|
+
|
|
366
|
+
type Model = {
|
|
367
|
+
/* ... */
|
|
368
|
+
};
|
|
369
|
+
const model: Model = {
|
|
370
|
+
/* ... */
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
function useActions() {
|
|
374
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
375
|
+
const actions = context.useActions(model);
|
|
376
|
+
return actions;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export default function Dashboard(): React.ReactElement {
|
|
380
|
+
const [, actions] = useActions();
|
|
259
381
|
|
|
260
382
|
return (
|
|
261
383
|
<div>
|
|
262
|
-
{actions.stream(Actions.Broadcast.User, (user
|
|
384
|
+
{actions.stream(Actions.Broadcast.User, (user) => (
|
|
263
385
|
<span>Welcome, {user.name}</span>
|
|
264
386
|
))}
|
|
265
387
|
</div>
|
|
@@ -269,88 +391,126 @@ function Dashboard() {
|
|
|
269
391
|
|
|
270
392
|
Components that mount after a broadcast has already been dispatched automatically receive the cached value via their `useAction` handler. If you also fetch data in `Lifecycle.Mount()`, see the [mount deduplication recipe](./recipes/mount-broadcast-deduplication.md) to avoid duplicate requests.
|
|
271
393
|
|
|
272
|
-
|
|
394
|
+
## Remote data with `Resource`
|
|
273
395
|
|
|
274
|
-
For remote data, declare
|
|
396
|
+
For remote data, declare an `app.Resource` at module scope. `resource.user(params)` is the unified call form — it returns the sync cache read (`User | null`) and primes a slot that `context.actions.resource(resource.user(params))` consumes for the fetch path (with auto-threaded abort controller and a live handle to the per-`<Boundary>` Env). Every successful fetch caches the response in a module-level slot keyed by the fetcher and the stringified params, so different param-sets are independent. Keep all resources in `resources.ts` and pull the whole module in as a namespace (`import * as resource from "./resources"`):
|
|
275
397
|
|
|
276
398
|
```ts
|
|
277
399
|
// resources.ts
|
|
278
|
-
import {
|
|
400
|
+
import { app } from "./app";
|
|
279
401
|
|
|
280
|
-
export const user = Resource((
|
|
281
|
-
ky.get("/api/user", { signal: controller.signal }).json<User>(),
|
|
402
|
+
export const user = app.Resource<User>((context) =>
|
|
403
|
+
ky.get("/api/user", { signal: context.controller.signal }).json<User>(),
|
|
282
404
|
);
|
|
283
405
|
|
|
284
|
-
export const pay = Resource<Receipt, Body>((
|
|
406
|
+
export const pay = app.Resource<Receipt, Body>((context) =>
|
|
285
407
|
ky
|
|
286
|
-
.post("/api/pay", {
|
|
408
|
+
.post("/api/pay", {
|
|
409
|
+
json: context.params,
|
|
410
|
+
signal: context.controller.signal,
|
|
411
|
+
})
|
|
287
412
|
.json<Receipt>(),
|
|
288
413
|
);
|
|
289
414
|
```
|
|
290
415
|
|
|
291
416
|
```tsx
|
|
292
|
-
// actions.ts
|
|
293
|
-
import {
|
|
294
|
-
import {
|
|
417
|
+
// profile/actions.ts
|
|
418
|
+
import { Action, Lifecycle, type Maybe } from "march-hare";
|
|
419
|
+
import { app } from "../app";
|
|
420
|
+
import * as resource from "../resources";
|
|
295
421
|
|
|
296
|
-
|
|
297
|
-
|
|
422
|
+
type Model = { user: Maybe<User>; receipt: Maybe<Receipt> };
|
|
423
|
+
|
|
424
|
+
export class Actions {
|
|
425
|
+
static Mount = Lifecycle.Mount();
|
|
426
|
+
static Submit = Action<Body>("Submit");
|
|
427
|
+
}
|
|
298
428
|
|
|
429
|
+
function useActions() {
|
|
430
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
299
431
|
const actions = context.useActions({
|
|
300
432
|
// Sync cache read at the model literal — returns null when nothing is cached.
|
|
301
|
-
user: user(),
|
|
433
|
+
user: resource.user(),
|
|
302
434
|
receipt: null,
|
|
303
435
|
});
|
|
304
436
|
|
|
305
437
|
actions.useAction(Actions.Mount, async (context) => {
|
|
306
|
-
const
|
|
307
|
-
.resource(user())
|
|
438
|
+
const user = await context.actions
|
|
439
|
+
.resource(resource.user())
|
|
308
440
|
.exceeds({ minutes: 5 });
|
|
309
|
-
context.actions.produce(({ model }) => void (model.user =
|
|
441
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
310
442
|
});
|
|
311
443
|
|
|
312
444
|
actions.useAction(Actions.Submit, async (context, body) => {
|
|
313
|
-
const
|
|
314
|
-
context.actions.produce(({ model }) => void (model.receipt =
|
|
445
|
+
const pay = await context.actions.resource(resource.pay(body));
|
|
446
|
+
context.actions.produce(({ model }) => void (model.receipt = pay));
|
|
315
447
|
});
|
|
316
448
|
|
|
317
449
|
return actions;
|
|
318
450
|
}
|
|
451
|
+
|
|
452
|
+
export default function Profile(): React.ReactElement {
|
|
453
|
+
const [model, actions] = useActions();
|
|
454
|
+
// ...
|
|
455
|
+
}
|
|
319
456
|
```
|
|
320
457
|
|
|
321
|
-
`context.actions.resource(invocation)` returns a thenable
|
|
458
|
+
`context.actions.resource(invocation)` returns a chainable thenable:
|
|
322
459
|
|
|
323
|
-
|
|
460
|
+
- `.exceeds({ minutes: 5 })` — short-circuits when the per-params cache age is within the freshness window. Accepts a `Temporal.Duration`, a `DurationLike` object, or an ISO 8601 duration string. `Temporal` is read from the host runtime – bring a polyfill (e.g. [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill)) if your target doesn't expose it natively.
|
|
461
|
+
- `.coalesce()` — opts the call into in-flight sharing. Any other caller with the same Resource and same structural params receives the same promise. The shared fetch uses a detached `AbortController` so a single caller's abort never cancels work other callers are waiting on; each caller still sees its own `context.task.controller` abort as a rejection of its personal await.
|
|
324
462
|
|
|
325
463
|
```ts
|
|
326
|
-
|
|
327
|
-
ky.get("/api/user", { signal: controller.signal }).json<User>(),
|
|
328
|
-
);
|
|
329
|
-
|
|
464
|
+
// Mount and a broadcast handler both fire on mount — only one network request.
|
|
330
465
|
actions.useAction(Actions.Mount, async (context) => {
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
466
|
+
const user = await context.actions.resource(resource.user()).coalesce();
|
|
467
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
actions.useAction(Actions.Broadcast.UserId, async (context, id) => {
|
|
471
|
+
const user = await context.actions.resource(resource.user({ id })).coalesce();
|
|
472
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
334
473
|
});
|
|
335
474
|
```
|
|
336
475
|
|
|
337
|
-
|
|
476
|
+
For finer-grained control — separating concurrent fetches into distinct coalesce groups via a token argument — see the [coalesce tokens recipe](./recipes/coalesce-tokens.md).
|
|
477
|
+
|
|
478
|
+
The fetcher receives a `context` object — read fields via `context.env`, `context.controller`, `context.params`. There are no callbacks – no `onSuccess`, no `onError`. The `context.dispatch` field can fire broadcast or multicast actions from inside the fetcher (unicast is rejected at compile time), but most side-effects (model writes, analytics) belong in the `useAction` handler that awaited the call:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
// resources.ts
|
|
482
|
+
export const user = app.Resource<User>(async (context) => {
|
|
483
|
+
const data = await ky
|
|
484
|
+
.get("/api/user", { signal: context.controller.signal })
|
|
485
|
+
.json<User>();
|
|
486
|
+
await context.dispatch(Actions.Broadcast.UserUpdated, data);
|
|
487
|
+
return data;
|
|
488
|
+
});
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
`params` is the second generic on `app.Resource` and defaults to `{}`. Declare it when the fetcher needs call-time inputs – cursors, ids, query strings, request bodies:
|
|
338
492
|
|
|
339
493
|
```ts
|
|
340
494
|
type Params = { cursor: string | null };
|
|
341
495
|
|
|
342
|
-
export const feed = Resource<Page<Item>, Params>((
|
|
496
|
+
export const feed = app.Resource<Page<Item>, Params>((context) =>
|
|
343
497
|
http
|
|
344
498
|
.get("feed", {
|
|
345
|
-
searchParams: { cursor: params.cursor ?? "" },
|
|
346
|
-
signal: controller.signal,
|
|
499
|
+
searchParams: { cursor: context.params.cursor ?? "" },
|
|
500
|
+
signal: context.controller.signal,
|
|
347
501
|
})
|
|
348
502
|
.json<Page<Item>>(),
|
|
349
503
|
);
|
|
504
|
+
```
|
|
350
505
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
)
|
|
506
|
+
```ts
|
|
507
|
+
// Inside useActions:
|
|
508
|
+
actions.useAction(Actions.LoadMore, async (context) => {
|
|
509
|
+
const feed = await context.actions.resource(
|
|
510
|
+
resource.feed({ cursor: context.model.cursor }),
|
|
511
|
+
);
|
|
512
|
+
// ...
|
|
513
|
+
});
|
|
354
514
|
```
|
|
355
515
|
|
|
356
516
|
A complete IntersectionObserver-driven infinite-scroll demo lives at [`src/example/transactions/`](./src/example/transactions/) – mock paginated API, scroll-triggered `LoadMore`, `pending()` guard, broadcast on success.
|
|
@@ -360,8 +520,8 @@ For typed failure routing, wrap the call in `try/catch` and use `instanceof` &nd
|
|
|
360
520
|
```ts
|
|
361
521
|
actions.useAction(Actions.Mount, async (context) => {
|
|
362
522
|
try {
|
|
363
|
-
const
|
|
364
|
-
context.actions.produce(({ model }) => void (model.user =
|
|
523
|
+
const user = await context.actions.resource(resource.user());
|
|
524
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
365
525
|
} catch (error) {
|
|
366
526
|
if (error instanceof RateLimitedError) {
|
|
367
527
|
await context.actions.dispatch(
|
|
@@ -376,13 +536,12 @@ actions.useAction(Actions.Mount, async (context) => {
|
|
|
376
536
|
|
|
377
537
|
See the [Resource recipe](./recipes/use-resource.md) for the three-tier error handling model, parameterised resources, and limitations.
|
|
378
538
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
By default a `Resource`'s cache is in-memory only – it resets on every page load. To keep the most recent successful payload around between sessions, wire a `Cache` instance to the `Resource` definition. The Cache writes through to its adapter on every successful run and seeds the per-params slot from storage on first read, so call sites stay free of explicit `store.set` / `store.get` ceremony.
|
|
539
|
+
By default an `app.Resource`'s cache is in-memory only – it resets on every page load. To keep the most recent successful payload around between sessions, switch to `app.Resource.Cachable(cache, fetcher)`. The cache is the **first** argument — persistence is the headline of this form, the fetcher is the operation. Every successful fetch writes through to the Cache; first reads via the call form auto-seed from the Cache's adapter:
|
|
382
540
|
|
|
383
541
|
```ts
|
|
384
542
|
// resources.ts
|
|
385
|
-
import { Cache
|
|
543
|
+
import { Cache } from "march-hare";
|
|
544
|
+
import { app } from "./app";
|
|
386
545
|
|
|
387
546
|
const cache = Cache({
|
|
388
547
|
get: (key) => localStorage.getItem(key),
|
|
@@ -391,140 +550,204 @@ const cache = Cache({
|
|
|
391
550
|
clear: () => localStorage.clear(),
|
|
392
551
|
});
|
|
393
552
|
|
|
394
|
-
export const cat = Resource(
|
|
395
|
-
|
|
396
|
-
cache,
|
|
553
|
+
export const cat = app.Resource.Cachable(cache, (context) =>
|
|
554
|
+
fetchCat(context.controller.signal),
|
|
397
555
|
);
|
|
398
556
|
```
|
|
399
557
|
|
|
400
|
-
```
|
|
401
|
-
// actions.ts
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
cat: cat(),
|
|
406
|
-
});
|
|
558
|
+
```tsx
|
|
559
|
+
// cats/actions.ts
|
|
560
|
+
import { Lifecycle, type Maybe } from "march-hare";
|
|
561
|
+
import { app } from "../app";
|
|
562
|
+
import * as resource from "../resources";
|
|
407
563
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
564
|
+
type Model = { cat: Maybe<Cat> };
|
|
565
|
+
|
|
566
|
+
export class Actions {
|
|
567
|
+
static Mount = Lifecycle.Mount();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function useActions() {
|
|
571
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
572
|
+
const actions = context.useActions({
|
|
573
|
+
// First render reads the Cache automatically.
|
|
574
|
+
cat: resource.cat(),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
actions.useAction(Actions.Mount, async (context) => {
|
|
578
|
+
// Short-circuits when the persisted payload is < 5 minutes old.
|
|
579
|
+
// The Cache writes through automatically on success.
|
|
580
|
+
const cat = await context.actions
|
|
581
|
+
.resource(resource.cat())
|
|
582
|
+
.exceeds({ minutes: 5 });
|
|
583
|
+
context.actions.produce(({ model }) => void (model.cat = cat));
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
return actions;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export default function CatCard(): React.ReactElement {
|
|
590
|
+
const [model] = useActions();
|
|
591
|
+
return model.cat ? <img src={model.cat.url} /> : null;
|
|
592
|
+
}
|
|
416
593
|
```
|
|
417
594
|
|
|
418
595
|
`Cache()` with no adapter is an in-memory scope – useful in tests or when you want a holdable cache without persistence. Per-params keying via `JSON.stringify(params)` is automatic, so `user({ id: 5 })` and `user({ id: 6 })` are distinct slots.
|
|
419
596
|
|
|
420
597
|
See the [storage recipe](./recipes/storage.md) for backend adapters (React Native MMKV, browser extension `chrome.storage`), sign-out purge, and the `unset` sentinel that keeps "nothing stored" distinct from "a legitimately stored null".
|
|
421
598
|
|
|
422
|
-
|
|
599
|
+
## Channeled actions
|
|
600
|
+
|
|
601
|
+
For targeted event delivery, use channeled actions. Define a controller type as the second generic argument and call the action with a controller object – handlers fire only when the dispatch controller matches:
|
|
423
602
|
|
|
424
603
|
```tsx
|
|
425
|
-
|
|
426
|
-
|
|
604
|
+
import { Action } from "march-hare";
|
|
605
|
+
import { app } from "./app";
|
|
606
|
+
|
|
607
|
+
export class Actions {
|
|
608
|
+
// Second generic arg defines the controller type.
|
|
427
609
|
static UserUpdated = Action<User, { UserId: number }>("UserUpdated");
|
|
428
610
|
}
|
|
429
611
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
(context, user) => {
|
|
434
|
-
// Only fires when dispatched with matching UserId
|
|
435
|
-
},
|
|
436
|
-
);
|
|
612
|
+
function useActions(props: { userId: number }) {
|
|
613
|
+
const context = app.useContext<Model, typeof Actions, { userId: number }>();
|
|
614
|
+
const actions = context.useActions(model, () => ({ userId: props.userId }));
|
|
437
615
|
|
|
438
|
-
// Subscribe to
|
|
439
|
-
actions.useAction(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
);
|
|
616
|
+
// Subscribe to updates for a specific user.
|
|
617
|
+
actions.useAction(
|
|
618
|
+
Actions.UserUpdated({ UserId: props.userId }),
|
|
619
|
+
(context, user) => {
|
|
620
|
+
// Only fires when dispatched with matching UserId.
|
|
621
|
+
},
|
|
622
|
+
);
|
|
445
623
|
|
|
446
|
-
|
|
447
|
-
|
|
624
|
+
return actions;
|
|
625
|
+
}
|
|
448
626
|
|
|
449
|
-
// Dispatch to
|
|
450
|
-
actions.dispatch(Actions.UserUpdated({
|
|
627
|
+
// Dispatch to specific user.
|
|
628
|
+
actions.dispatch(Actions.UserUpdated({ UserId: user.id }), user);
|
|
451
629
|
|
|
452
|
-
// Dispatch to plain action
|
|
630
|
+
// Dispatch to plain action — ALL handlers fire (plain + all channeled).
|
|
453
631
|
actions.dispatch(Actions.UserUpdated, user);
|
|
454
632
|
```
|
|
455
633
|
|
|
456
634
|
Channel values support non-nullable primitives: `string`, `number`, `boolean`, or `symbol`. By convention, use uppercase keys like `{UserId: 4}` to distinguish controller keys from payload properties.
|
|
457
635
|
|
|
458
|
-
|
|
636
|
+
## Multicast actions
|
|
459
637
|
|
|
460
|
-
|
|
461
|
-
|
|
638
|
+
For scoped communication between component groups, declare a multicast action class and open a scope via `app.Scope<typeof MulticastActions>()`. The generic carries the multicast surface at the type level — `scope.useContext().actions.dispatch` widens to include those actions on top of the local `Actions` class, the same way `Actions.Broadcast = BroadcastActions` widens for broadcasts. Render `<scope.Boundary>` once at the root of the subtree the scope governs:
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
// scope/types.ts — multicast action class, kept separate from the local Actions.
|
|
642
|
+
import { Action, Distribution } from "march-hare";
|
|
462
643
|
|
|
463
|
-
|
|
464
|
-
class Scope {
|
|
644
|
+
export class MulticastActions {
|
|
465
645
|
static Update = Action<number>("Update", Distribution.Multicast);
|
|
466
646
|
}
|
|
647
|
+
```
|
|
467
648
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
649
|
+
```tsx
|
|
650
|
+
// scope/index.tsx — open the scope once.
|
|
651
|
+
import { app } from "../app";
|
|
652
|
+
import type { MulticastActions } from "./types";
|
|
653
|
+
import ScoreBoard from "./components/score-board";
|
|
654
|
+
import PlayerList from "./components/player-list";
|
|
471
655
|
|
|
472
|
-
|
|
656
|
+
export const scope = app.Scope<typeof MulticastActions>();
|
|
657
|
+
|
|
658
|
+
export default function ScoreArea(): React.ReactElement {
|
|
473
659
|
return (
|
|
474
|
-
|
|
660
|
+
<scope.Boundary>
|
|
475
661
|
<ScoreBoard />
|
|
476
662
|
<PlayerList />
|
|
477
|
-
|
|
663
|
+
</scope.Boundary>
|
|
478
664
|
);
|
|
479
665
|
}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
```tsx
|
|
669
|
+
// scope/components/score-board/actions.ts — subscribe and dispatch from inside.
|
|
670
|
+
import { Action } from "march-hare";
|
|
671
|
+
import { scope } from "../../index";
|
|
672
|
+
import { MulticastActions } from "../../types";
|
|
673
|
+
|
|
674
|
+
type Model = { score: number };
|
|
675
|
+
|
|
676
|
+
// Like `Broadcast`, you also list the multicast surface on the local
|
|
677
|
+
// Actions class so the bound dispatch sees it on `Actions.Multicast.*`.
|
|
678
|
+
export class Actions {
|
|
679
|
+
static Multicast = MulticastActions;
|
|
680
|
+
static Increment = Action("Increment");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function useActions() {
|
|
684
|
+
const context = scope.useContext<Model, typeof Actions>();
|
|
685
|
+
const actions = context.useActions({ score: 0 });
|
|
686
|
+
|
|
687
|
+
actions.useAction(MulticastActions.Update, (context, score) => {
|
|
688
|
+
context.actions.produce(({ model }) => void (model.score = score));
|
|
689
|
+
});
|
|
480
690
|
|
|
481
|
-
|
|
482
|
-
|
|
691
|
+
actions.useAction(Actions.Increment, (context) => {
|
|
692
|
+
context.actions.dispatch(MulticastActions.Update, context.model.score + 1);
|
|
693
|
+
});
|
|
483
694
|
|
|
484
|
-
|
|
485
|
-
|
|
695
|
+
return actions;
|
|
696
|
+
}
|
|
486
697
|
```
|
|
487
698
|
|
|
488
|
-
|
|
699
|
+
A few rules worth knowing:
|
|
700
|
+
|
|
701
|
+
- **Scope is confined to the subtree.** Multicast dispatches inside `<scope.Boundary>` reach every subscriber inside the same boundary, and only those subscribers. Sibling boundaries don't see each other; nothing outside any boundary sees them either.
|
|
702
|
+
- **Nesting shadows.** `<scope.Boundary>` is a React context provider, so an inner boundary fully shadows an outer one for its subtree. If you need a single scope to dispatch actions from multiple multicast classes, declare them as a union at the call site — e.g. `app.Scope<typeof PaymentMulticast | typeof RoomMulticast>()`.
|
|
703
|
+
- **No `scope.Scope()`.** The handle deliberately omits a nested factory. Open another scope by calling `app.Scope<...>()` again and rendering its `<Boundary>` — that way the multicast surface stays declared at the call site.
|
|
704
|
+
- **Replay on late-mount is per-scope.** Like broadcast, multicast caches its most recent payload per action symbol; components that mount later inside the same boundary pick up the cached value through their `useAction` handler. See the [mount deduplication recipe](./recipes/mount-broadcast-deduplication.md) if you also fetch in `Lifecycle.Mount()`.
|
|
489
705
|
|
|
490
706
|
See the [multicast recipe](./recipes/multicast-actions.md) for more details.
|
|
491
707
|
|
|
492
|
-
|
|
708
|
+
## Global data
|
|
709
|
+
|
|
710
|
+
For coordinating between async handlers and threading ambient values (session tokens, locale, feature flags, current operational mode) without re-rendering the JSX tree on every dot read, use the per-`<app.Boundary>` `Env`. Declare your env shape inline on `App({ env })`, read via dot notation (`env.session`, `context.env.locale`), and write via `context.actions.produce(({ env }) => { ... })` — the same Immer-style recipe used for the model. Every `app.Resource` fetcher also receives a live read-only handle to the Env on its args object — the same `Proxy` as `context.env`, so dot reads stay fresh across `await` boundaries inside the fetcher. When the view side needs to react to Env changes, subscribe to the global `Lifecycle.Env` broadcast — `actions.useAction(Lifecycle.Env, handler)` for handler-level work and `actions.stream(Lifecycle.Env, (env) => ...)` for JSX. Both seed from the initial Env on mount.
|
|
493
711
|
|
|
494
712
|
```ts
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
713
|
+
// app.ts
|
|
714
|
+
import { App, type Maybe } from "march-hare";
|
|
715
|
+
|
|
716
|
+
export const app = App({
|
|
717
|
+
env: {
|
|
718
|
+
session: null as Maybe<Session>,
|
|
719
|
+
operating: "idle" as "idle" | "signing-out",
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
```ts
|
|
725
|
+
// auth/actions.ts — every read/write is typed against the App's env shape.
|
|
726
|
+
import { Action } from "march-hare";
|
|
727
|
+
import { app } from "../app";
|
|
505
728
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
729
|
+
export class Actions {
|
|
730
|
+
static SignOut = Action("SignOut");
|
|
731
|
+
static Refresh = Action("Refresh");
|
|
732
|
+
}
|
|
510
733
|
|
|
511
|
-
|
|
512
|
-
const context = useContext<void, typeof Actions>();
|
|
734
|
+
function useActions() {
|
|
735
|
+
const context = app.useContext<void, typeof Actions>();
|
|
513
736
|
const actions = context.useActions();
|
|
514
737
|
|
|
515
738
|
actions.useAction(Actions.SignOut, async (context) => {
|
|
516
|
-
context.actions.produce(({
|
|
517
|
-
|
|
739
|
+
context.actions.produce(({ env }) => {
|
|
740
|
+
env.operating = "signing-out";
|
|
518
741
|
});
|
|
519
742
|
await api.signOut();
|
|
520
|
-
context.actions.produce(({
|
|
521
|
-
|
|
522
|
-
|
|
743
|
+
context.actions.produce(({ env }) => {
|
|
744
|
+
env.session = null;
|
|
745
|
+
env.operating = "idle";
|
|
523
746
|
});
|
|
524
747
|
});
|
|
525
748
|
|
|
526
749
|
actions.useAction(Actions.Refresh, async (context) => {
|
|
527
|
-
if (context.
|
|
750
|
+
if (context.env.operating === "signing-out") return;
|
|
528
751
|
// ...
|
|
529
752
|
});
|
|
530
753
|
|
|
@@ -532,36 +755,93 @@ export function useAuthActions() {
|
|
|
532
755
|
}
|
|
533
756
|
```
|
|
534
757
|
|
|
535
|
-
|
|
758
|
+
For the view side, render against `Lifecycle.Env` with `actions.stream` — the renderer receives the latest Env snapshot and re-runs whenever a `produce(({ env }) => ...)` mutation lands:
|
|
536
759
|
|
|
537
760
|
```tsx
|
|
538
|
-
import {
|
|
761
|
+
import { Lifecycle } from "march-hare";
|
|
762
|
+
import { app } from "./app";
|
|
763
|
+
|
|
764
|
+
export class Actions {}
|
|
765
|
+
|
|
766
|
+
function useActions() {
|
|
767
|
+
const context = app.useContext<void, typeof Actions>();
|
|
768
|
+
return context.useActions();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export default function Header(): React.ReactElement {
|
|
772
|
+
const [, actions] = useActions();
|
|
773
|
+
|
|
774
|
+
return (
|
|
775
|
+
<header>
|
|
776
|
+
{actions.stream(Lifecycle.Env, (env) =>
|
|
777
|
+
env.session ? (
|
|
778
|
+
<span>Hi, {env.session.user.name}</span>
|
|
779
|
+
) : (
|
|
780
|
+
<span>Signed out</span>
|
|
781
|
+
),
|
|
782
|
+
)}
|
|
783
|
+
</header>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
`Lifecycle.Env` seeds with the initial Env on mount, so late-mounting components paint the current value immediately instead of flashing through a null state. Pair `actions.stream` with `actions.useAction(Lifecycle.Env, ...)` when a handler-side reaction is also required.
|
|
789
|
+
|
|
790
|
+
Multiple `App` instances can coexist in the same tree — each `<app.Boundary>` owns its own Env with its own type.
|
|
791
|
+
|
|
792
|
+
## Toggling boolean state
|
|
793
|
+
|
|
794
|
+
Toggling boolean UI state – modals, sidebars, drawers – is one of the most common patterns. Bind a unicast action to a boolean field on the model with `context.with.invert`:
|
|
795
|
+
|
|
796
|
+
```tsx
|
|
797
|
+
import { Action } from "march-hare";
|
|
798
|
+
import { app } from "./app";
|
|
539
799
|
|
|
540
800
|
type Model = {
|
|
541
801
|
paymentDialog: boolean;
|
|
542
802
|
sidebar: boolean;
|
|
543
803
|
};
|
|
544
804
|
|
|
805
|
+
const model: Model = {
|
|
806
|
+
paymentDialog: false,
|
|
807
|
+
sidebar: false,
|
|
808
|
+
};
|
|
809
|
+
|
|
545
810
|
export class Actions {
|
|
546
811
|
static TogglePaymentDialog = Action("TogglePaymentDialog");
|
|
547
812
|
static ToggleSidebar = Action("ToggleSidebar");
|
|
548
813
|
}
|
|
549
814
|
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
sidebar: false,
|
|
554
|
-
});
|
|
815
|
+
function useActions() {
|
|
816
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
817
|
+
const actions = context.useActions(model);
|
|
555
818
|
|
|
556
|
-
actions.useAction(
|
|
557
|
-
|
|
819
|
+
actions.useAction(
|
|
820
|
+
Actions.TogglePaymentDialog,
|
|
821
|
+
context.with.invert("paymentDialog"),
|
|
822
|
+
);
|
|
823
|
+
actions.useAction(Actions.ToggleSidebar, context.with.invert("sidebar"));
|
|
558
824
|
|
|
559
|
-
|
|
560
|
-
|
|
825
|
+
return actions;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export default function Shell(): React.ReactElement {
|
|
829
|
+
const [model, actions] = useActions();
|
|
561
830
|
|
|
562
|
-
|
|
563
|
-
|
|
831
|
+
return (
|
|
832
|
+
<>
|
|
833
|
+
<button onClick={() => actions.dispatch(Actions.TogglePaymentDialog)}>
|
|
834
|
+
Pay
|
|
835
|
+
</button>
|
|
836
|
+
{model.paymentDialog && <PaymentDialog />}
|
|
837
|
+
</>
|
|
838
|
+
);
|
|
564
839
|
}
|
|
565
840
|
```
|
|
566
841
|
|
|
567
|
-
`
|
|
842
|
+
`context.with.invert` only compiles when the leaf at the path is a boolean on the model; sibling helpers cover other common shapes:
|
|
843
|
+
|
|
844
|
+
- `context.with.update(key)` — binds the dispatched payload to a model leaf; the payload type must match `Get<Model, key>`.
|
|
845
|
+
- `context.with.always(key, value)` — pins the leaf to a fixed value, ignoring any dispatched payload. Handy for Open/Close, Show/Hide pairs where each action pins the same field to a known value.
|
|
846
|
+
|
|
847
|
+
All three accept lodash-style dotted paths (`"a.b.c"`) and array indices (`"items.0.name"`); keys autocomplete from the model declared on `useContext<Model, …>()`. The top-level `With.Update` / `With.Invert` / `With.Always` import is kept for call sites that don't have a typed `context` in scope.
|