march-hare 0.7.5 → 0.9.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 +496 -204
- package/dist/{hooks → actions}/index.d.ts +1 -2
- package/dist/{hooks → actions}/utils.d.ts +0 -39
- package/dist/app/index.d.ts +112 -0
- package/dist/app/types.d.ts +49 -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/index.d.ts +10 -10
- package/dist/boundary/types.d.ts +6 -16
- package/dist/cache/index.d.ts +4 -4
- package/dist/coalesce/index.d.ts +57 -0
- package/dist/context/index.d.ts +39 -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 +8 -12
- package/dist/march-hare.js +7 -5
- package/dist/march-hare.umd.cjs +1 -1
- package/dist/resource/index.d.ts +52 -78
- package/dist/resource/types.d.ts +83 -10
- package/dist/scope/index.d.ts +63 -0
- package/dist/scope/types.d.ts +55 -0
- package/dist/types/index.d.ts +116 -229
- package/dist/utils/index.d.ts +6 -5
- package/dist/with/index.d.ts +40 -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 → 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,34 @@ 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.
|
|
42
50
|
- React Native compatible – uses [eventemitter3](https://github.com/primus/eventemitter3) for cross-platform pub/sub.
|
|
43
51
|
|
|
44
52
|
## Getting started
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
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)):
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// app.ts
|
|
58
|
+
import { App } from "march-hare";
|
|
59
|
+
|
|
60
|
+
export const app = App();
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// index.tsx
|
|
65
|
+
import { app } from "./app";
|
|
66
|
+
|
|
67
|
+
<app.Boundary>
|
|
68
|
+
<Root />
|
|
69
|
+
</app.Boundary>;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
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:
|
|
47
73
|
|
|
48
74
|
```tsx
|
|
49
|
-
import {
|
|
75
|
+
import { Action, With } from "march-hare";
|
|
76
|
+
import { app } from "./app";
|
|
50
77
|
|
|
51
78
|
type Model = {
|
|
52
79
|
name: string | null;
|
|
@@ -60,14 +87,21 @@ export class Actions {
|
|
|
60
87
|
static Name = Action<string>("Name");
|
|
61
88
|
}
|
|
62
89
|
|
|
63
|
-
|
|
64
|
-
const
|
|
90
|
+
function useActions() {
|
|
91
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
92
|
+
const actions = context.useActions(model);
|
|
65
93
|
|
|
66
94
|
actions.useAction(Actions.Name, With.Update("name"));
|
|
67
95
|
|
|
96
|
+
return actions;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default function Profile(): React.ReactElement {
|
|
100
|
+
const [model, actions] = useActions();
|
|
101
|
+
|
|
68
102
|
return (
|
|
69
103
|
<>
|
|
70
|
-
<p>Hey {
|
|
104
|
+
<p>Hey {model.name}</p>
|
|
71
105
|
|
|
72
106
|
<button onClick={() => actions.dispatch(Actions.Name, randomName())}>
|
|
73
107
|
Sign in
|
|
@@ -77,147 +111,231 @@ export default function Profile(): React.ReactElement {
|
|
|
77
111
|
}
|
|
78
112
|
```
|
|
79
113
|
|
|
80
|
-
|
|
114
|
+
This shape – `useActions` hook, `[model, actions]` destructure in the component – is the canonical pattern used throughout this README.
|
|
115
|
+
|
|
116
|
+
## Async resources
|
|
117
|
+
|
|
118
|
+
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(...)`:
|
|
81
119
|
|
|
82
120
|
```ts
|
|
83
121
|
// resources.ts
|
|
84
|
-
import {
|
|
122
|
+
import { app } from "./app";
|
|
85
123
|
|
|
86
|
-
export const user = Resource((
|
|
87
|
-
ky.get(api.user(), { signal: controller.signal }).json<User>(),
|
|
124
|
+
export const user = app.Resource<User>((context) =>
|
|
125
|
+
ky.get(api.user(), { signal: context.controller.signal }).json<User>(),
|
|
88
126
|
);
|
|
89
127
|
```
|
|
90
128
|
|
|
91
129
|
```tsx
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
void (model.name = context.actions.annotate(model.name, Op.Update)),
|
|
96
|
-
);
|
|
130
|
+
import { Action, Op } from "march-hare";
|
|
131
|
+
import { app } from "./app";
|
|
132
|
+
import * as resource from "./resources";
|
|
97
133
|
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
type Model = { name: string | null };
|
|
135
|
+
const model: Model = { name: null };
|
|
100
136
|
|
|
101
|
-
|
|
102
|
-
|
|
137
|
+
export class Actions {
|
|
138
|
+
static Name = Action<string>("Name");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function useActions() {
|
|
142
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
143
|
+
const actions = context.useActions(model);
|
|
144
|
+
|
|
145
|
+
actions.useAction(Actions.Name, async (context) => {
|
|
146
|
+
context.actions.produce(
|
|
147
|
+
({ model }) =>
|
|
148
|
+
void (model.name = context.actions.annotate(model.name, Op.Update)),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Auto-threads context.task.controller and the Env snapshot.
|
|
152
|
+
const user = await context.actions.resource(resource.user());
|
|
153
|
+
|
|
154
|
+
context.actions.produce(({ model }) => void (model.name = user.name));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return actions;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default function Profile(): React.ReactElement {
|
|
161
|
+
const [model, actions] = useActions();
|
|
162
|
+
return <p>Hey {model.name}</p>;
|
|
163
|
+
}
|
|
103
164
|
```
|
|
104
165
|
|
|
105
|
-
|
|
166
|
+
`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).
|
|
167
|
+
|
|
168
|
+
## Reactive data
|
|
106
169
|
|
|
107
|
-
If you need to access external reactive values (
|
|
170
|
+
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:
|
|
108
171
|
|
|
109
172
|
```tsx
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
173
|
+
import { Action } from "march-hare";
|
|
174
|
+
import { app } from "./app";
|
|
175
|
+
import * as resource from "./resources";
|
|
176
|
+
|
|
177
|
+
type Model = { results: Result[] };
|
|
178
|
+
const model: Model = { results: [] };
|
|
179
|
+
|
|
180
|
+
type Props = { query: string };
|
|
181
|
+
|
|
182
|
+
export class Actions {
|
|
183
|
+
static Search = Action<string>("Search");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function useActions(props: Props) {
|
|
187
|
+
const context = app.useContext<Model, typeof Actions, Props>();
|
|
188
|
+
const actions = context.useActions(model, () => ({ query: props.query }));
|
|
189
|
+
|
|
190
|
+
actions.useAction(Actions.Search, async (context) => {
|
|
191
|
+
const search = await context.actions.resource(
|
|
192
|
+
resource.search({ query: context.data.query }),
|
|
193
|
+
);
|
|
194
|
+
// context.data.query is always the latest value, even after await.
|
|
195
|
+
context.actions.produce(({ model }) => void (model.results = search));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return actions;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default function Search(props: { query: string }): React.ReactElement {
|
|
202
|
+
const [, actions, data] = useActions(props);
|
|
125
203
|
|
|
126
|
-
return
|
|
204
|
+
return (
|
|
205
|
+
<input
|
|
206
|
+
value={data.query}
|
|
207
|
+
onChange={(event) => actions.dispatch(Actions.Search, event.target.value)}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
127
211
|
```
|
|
128
212
|
|
|
129
|
-
`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)
|
|
213
|
+
`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.
|
|
130
214
|
|
|
131
|
-
|
|
215
|
+
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:
|
|
132
216
|
|
|
133
217
|
```tsx
|
|
134
|
-
import {
|
|
218
|
+
import { Action } from "march-hare";
|
|
219
|
+
import { app } from "./app";
|
|
220
|
+
import * as resource from "./resources";
|
|
221
|
+
import { useForm } from "some-form-library";
|
|
135
222
|
|
|
136
|
-
|
|
137
|
-
|
|
223
|
+
type Model = { saving: boolean };
|
|
224
|
+
|
|
225
|
+
export class Actions {
|
|
226
|
+
static Submit = Action("Submit");
|
|
138
227
|
}
|
|
139
228
|
|
|
140
|
-
|
|
229
|
+
function useActions() {
|
|
230
|
+
// 1. The handle is ready immediately — `context.actions.dispatch` is callable
|
|
231
|
+
// before `context.useActions(...)` runs.
|
|
232
|
+
const context = app.useContext<Model, typeof Actions, { form: FormApi }>();
|
|
141
233
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
234
|
+
// 2. The external library closes over `context.actions.dispatch` at
|
|
235
|
+
// construction time. The form's `onSubmit` fires a Submit action.
|
|
236
|
+
const form = useForm({
|
|
237
|
+
onSubmit: () => void context.actions.dispatch(Actions.Submit),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// 3. The form value (`form`) flows back into the data callback so handlers
|
|
241
|
+
// read it via `context.data.form`.
|
|
242
|
+
const actions = context.useActions({ saving: false }, () => ({ form }));
|
|
243
|
+
|
|
244
|
+
actions.useAction(Actions.Submit, async (context) => {
|
|
245
|
+
context.actions.produce(({ model }) => void (model.saving = true));
|
|
246
|
+
await context.actions.resource(resource.save(context.data.form.values));
|
|
247
|
+
context.actions.produce(({ model }) => void (model.saving = false));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return actions;
|
|
251
|
+
}
|
|
145
252
|
```
|
|
146
253
|
|
|
147
|
-
|
|
254
|
+
See the [`useContext` recipe](./recipes/use-context.md) for the full pattern.
|
|
255
|
+
|
|
256
|
+
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:
|
|
148
257
|
|
|
149
258
|
```tsx
|
|
150
|
-
import {
|
|
259
|
+
import { Action } from "march-hare";
|
|
260
|
+
import { app } from "./app";
|
|
151
261
|
|
|
152
262
|
export class Actions {
|
|
153
263
|
static Ping = Action("Ping");
|
|
154
264
|
}
|
|
155
265
|
|
|
156
|
-
|
|
157
|
-
const
|
|
266
|
+
function useActions() {
|
|
267
|
+
const context = app.useContext<void, typeof Actions>();
|
|
268
|
+
const actions = context.useActions();
|
|
158
269
|
|
|
159
270
|
actions.useAction(Actions.Ping, () => {
|
|
160
271
|
console.log("Pinged!");
|
|
161
272
|
});
|
|
162
273
|
|
|
274
|
+
return actions;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default function Pinger(): React.ReactElement {
|
|
278
|
+
const [, actions] = useActions();
|
|
163
279
|
return <button onClick={() => actions.dispatch(Actions.Ping)}>Ping</button>;
|
|
164
280
|
}
|
|
165
281
|
```
|
|
166
282
|
|
|
167
|
-
|
|
283
|
+
You can still use lifecycle hooks, `context.data`, and `dispatch` as normal. See the [void model recipe](./recipes/void-model.md) for more.
|
|
168
284
|
|
|
169
|
-
|
|
285
|
+
## Broadcast actions
|
|
286
|
+
|
|
287
|
+
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:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
// actions.ts (excerpt)
|
|
291
|
+
import { Action, Distribution } from "march-hare";
|
|
170
292
|
|
|
171
|
-
```tsx
|
|
172
293
|
class BroadcastActions {
|
|
173
294
|
static Name = Action<string>("Name", Distribution.Broadcast);
|
|
174
295
|
}
|
|
175
296
|
|
|
176
|
-
class Actions {
|
|
297
|
+
export class Actions {
|
|
177
298
|
static Broadcast = BroadcastActions;
|
|
178
299
|
static Profile = Action<string>("Profile");
|
|
179
300
|
}
|
|
180
301
|
```
|
|
181
302
|
|
|
182
|
-
|
|
303
|
+
Inside `useActions`, the handler fetches the user and then dispatches the broadcast so siblings see the result:
|
|
304
|
+
|
|
305
|
+
```ts
|
|
183
306
|
actions.useAction(Actions.Profile, async (context) => {
|
|
184
307
|
context.actions.produce(
|
|
185
308
|
({ model }) =>
|
|
186
309
|
void (model.name = context.actions.annotate(model.name, Op.Update)),
|
|
187
310
|
);
|
|
188
311
|
|
|
189
|
-
const
|
|
312
|
+
const user = await context.actions.resource(resource.user());
|
|
190
313
|
|
|
191
|
-
context.actions.produce(({ model }) => void (model.name =
|
|
314
|
+
context.actions.produce(({ model }) => void (model.name = user.name));
|
|
192
315
|
|
|
193
|
-
context.actions.dispatch(Actions.Broadcast.Name,
|
|
316
|
+
context.actions.dispatch(Actions.Broadcast.Name, user.name);
|
|
194
317
|
});
|
|
195
318
|
```
|
|
196
319
|
|
|
197
|
-
|
|
320
|
+
Any component whose `useActions` subscribes to the broadcast receives it:
|
|
198
321
|
|
|
199
|
-
```
|
|
322
|
+
```ts
|
|
200
323
|
actions.useAction(Actions.Broadcast.Name, async (context, name) => {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
context.actions.produce(({ model }) => void (model.friends = data));
|
|
324
|
+
const friends = await context.actions.resource(resource.friends({ name }));
|
|
325
|
+
context.actions.produce(({ model }) => void (model.friends = friends));
|
|
204
326
|
});
|
|
205
327
|
```
|
|
206
328
|
|
|
207
|
-
|
|
329
|
+
`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:
|
|
208
330
|
|
|
209
|
-
```
|
|
331
|
+
```ts
|
|
210
332
|
actions.useAction(Actions.FetchFriends, async (context) => {
|
|
211
|
-
const name = await context.actions.
|
|
333
|
+
const name = await context.actions.final(Actions.Broadcast.Name);
|
|
212
334
|
if (!name) return;
|
|
213
|
-
const
|
|
214
|
-
context.actions.produce(({ model }) => void (model.friends =
|
|
335
|
+
const friends = await context.actions.resource(resource.friends({ name }));
|
|
336
|
+
context.actions.produce(({ model }) => void (model.friends = friends));
|
|
215
337
|
});
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
`peek` is useful for guard checks or synchronous reads where you don't need to wait for settled state:
|
|
219
338
|
|
|
220
|
-
```tsx
|
|
221
339
|
actions.useAction(Actions.Check, (context) => {
|
|
222
340
|
const name = context.actions.peek(Actions.Broadcast.Name);
|
|
223
341
|
if (!name) return;
|
|
@@ -225,9 +343,9 @@ actions.useAction(Actions.Check, (context) => {
|
|
|
225
343
|
});
|
|
226
344
|
```
|
|
227
345
|
|
|
228
|
-
Dispatch is awaitable – `context.actions.dispatch` returns a `Promise<void>` that resolves when
|
|
346
|
+
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:
|
|
229
347
|
|
|
230
|
-
```
|
|
348
|
+
```ts
|
|
231
349
|
actions.useAction(Actions.Mount, async (context) => {
|
|
232
350
|
// Wait for all PaymentSent handlers across the app to finish.
|
|
233
351
|
await context.actions.dispatch(Actions.Broadcast.PaymentSent);
|
|
@@ -242,12 +360,27 @@ Generator handlers are excluded from the await — they run in the backgroun
|
|
|
242
360
|
You can also render broadcast values declaratively in JSX with `actions.stream`. The renderer callback receives `(value, inspect)` and returns React nodes:
|
|
243
361
|
|
|
244
362
|
```tsx
|
|
245
|
-
|
|
246
|
-
|
|
363
|
+
import { app } from "./app";
|
|
364
|
+
|
|
365
|
+
type Model = {
|
|
366
|
+
/* ... */
|
|
367
|
+
};
|
|
368
|
+
const model: Model = {
|
|
369
|
+
/* ... */
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
function useActions() {
|
|
373
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
374
|
+
const actions = context.useActions(model);
|
|
375
|
+
return actions;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export default function Dashboard(): React.ReactElement {
|
|
379
|
+
const [, actions] = useActions();
|
|
247
380
|
|
|
248
381
|
return (
|
|
249
382
|
<div>
|
|
250
|
-
{actions.stream(Actions.Broadcast.User, (user
|
|
383
|
+
{actions.stream(Actions.Broadcast.User, (user) => (
|
|
251
384
|
<span>Welcome, {user.name}</span>
|
|
252
385
|
))}
|
|
253
386
|
</div>
|
|
@@ -257,84 +390,126 @@ function Dashboard() {
|
|
|
257
390
|
|
|
258
391
|
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.
|
|
259
392
|
|
|
260
|
-
|
|
393
|
+
## Remote data with `Resource`
|
|
261
394
|
|
|
262
|
-
For remote data, declare
|
|
395
|
+
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 Env snapshot). 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"`):
|
|
263
396
|
|
|
264
397
|
```ts
|
|
265
398
|
// resources.ts
|
|
266
|
-
import {
|
|
399
|
+
import { app } from "./app";
|
|
267
400
|
|
|
268
|
-
export const user = Resource((
|
|
269
|
-
ky.get("/api/user", { signal: controller.signal }).json<User>(),
|
|
401
|
+
export const user = app.Resource<User>((context) =>
|
|
402
|
+
ky.get("/api/user", { signal: context.controller.signal }).json<User>(),
|
|
270
403
|
);
|
|
271
404
|
|
|
272
|
-
export const pay = Resource<Receipt, Body>((
|
|
405
|
+
export const pay = app.Resource<Receipt, Body>((context) =>
|
|
273
406
|
ky
|
|
274
|
-
.post("/api/pay", {
|
|
407
|
+
.post("/api/pay", {
|
|
408
|
+
json: context.params,
|
|
409
|
+
signal: context.controller.signal,
|
|
410
|
+
})
|
|
275
411
|
.json<Receipt>(),
|
|
276
412
|
);
|
|
277
413
|
```
|
|
278
414
|
|
|
279
415
|
```tsx
|
|
280
|
-
// actions.ts
|
|
281
|
-
import {
|
|
282
|
-
import {
|
|
416
|
+
// profile/actions.ts
|
|
417
|
+
import { Action, Lifecycle, type Maybe } from "march-hare";
|
|
418
|
+
import { app } from "../app";
|
|
419
|
+
import * as resource from "../resources";
|
|
283
420
|
|
|
284
|
-
|
|
285
|
-
|
|
421
|
+
type Model = { user: Maybe<User>; receipt: Maybe<Receipt> };
|
|
422
|
+
|
|
423
|
+
export class Actions {
|
|
424
|
+
static Mount = Lifecycle.Mount();
|
|
425
|
+
static Submit = Action<Body>("Submit");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function useActions() {
|
|
429
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
430
|
+
const actions = context.useActions({
|
|
286
431
|
// Sync cache read at the model literal — returns null when nothing is cached.
|
|
287
|
-
user: user(),
|
|
432
|
+
user: resource.user(),
|
|
288
433
|
receipt: null,
|
|
289
434
|
});
|
|
290
435
|
|
|
291
436
|
actions.useAction(Actions.Mount, async (context) => {
|
|
292
|
-
const
|
|
293
|
-
|
|
437
|
+
const user = await context.actions
|
|
438
|
+
.resource(resource.user())
|
|
439
|
+
.exceeds({ minutes: 5 });
|
|
440
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
294
441
|
});
|
|
295
442
|
|
|
296
443
|
actions.useAction(Actions.Submit, async (context, body) => {
|
|
297
|
-
const
|
|
298
|
-
context.actions.produce(({ model }) => void (model.receipt =
|
|
444
|
+
const pay = await context.actions.resource(resource.pay(body));
|
|
445
|
+
context.actions.produce(({ model }) => void (model.receipt = pay));
|
|
299
446
|
});
|
|
300
447
|
|
|
301
448
|
return actions;
|
|
302
449
|
}
|
|
450
|
+
|
|
451
|
+
export default function Profile(): React.ReactElement {
|
|
452
|
+
const [model, actions] = useActions();
|
|
453
|
+
// ...
|
|
454
|
+
}
|
|
303
455
|
```
|
|
304
456
|
|
|
305
|
-
`context.actions.resource(invocation)` returns a thenable
|
|
457
|
+
`context.actions.resource(invocation)` returns a chainable thenable:
|
|
306
458
|
|
|
307
|
-
|
|
459
|
+
- `.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.
|
|
460
|
+
- `.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.
|
|
308
461
|
|
|
309
462
|
```ts
|
|
310
|
-
|
|
311
|
-
ky.get("/api/user", { signal: controller.signal }).json<User>(),
|
|
312
|
-
);
|
|
313
|
-
|
|
463
|
+
// Mount and a broadcast handler both fire on mount — only one network request.
|
|
314
464
|
actions.useAction(Actions.Mount, async (context) => {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
465
|
+
const user = await context.actions.resource(resource.user()).coalesce();
|
|
466
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
actions.useAction(Actions.Broadcast.UserId, async (context, id) => {
|
|
470
|
+
const user = await context.actions.resource(resource.user({ id })).coalesce();
|
|
471
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
318
472
|
});
|
|
319
473
|
```
|
|
320
474
|
|
|
321
|
-
|
|
475
|
+
For finer-grained control — separating concurrent fetches into distinct coalesce groups via a token argument — see the [coalesce tokens recipe](./recipes/coalesce-tokens.md).
|
|
476
|
+
|
|
477
|
+
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:
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
// resources.ts
|
|
481
|
+
export const user = app.Resource<User>(async (context) => {
|
|
482
|
+
const data = await ky
|
|
483
|
+
.get("/api/user", { signal: context.controller.signal })
|
|
484
|
+
.json<User>();
|
|
485
|
+
await context.dispatch(Actions.Broadcast.UserUpdated, data);
|
|
486
|
+
return data;
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`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:
|
|
322
491
|
|
|
323
492
|
```ts
|
|
324
493
|
type Params = { cursor: string | null };
|
|
325
494
|
|
|
326
|
-
export const feed = Resource<Page<Item>, Params>((
|
|
495
|
+
export const feed = app.Resource<Page<Item>, Params>((context) =>
|
|
327
496
|
http
|
|
328
497
|
.get("feed", {
|
|
329
|
-
searchParams: { cursor: params.cursor ?? "" },
|
|
330
|
-
signal: controller.signal,
|
|
498
|
+
searchParams: { cursor: context.params.cursor ?? "" },
|
|
499
|
+
signal: context.controller.signal,
|
|
331
500
|
})
|
|
332
501
|
.json<Page<Item>>(),
|
|
333
502
|
);
|
|
503
|
+
```
|
|
334
504
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
)
|
|
505
|
+
```ts
|
|
506
|
+
// Inside useActions:
|
|
507
|
+
actions.useAction(Actions.LoadMore, async (context) => {
|
|
508
|
+
const feed = await context.actions.resource(
|
|
509
|
+
resource.feed({ cursor: context.model.cursor }),
|
|
510
|
+
);
|
|
511
|
+
// ...
|
|
512
|
+
});
|
|
338
513
|
```
|
|
339
514
|
|
|
340
515
|
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.
|
|
@@ -344,8 +519,8 @@ For typed failure routing, wrap the call in `try/catch` and use `instanceof` &nd
|
|
|
344
519
|
```ts
|
|
345
520
|
actions.useAction(Actions.Mount, async (context) => {
|
|
346
521
|
try {
|
|
347
|
-
const
|
|
348
|
-
context.actions.produce(({ model }) => void (model.user =
|
|
522
|
+
const user = await context.actions.resource(resource.user());
|
|
523
|
+
context.actions.produce(({ model }) => void (model.user = user));
|
|
349
524
|
} catch (error) {
|
|
350
525
|
if (error instanceof RateLimitedError) {
|
|
351
526
|
await context.actions.dispatch(
|
|
@@ -360,13 +535,12 @@ actions.useAction(Actions.Mount, async (context) => {
|
|
|
360
535
|
|
|
361
536
|
See the [Resource recipe](./recipes/use-resource.md) for the three-tier error handling model, parameterised resources, and limitations.
|
|
362
537
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
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.
|
|
538
|
+
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:
|
|
366
539
|
|
|
367
540
|
```ts
|
|
368
541
|
// resources.ts
|
|
369
|
-
import { Cache
|
|
542
|
+
import { Cache } from "march-hare";
|
|
543
|
+
import { app } from "./app";
|
|
370
544
|
|
|
371
545
|
const cache = Cache({
|
|
372
546
|
get: (key) => localStorage.getItem(key),
|
|
@@ -375,136 +549,204 @@ const cache = Cache({
|
|
|
375
549
|
clear: () => localStorage.clear(),
|
|
376
550
|
});
|
|
377
551
|
|
|
378
|
-
export const cat = Resource(
|
|
379
|
-
|
|
380
|
-
cache,
|
|
552
|
+
export const cat = app.Resource.Cachable(cache, (context) =>
|
|
553
|
+
fetchCat(context.controller.signal),
|
|
381
554
|
);
|
|
382
555
|
```
|
|
383
556
|
|
|
384
|
-
```
|
|
385
|
-
// actions.ts
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
});
|
|
557
|
+
```tsx
|
|
558
|
+
// cats/actions.ts
|
|
559
|
+
import { Lifecycle, type Maybe } from "march-hare";
|
|
560
|
+
import { app } from "../app";
|
|
561
|
+
import * as resource from "../resources";
|
|
390
562
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
563
|
+
type Model = { cat: Maybe<Cat> };
|
|
564
|
+
|
|
565
|
+
export class Actions {
|
|
566
|
+
static Mount = Lifecycle.Mount();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function useActions() {
|
|
570
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
571
|
+
const actions = context.useActions({
|
|
572
|
+
// First render reads the Cache automatically.
|
|
573
|
+
cat: resource.cat(),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
actions.useAction(Actions.Mount, async (context) => {
|
|
577
|
+
// Short-circuits when the persisted payload is < 5 minutes old.
|
|
578
|
+
// The Cache writes through automatically on success.
|
|
579
|
+
const cat = await context.actions
|
|
580
|
+
.resource(resource.cat())
|
|
581
|
+
.exceeds({ minutes: 5 });
|
|
582
|
+
context.actions.produce(({ model }) => void (model.cat = cat));
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return actions;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export default function CatCard(): React.ReactElement {
|
|
589
|
+
const [model] = useActions();
|
|
590
|
+
return model.cat ? <img src={model.cat.url} /> : null;
|
|
591
|
+
}
|
|
397
592
|
```
|
|
398
593
|
|
|
399
594
|
`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.
|
|
400
595
|
|
|
401
596
|
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".
|
|
402
597
|
|
|
403
|
-
|
|
598
|
+
## Channeled actions
|
|
599
|
+
|
|
600
|
+
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:
|
|
404
601
|
|
|
405
602
|
```tsx
|
|
406
|
-
|
|
407
|
-
|
|
603
|
+
import { Action } from "march-hare";
|
|
604
|
+
import { app } from "./app";
|
|
605
|
+
|
|
606
|
+
export class Actions {
|
|
607
|
+
// Second generic arg defines the controller type.
|
|
408
608
|
static UserUpdated = Action<User, { UserId: number }>("UserUpdated");
|
|
409
609
|
}
|
|
410
610
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
(context, user) => {
|
|
415
|
-
// Only fires when dispatched with matching UserId
|
|
416
|
-
},
|
|
417
|
-
);
|
|
611
|
+
function useActions(props: { userId: number }) {
|
|
612
|
+
const context = app.useContext<Model, typeof Actions, { userId: number }>();
|
|
613
|
+
const actions = context.useActions(model, () => ({ userId: props.userId }));
|
|
418
614
|
|
|
419
|
-
// Subscribe to
|
|
420
|
-
actions.useAction(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
);
|
|
615
|
+
// Subscribe to updates for a specific user.
|
|
616
|
+
actions.useAction(
|
|
617
|
+
Actions.UserUpdated({ UserId: props.userId }),
|
|
618
|
+
(context, user) => {
|
|
619
|
+
// Only fires when dispatched with matching UserId.
|
|
620
|
+
},
|
|
621
|
+
);
|
|
426
622
|
|
|
427
|
-
|
|
428
|
-
|
|
623
|
+
return actions;
|
|
624
|
+
}
|
|
429
625
|
|
|
430
|
-
// Dispatch to
|
|
431
|
-
actions.dispatch(Actions.UserUpdated({
|
|
626
|
+
// Dispatch to specific user.
|
|
627
|
+
actions.dispatch(Actions.UserUpdated({ UserId: user.id }), user);
|
|
432
628
|
|
|
433
|
-
// Dispatch to plain action
|
|
629
|
+
// Dispatch to plain action — ALL handlers fire (plain + all channeled).
|
|
434
630
|
actions.dispatch(Actions.UserUpdated, user);
|
|
435
631
|
```
|
|
436
632
|
|
|
437
|
-
Channel values support non-nullable primitives: `string`, `number`, `boolean`, or `symbol`. By convention, use uppercase keys like `{UserId: 4}` to distinguish
|
|
633
|
+
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.
|
|
438
634
|
|
|
439
|
-
|
|
635
|
+
## Multicast actions
|
|
440
636
|
|
|
441
|
-
|
|
442
|
-
import { Action, Distribution, withScope } from "march-hare";
|
|
637
|
+
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:
|
|
443
638
|
|
|
444
|
-
|
|
445
|
-
class
|
|
639
|
+
```ts
|
|
640
|
+
// scope/types.ts — multicast action class, kept separate from the local Actions.
|
|
641
|
+
import { Action, Distribution } from "march-hare";
|
|
642
|
+
|
|
643
|
+
export class MulticastActions {
|
|
446
644
|
static Update = Action<number>("Update", Distribution.Multicast);
|
|
447
645
|
}
|
|
646
|
+
```
|
|
448
647
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
648
|
+
```tsx
|
|
649
|
+
// scope/index.tsx — open the scope once.
|
|
650
|
+
import { app } from "../app";
|
|
651
|
+
import type { MulticastActions } from "./types";
|
|
652
|
+
import ScoreBoard from "./components/score-board";
|
|
653
|
+
import PlayerList from "./components/player-list";
|
|
452
654
|
|
|
453
|
-
|
|
655
|
+
export const scope = app.Scope<typeof MulticastActions>();
|
|
656
|
+
|
|
657
|
+
export default function ScoreArea(): React.ReactElement {
|
|
454
658
|
return (
|
|
455
|
-
|
|
659
|
+
<scope.Boundary>
|
|
456
660
|
<ScoreBoard />
|
|
457
661
|
<PlayerList />
|
|
458
|
-
|
|
662
|
+
</scope.Boundary>
|
|
459
663
|
);
|
|
460
664
|
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
```tsx
|
|
668
|
+
// scope/components/score-board/actions.ts — subscribe and dispatch from inside.
|
|
669
|
+
import { Action } from "march-hare";
|
|
670
|
+
import { scope } from "../../index";
|
|
671
|
+
import { MulticastActions } from "../../types";
|
|
672
|
+
|
|
673
|
+
type Model = { score: number };
|
|
674
|
+
|
|
675
|
+
// Like `Broadcast`, you also list the multicast surface on the local
|
|
676
|
+
// Actions class so the bound dispatch sees it on `Actions.Multicast.*`.
|
|
677
|
+
export class Actions {
|
|
678
|
+
static Multicast = MulticastActions;
|
|
679
|
+
static Increment = Action("Increment");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function useActions() {
|
|
683
|
+
const context = scope.useContext<Model, typeof Actions>();
|
|
684
|
+
const actions = context.useActions({ score: 0 });
|
|
685
|
+
|
|
686
|
+
actions.useAction(MulticastActions.Update, (context, score) => {
|
|
687
|
+
context.actions.produce(({ model }) => void (model.score = score));
|
|
688
|
+
});
|
|
461
689
|
|
|
462
|
-
|
|
463
|
-
|
|
690
|
+
actions.useAction(Actions.Increment, (context) => {
|
|
691
|
+
context.actions.dispatch(MulticastActions.Update, context.model.score + 1);
|
|
692
|
+
});
|
|
464
693
|
|
|
465
|
-
|
|
466
|
-
|
|
694
|
+
return actions;
|
|
695
|
+
}
|
|
467
696
|
```
|
|
468
697
|
|
|
469
|
-
|
|
698
|
+
A few rules worth knowing:
|
|
699
|
+
|
|
700
|
+
- **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.
|
|
701
|
+
- **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>()`.
|
|
702
|
+
- **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.
|
|
703
|
+
- **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()`.
|
|
470
704
|
|
|
471
705
|
See the [multicast recipe](./recipes/multicast-actions.md) for more details.
|
|
472
706
|
|
|
473
|
-
|
|
707
|
+
## Global data
|
|
708
|
+
|
|
709
|
+
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 snapshot of the Env on its args object. 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.
|
|
474
710
|
|
|
475
711
|
```ts
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
712
|
+
// app.ts
|
|
713
|
+
import { App, type Maybe } from "march-hare";
|
|
714
|
+
|
|
715
|
+
export const app = App({
|
|
716
|
+
env: {
|
|
717
|
+
session: null as Maybe<Session>,
|
|
718
|
+
operating: "idle" as "idle" | "signing-out",
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
```
|
|
486
722
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
723
|
+
```ts
|
|
724
|
+
// auth/actions.ts — every read/write is typed against the App's env shape.
|
|
725
|
+
import { Action } from "march-hare";
|
|
726
|
+
import { app } from "../app";
|
|
727
|
+
|
|
728
|
+
export class Actions {
|
|
729
|
+
static SignOut = Action("SignOut");
|
|
730
|
+
static Refresh = Action("Refresh");
|
|
731
|
+
}
|
|
491
732
|
|
|
492
|
-
|
|
493
|
-
const
|
|
733
|
+
function useActions() {
|
|
734
|
+
const context = app.useContext<void, typeof Actions>();
|
|
735
|
+
const actions = context.useActions();
|
|
494
736
|
|
|
495
737
|
actions.useAction(Actions.SignOut, async (context) => {
|
|
496
|
-
context.actions.produce(({
|
|
497
|
-
|
|
738
|
+
context.actions.produce(({ env }) => {
|
|
739
|
+
env.operating = "signing-out";
|
|
498
740
|
});
|
|
499
741
|
await api.signOut();
|
|
500
|
-
context.actions.produce(({
|
|
501
|
-
|
|
502
|
-
|
|
742
|
+
context.actions.produce(({ env }) => {
|
|
743
|
+
env.session = null;
|
|
744
|
+
env.operating = "idle";
|
|
503
745
|
});
|
|
504
746
|
});
|
|
505
747
|
|
|
506
748
|
actions.useAction(Actions.Refresh, async (context) => {
|
|
507
|
-
if (context.
|
|
749
|
+
if (context.env.operating === "signing-out") return;
|
|
508
750
|
// ...
|
|
509
751
|
});
|
|
510
752
|
|
|
@@ -512,34 +754,84 @@ export function useAuthActions() {
|
|
|
512
754
|
}
|
|
513
755
|
```
|
|
514
756
|
|
|
757
|
+
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:
|
|
758
|
+
|
|
759
|
+
```tsx
|
|
760
|
+
import { Lifecycle } from "march-hare";
|
|
761
|
+
import { app } from "./app";
|
|
762
|
+
|
|
763
|
+
export class Actions {}
|
|
764
|
+
|
|
765
|
+
function useActions() {
|
|
766
|
+
const context = app.useContext<void, typeof Actions>();
|
|
767
|
+
return context.useActions();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export default function Header(): React.ReactElement {
|
|
771
|
+
const [, actions] = useActions();
|
|
772
|
+
|
|
773
|
+
return (
|
|
774
|
+
<header>
|
|
775
|
+
{actions.stream(Lifecycle.Env, (env) =>
|
|
776
|
+
env.session ? (
|
|
777
|
+
<span>Hi, {env.session.user.name}</span>
|
|
778
|
+
) : (
|
|
779
|
+
<span>Signed out</span>
|
|
780
|
+
),
|
|
781
|
+
)}
|
|
782
|
+
</header>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
`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.
|
|
788
|
+
|
|
789
|
+
Multiple `App` instances can coexist in the same tree — each `<app.Boundary>` owns its own Env with its own type.
|
|
790
|
+
|
|
791
|
+
## Toggling boolean state
|
|
792
|
+
|
|
515
793
|
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 `With.Invert`:
|
|
516
794
|
|
|
517
795
|
```tsx
|
|
518
|
-
import {
|
|
796
|
+
import { Action, With } from "march-hare";
|
|
797
|
+
import { app } from "./app";
|
|
519
798
|
|
|
520
799
|
type Model = {
|
|
521
800
|
paymentDialog: boolean;
|
|
522
801
|
sidebar: boolean;
|
|
523
802
|
};
|
|
524
803
|
|
|
804
|
+
const model: Model = {
|
|
805
|
+
paymentDialog: false,
|
|
806
|
+
sidebar: false,
|
|
807
|
+
};
|
|
808
|
+
|
|
525
809
|
export class Actions {
|
|
526
810
|
static TogglePaymentDialog = Action("TogglePaymentDialog");
|
|
527
811
|
static ToggleSidebar = Action("ToggleSidebar");
|
|
528
812
|
}
|
|
529
813
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
814
|
+
function useActions() {
|
|
815
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
816
|
+
const actions = context.useActions(model);
|
|
817
|
+
|
|
818
|
+
actions.useAction(Actions.TogglePaymentDialog, With.Invert("paymentDialog"));
|
|
819
|
+
actions.useAction(Actions.ToggleSidebar, With.Invert("sidebar"));
|
|
534
820
|
|
|
535
|
-
actions
|
|
536
|
-
|
|
821
|
+
return actions;
|
|
822
|
+
}
|
|
537
823
|
|
|
538
|
-
|
|
539
|
-
actions
|
|
824
|
+
export default function Shell(): React.ReactElement {
|
|
825
|
+
const [model, actions] = useActions();
|
|
540
826
|
|
|
541
|
-
|
|
542
|
-
|
|
827
|
+
return (
|
|
828
|
+
<>
|
|
829
|
+
<button onClick={() => actions.dispatch(Actions.TogglePaymentDialog)}>
|
|
830
|
+
Pay
|
|
831
|
+
</button>
|
|
832
|
+
{model.paymentDialog && <PaymentDialog />}
|
|
833
|
+
</>
|
|
834
|
+
);
|
|
543
835
|
}
|
|
544
836
|
```
|
|
545
837
|
|