march-hare 0.8.0 → 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.
Files changed (36) hide show
  1. package/README.md +480 -209
  2. package/dist/{hooks → actions}/index.d.ts +2 -39
  3. package/dist/{hooks → actions}/utils.d.ts +0 -39
  4. package/dist/app/index.d.ts +112 -0
  5. package/dist/app/types.d.ts +49 -0
  6. package/dist/boundary/components/broadcast/utils.d.ts +1 -1
  7. package/dist/boundary/components/env/index.d.ts +26 -0
  8. package/dist/boundary/components/env/types.d.ts +11 -0
  9. package/dist/boundary/components/env/utils.d.ts +36 -0
  10. package/dist/boundary/components/scope/index.d.ts +1 -39
  11. package/dist/boundary/components/scope/types.d.ts +17 -13
  12. package/dist/boundary/components/scope/utils.d.ts +12 -8
  13. package/dist/boundary/components/sharing/index.d.ts +43 -0
  14. package/dist/boundary/index.d.ts +10 -10
  15. package/dist/boundary/types.d.ts +6 -16
  16. package/dist/cache/index.d.ts +4 -4
  17. package/dist/coalesce/index.d.ts +57 -0
  18. package/dist/context/index.d.ts +39 -0
  19. package/dist/context/types.d.ts +14 -0
  20. package/dist/error/index.d.ts +1 -1
  21. package/dist/error/types.d.ts +8 -19
  22. package/dist/index.d.ts +8 -13
  23. package/dist/march-hare.js +7 -5
  24. package/dist/march-hare.umd.cjs +1 -1
  25. package/dist/resource/index.d.ts +52 -78
  26. package/dist/resource/types.d.ts +83 -10
  27. package/dist/scope/index.d.ts +63 -0
  28. package/dist/scope/types.d.ts +55 -0
  29. package/dist/types/index.d.ts +77 -39
  30. package/dist/utils/index.d.ts +6 -5
  31. package/dist/with/index.d.ts +40 -0
  32. package/package.json +1 -1
  33. package/dist/boundary/components/store/index.d.ts +0 -41
  34. package/dist/boundary/components/store/types.d.ts +0 -11
  35. package/dist/boundary/components/store/utils.d.ts +0 -64
  36. /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>` Store via the global `Lifecycle.Store` broadcast.
49
+ - View-side reactivity for the per-`<app.Boundary>` Env via the global `Lifecycle.Env` broadcast.
42
50
  - React Native compatible &ndash; uses [eventemitter3](https://github.com/primus/eventemitter3) for cross-platform pub/sub.
43
51
 
44
52
  ## Getting started
45
53
 
46
- We dispatch the `Actions.Name` event upon clicking the "Sign in" button and within the component we subscribe to that same event via `useContext` so that when it's triggered it updates the model with the payload &ndash; in the React component we render `model.name`. The `With.Update` helper binds the action's payload directly to a model property.
54
+ Declare your app once via `App()` &ndash; 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
+ ```
47
62
 
48
63
  ```tsx
49
- import { useContext, Action, With } from "march-hare";
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:
73
+
74
+ ```tsx
75
+ import { Action, With } from "march-hare";
76
+ import { app } from "./app";
50
77
 
51
78
  type Model = {
52
79
  name: string | null;
@@ -61,7 +88,7 @@ export class Actions {
61
88
  }
62
89
 
63
90
  function useActions() {
64
- const context = useContext<Model, typeof Actions>();
91
+ const context = app.useContext<Model, typeof Actions>();
65
92
  const actions = context.useActions(model);
66
93
 
67
94
  actions.useAction(Actions.Name, With.Update("name"));
@@ -84,151 +111,231 @@ export default function Profile(): React.ReactElement {
84
111
  }
85
112
  ```
86
113
 
87
- When you need to do more than just assign the payload &ndash; such as making an API request &ndash; expand `useAction` to a full function. It can be synchronous, asynchronous, or even a generator. Remote data goes through `Resource` rather than a bare `fetch` &ndash; declare the resource at module scope, fetch from handlers via `context.actions.resource(...)`:
114
+ This shape &ndash; `useActions` hook, `[model, actions]` destructure in the component &ndash; 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 &ndash; an API call, for example &ndash; 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
119
 
89
120
  ```ts
90
121
  // resources.ts
91
- import { Resource } from "march-hare";
122
+ import { app } from "./app";
92
123
 
93
- export const user = Resource(({ controller }) =>
94
- 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>(),
95
126
  );
96
127
  ```
97
128
 
98
129
  ```tsx
99
- actions.useAction(Actions.Name, async (context) => {
100
- context.actions.produce(
101
- ({ model }) =>
102
- void (model.name = context.actions.annotate(model.name, Op.Update)),
103
- );
130
+ import { Action, Op } from "march-hare";
131
+ import { app } from "./app";
132
+ import * as resource from "./resources";
104
133
 
105
- // Auto-threads context.task.controller and the Store snapshot.
106
- const data = await context.actions.resource(user());
134
+ type Model = { name: string | null };
135
+ const model: Model = { name: null };
107
136
 
108
- context.actions.produce(({ model }) => void (model.name = data.name));
109
- });
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
+ }
110
164
  ```
111
165
 
112
- Notice we're using `annotate` which you can read more about in the [Immertation documentation](https://github.com/Wildhoney/Immertation). Once the request is finished we update the model again with the name fetched from the response and re-render the React component. `Resource` caches the most recent successful payload and exposes typed params &ndash; the full API is covered [further down](#remote-data).
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 &ndash; the full API is covered [further down](#remote-data-with-resource).
167
+
168
+ ## Reactive data
113
169
 
114
- If you need to access external reactive values (like props or `useState` from parent components) that always reflect the latest value even after `await` operations, pass a data callback to `context.useActions`. The same snapshot is exposed as the third tuple element so JSX and handlers read from a single named source:
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:
115
171
 
116
172
  ```tsx
117
- const context = useContext<Model, typeof Actions, { query: string }>();
118
- const actions = context.useActions(
119
- model,
120
- () => ({
121
- query: props.query,
122
- }),
123
- );
173
+ import { Action } from "march-hare";
174
+ import { app } from "./app";
175
+ import * as resource from "./resources";
124
176
 
125
- actions.useAction(Actions.Search, async (context) => {
126
- const results = await context.actions.resource(
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
- });
177
+ type Model = { results: Result[] };
178
+ const model: Model = { results: [] };
132
179
 
133
- return <input value={data.query} onChange={…} />;
134
- ```
180
+ type Props = { query: string };
135
181
 
136
- `data` is read-only from the view side &ndash; 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()` &mdash; it fires whenever `getData`'s result differs from the previous render. For more details, see the [referential equality recipe](./recipes/referential-equality.md) and the [React context in handlers recipe](./recipes/react-context-in-handlers.md).
182
+ export class Actions {
183
+ static Search = Action<string>("Search");
184
+ }
137
185
 
138
- 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 &mdash; each side wants the other to exist first. `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. See the [`useContext` recipe](./recipes/use-context.md) for the full pattern.
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
+ }
139
200
 
140
- The model defaults to `void`, so you can call `context.useActions()` with no generics or initial state when only handlers are needed:
201
+ export default function Search(props: { query: string }): React.ReactElement {
202
+ const [, actions, data] = useActions(props);
203
+
204
+ return (
205
+ <input
206
+ value={data.query}
207
+ onChange={(event) => actions.dispatch(Actions.Search, event.target.value)}
208
+ />
209
+ );
210
+ }
211
+ ```
212
+
213
+ `data` is read-only from the view side &ndash; 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()` &mdash; 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.
214
+
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 &mdash; 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
216
 
142
217
  ```tsx
143
- import { useContext, Lifecycle } from "march-hare";
218
+ import { Action } from "march-hare";
219
+ import { app } from "./app";
220
+ import * as resource from "./resources";
221
+ import { useForm } from "some-form-library";
144
222
 
145
- class Actions {
146
- static Mount = Lifecycle.Mount();
223
+ type Model = { saving: boolean };
224
+
225
+ export class Actions {
226
+ static Submit = Action("Submit");
147
227
  }
148
228
 
149
- const context = useContext<void, typeof Actions>();
150
- const actions = context.useActions();
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 }>();
233
+
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
+ });
151
239
 
152
- actions.useAction(Actions.Mount, () => {
153
- console.log("Mounted!");
154
- });
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
+ }
155
252
  ```
156
253
 
157
- If your component doesn't need local state but still needs to dispatch or listen to typed actions, call `context.useActions()` with no initial model. No state is allocated:
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 &mdash; forwarding broadcasts, triggering side-effects, bridging external systems &mdash; can call `context.useActions()` with no initial model:
158
257
 
159
258
  ```tsx
160
- import { useContext, Action, Lifecycle } from "march-hare";
259
+ import { Action } from "march-hare";
260
+ import { app } from "./app";
161
261
 
162
262
  export class Actions {
163
263
  static Ping = Action("Ping");
164
264
  }
165
265
 
166
- export default function Pinger(): React.ReactElement {
167
- const context = useContext<void, typeof Actions>();
266
+ function useActions() {
267
+ const context = app.useContext<void, typeof Actions>();
168
268
  const actions = context.useActions();
169
269
 
170
270
  actions.useAction(Actions.Ping, () => {
171
271
  console.log("Pinged!");
172
272
  });
173
273
 
274
+ return actions;
275
+ }
276
+
277
+ export default function Pinger(): React.ReactElement {
278
+ const [, actions] = useActions();
174
279
  return <button onClick={() => actions.dispatch(Actions.Ping)}>Ping</button>;
175
280
  }
176
281
  ```
177
282
 
178
- This is useful for components that only coordinate via events &ndash; forwarding broadcasts, triggering side-effects, or bridging external systems. You can still use lifecycle hooks, `context.data`, and `dispatch` as normal. See the [void model recipe](./recipes/void-model.md) for more details.
283
+ You can still use lifecycle hooks, `context.data`, and `dispatch` as normal. See the [void model recipe](./recipes/void-model.md) for more.
179
284
 
180
- Each action should be responsible for managing its own data &ndash; in this case our `Profile` action handles fetching the user but other components may want to consume it &ndash; for that we should use a broadcast action:
285
+ ## Broadcast actions
286
+
287
+ Each action should be responsible for its own data &mdash; 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";
181
292
 
182
- ```tsx
183
293
  class BroadcastActions {
184
294
  static Name = Action<string>("Name", Distribution.Broadcast);
185
295
  }
186
296
 
187
- class Actions {
297
+ export class Actions {
188
298
  static Broadcast = BroadcastActions;
189
299
  static Profile = Action<string>("Profile");
190
300
  }
191
301
  ```
192
302
 
193
- ```tsx
303
+ Inside `useActions`, the handler fetches the user and then dispatches the broadcast so siblings see the result:
304
+
305
+ ```ts
194
306
  actions.useAction(Actions.Profile, async (context) => {
195
307
  context.actions.produce(
196
308
  ({ model }) =>
197
309
  void (model.name = context.actions.annotate(model.name, Op.Update)),
198
310
  );
199
311
 
200
- const data = await context.actions.resource(user());
312
+ const user = await context.actions.resource(resource.user());
201
313
 
202
- context.actions.produce(({ model }) => void (model.name = data.name));
314
+ context.actions.produce(({ model }) => void (model.name = user.name));
203
315
 
204
- context.actions.dispatch(Actions.Broadcast.Name, data.name);
316
+ context.actions.dispatch(Actions.Broadcast.Name, user.name);
205
317
  });
206
318
  ```
207
319
 
208
- Once we have the broadcast action, if we want to listen for it and perform another operation in our local component we can do that via `useAction`:
320
+ Any component whose `useActions` subscribes to the broadcast receives it:
209
321
 
210
- ```tsx
322
+ ```ts
211
323
  actions.useAction(Actions.Broadcast.Name, async (context, name) => {
212
- const data = await context.actions.resource(friends({ name }));
213
-
214
- 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));
215
326
  });
216
327
  ```
217
328
 
218
- Both `read` and `peek` access the latest cached broadcast value without subscribing via `useAction`. The difference is that `read` waits for any pending annotations on the corresponding model field to settle before resolving, whereas `peek` returns the value immediately:
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:
219
330
 
220
- ```tsx
331
+ ```ts
221
332
  actions.useAction(Actions.FetchFriends, async (context) => {
222
- const name = await context.actions.resolution(Actions.Broadcast.Name);
333
+ const name = await context.actions.final(Actions.Broadcast.Name);
223
334
  if (!name) return;
224
- const data = await context.actions.resource(friends({ name }));
225
- context.actions.produce(({ model }) => void (model.friends = data));
335
+ const friends = await context.actions.resource(resource.friends({ name }));
336
+ context.actions.produce(({ model }) => void (model.friends = friends));
226
337
  });
227
- ```
228
-
229
- `peek` is useful for guard checks or synchronous reads where you don't need to wait for settled state:
230
338
 
231
- ```tsx
232
339
  actions.useAction(Actions.Check, (context) => {
233
340
  const name = context.actions.peek(Actions.Broadcast.Name);
234
341
  if (!name) return;
@@ -236,9 +343,9 @@ actions.useAction(Actions.Check, (context) => {
236
343
  });
237
344
  ```
238
345
 
239
- Dispatch is awaitable &ndash; `context.actions.dispatch` returns a `Promise<void>` that resolves when all triggered handlers have completed. This prevents UI flashes where local state changes before upstream handlers finish:
346
+ Dispatch is awaitable &ndash; `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
347
 
241
- ```tsx
348
+ ```ts
242
349
  actions.useAction(Actions.Mount, async (context) => {
243
350
  // Wait for all PaymentSent handlers across the app to finish.
244
351
  await context.actions.dispatch(Actions.Broadcast.PaymentSent);
@@ -253,13 +360,27 @@ Generator handlers are excluded from the await &mdash; they run in the backgroun
253
360
  You can also render broadcast values declaratively in JSX with `actions.stream`. The renderer callback receives `(value, inspect)` and returns React nodes:
254
361
 
255
362
  ```tsx
256
- function Dashboard() {
257
- const context = useContext<Model, typeof Actions>();
258
- const actions = context.useActions(initialModel);
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();
259
380
 
260
381
  return (
261
382
  <div>
262
- {actions.stream(Actions.Broadcast.User, (user, inspect) => (
383
+ {actions.stream(Actions.Broadcast.User, (user) => (
263
384
  <span>Welcome, {user.name}</span>
264
385
  ))}
265
386
  </div>
@@ -269,88 +390,126 @@ function Dashboard() {
269
390
 
270
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.
271
392
 
272
- <a id="remote-data"></a>
393
+ ## Remote data with `Resource`
273
394
 
274
- For remote data, declare a `Resource` at module scope and use it directly. `user(params)` is the unified call form &mdash; it returns the sync cache read (`User | null`) and primes a slot that `context.actions.resource(user(params))` consumes for the fetch path (with auto-threaded abort controller and Store 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 them in with named imports:
395
+ For remote data, declare an `app.Resource` at module scope. `resource.user(params)` is the unified call form &mdash; 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"`):
275
396
 
276
397
  ```ts
277
398
  // resources.ts
278
- import { Resource } from "march-hare";
399
+ import { app } from "./app";
279
400
 
280
- export const user = Resource(({ controller }) =>
281
- 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>(),
282
403
  );
283
404
 
284
- export const pay = Resource<Receipt, Body>(({ controller, params }) =>
405
+ export const pay = app.Resource<Receipt, Body>((context) =>
285
406
  ky
286
- .post("/api/pay", { json: params, signal: controller.signal })
407
+ .post("/api/pay", {
408
+ json: context.params,
409
+ signal: context.controller.signal,
410
+ })
287
411
  .json<Receipt>(),
288
412
  );
289
413
  ```
290
414
 
291
415
  ```tsx
292
- // actions.ts
293
- import { useContext } from "march-hare";
294
- import { user, pay } from "./resources";
416
+ // profile/actions.ts
417
+ import { Action, Lifecycle, type Maybe } from "march-hare";
418
+ import { app } from "../app";
419
+ import * as resource from "../resources";
295
420
 
296
- export function useActions() {
297
- const context = useContext<Model, typeof Actions>();
421
+ type Model = { user: Maybe<User>; receipt: Maybe<Receipt> };
298
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>();
299
430
  const actions = context.useActions({
300
431
  // Sync cache read at the model literal — returns null when nothing is cached.
301
- user: user(),
432
+ user: resource.user(),
302
433
  receipt: null,
303
434
  });
304
435
 
305
436
  actions.useAction(Actions.Mount, async (context) => {
306
- const data = await context.controller
307
- .resource(user())
437
+ const user = await context.actions
438
+ .resource(resource.user())
308
439
  .exceeds({ minutes: 5 });
309
- context.actions.produce(({ model }) => void (model.user = data));
440
+ context.actions.produce(({ model }) => void (model.user = user));
310
441
  });
311
442
 
312
443
  actions.useAction(Actions.Submit, async (context, body) => {
313
- const receipt = await context.actions.resource(pay(body));
314
- context.actions.produce(({ model }) => void (model.receipt = receipt));
444
+ const pay = await context.actions.resource(resource.pay(body));
445
+ context.actions.produce(({ model }) => void (model.receipt = pay));
315
446
  });
316
447
 
317
448
  return actions;
318
449
  }
450
+
451
+ export default function Profile(): React.ReactElement {
452
+ const [model, actions] = useActions();
453
+ // ...
454
+ }
319
455
  ```
320
456
 
321
- `context.actions.resource(invocation)` returns a thenable. Awaiting it fires the fetch unconditionally; chaining `.exceeds({ minutes: 5 })` short-circuits when the per-params cache age does not yet exceed the supplied freshness window. `.exceeds(duration)` accepts a `Temporal.Duration`, a `DurationLike` object, or an ISO 8601 duration string. `Temporal` is read from the host runtime &ndash; bring a polyfill (e.g. [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill)) if your target environment does not yet expose it natively.
457
+ `context.actions.resource(invocation)` returns a chainable thenable:
322
458
 
323
- `Resource` takes a single fetcher argument. The fetcher receives `{ store, controller, params }` &mdash; destructure whichever you need. There are no callbacks &ndash; no `onSuccess`, no `onError`, no injected `dispatch`. Side-effects after a run (broadcasting, analytics, model writes) live in the `useAction` handler that awaited the call, next to the rest of the flow:
459
+ - `.exceeds({ minutes: 5 })` &mdash; 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 &ndash; 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()` &mdash; 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
461
 
325
462
  ```ts
326
- export const user = Resource(({ controller }) =>
327
- ky.get("/api/user", { signal: controller.signal }).json<User>(),
328
- );
329
-
463
+ // Mount and a broadcast handler both fire on mount — only one network request.
330
464
  actions.useAction(Actions.Mount, async (context) => {
331
- const data = await context.actions.resource(user());
332
- await context.actions.dispatch(Actions.Broadcast.UserUpdated, data);
333
- context.actions.produce(({ model }) => void (model.user = data));
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));
472
+ });
473
+ ```
474
+
475
+ For finer-grained control &mdash; separating concurrent fetches into distinct coalesce groups via a token argument &mdash; see the [coalesce tokens recipe](./recipes/coalesce-tokens.md).
476
+
477
+ The fetcher receives a `context` object &mdash; read fields via `context.env`, `context.controller`, `context.params`. There are no callbacks &ndash; 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;
334
487
  });
335
488
  ```
336
489
 
337
- `params` is the second generic on `Resource` and defaults to `{}`. Declare it when the fetcher needs call-time inputs &ndash; cursors, ids, query strings, request bodies. `params` is a single object (not positional args), which keeps call sites self-documenting:
490
+ `params` is the second generic on `app.Resource` and defaults to `{}`. Declare it when the fetcher needs call-time inputs &ndash; cursors, ids, query strings, request bodies:
338
491
 
339
492
  ```ts
340
493
  type Params = { cursor: string | null };
341
494
 
342
- export const feed = Resource<Page<Item>, Params>(({ controller, params }) =>
495
+ export const feed = app.Resource<Page<Item>, Params>((context) =>
343
496
  http
344
497
  .get("feed", {
345
- searchParams: { cursor: params.cursor ?? "" },
346
- signal: controller.signal,
498
+ searchParams: { cursor: context.params.cursor ?? "" },
499
+ signal: context.controller.signal,
347
500
  })
348
501
  .json<Page<Item>>(),
349
502
  );
503
+ ```
350
504
 
351
- const page = await context.actions.resource(
352
- feed({ cursor: context.model.cursor }),
353
- );
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
+ });
354
513
  ```
355
514
 
356
515
  A complete IntersectionObserver-driven infinite-scroll demo lives at [`src/example/transactions/`](./src/example/transactions/) &ndash; mock paginated API, scroll-triggered `LoadMore`, `pending()` guard, broadcast on success.
@@ -360,8 +519,8 @@ For typed failure routing, wrap the call in `try/catch` and use `instanceof` &nd
360
519
  ```ts
361
520
  actions.useAction(Actions.Mount, async (context) => {
362
521
  try {
363
- const data = await context.actions.resource(user());
364
- context.actions.produce(({ model }) => void (model.user = data));
522
+ const user = await context.actions.resource(resource.user());
523
+ context.actions.produce(({ model }) => void (model.user = user));
365
524
  } catch (error) {
366
525
  if (error instanceof RateLimitedError) {
367
526
  await context.actions.dispatch(
@@ -376,13 +535,12 @@ actions.useAction(Actions.Mount, async (context) => {
376
535
 
377
536
  See the [Resource recipe](./recipes/use-resource.md) for the three-tier error handling model, parameterised resources, and limitations.
378
537
 
379
- ### Persisting resources across reloads
380
-
381
- By default a `Resource`'s cache is in-memory only &ndash; 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 &ndash; 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 &mdash; 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
539
 
383
540
  ```ts
384
541
  // resources.ts
385
- import { Cache, Resource } from "march-hare";
542
+ import { Cache } from "march-hare";
543
+ import { app } from "./app";
386
544
 
387
545
  const cache = Cache({
388
546
  get: (key) => localStorage.getItem(key),
@@ -391,140 +549,204 @@ const cache = Cache({
391
549
  clear: () => localStorage.clear(),
392
550
  });
393
551
 
394
- export const cat = Resource(
395
- async ({ controller }) => fetchCat(controller.signal),
396
- cache,
552
+ export const cat = app.Resource.Cachable(cache, (context) =>
553
+ fetchCat(context.controller.signal),
397
554
  );
398
555
  ```
399
556
 
400
- ```ts
401
- // actions.ts
402
- const context = useContext<Model, typeof Actions>();
403
- const actions = context.useActions({
404
- // First render reads the Cache automatically.
405
- cat: cat(),
406
- });
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";
407
562
 
408
- actions.useAction(Actions.Mount, async (context) => {
409
- // Short-circuits when the persisted payload is < 5 minutes old.
410
- // The Cache writes through automatically on success.
411
- const fresh = await context.controller
412
- .resource(cat())
413
- .exceeds({ minutes: 5 });
414
- context.actions.produce(({ model }) => void (model.cat = fresh));
415
- });
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
+ }
416
592
  ```
417
593
 
418
594
  `Cache()` with no adapter is an in-memory scope &ndash; 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
595
 
420
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".
421
597
 
422
- For targeted event delivery, use channeled actions. Define a controller type as the second generic argument and call the action with a controller object &ndash; handlers fire when the dispatch controller matches:
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 &ndash; handlers fire only when the dispatch controller matches:
423
601
 
424
602
  ```tsx
425
- class Actions {
426
- // Second generic arg defines the controller type
603
+ import { Action } from "march-hare";
604
+ import { app } from "./app";
605
+
606
+ export class Actions {
607
+ // Second generic arg defines the controller type.
427
608
  static UserUpdated = Action<User, { UserId: number }>("UserUpdated");
428
609
  }
429
610
 
430
- // Subscribe to updates for a specific user
431
- actions.useAction(
432
- Actions.UserUpdated({ UserId: props.userId }),
433
- (context, user) => {
434
- // Only fires when dispatched with matching UserId
435
- },
436
- );
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 }));
437
614
 
438
- // Subscribe to all admin user updates
439
- actions.useAction(
440
- Actions.UserUpdated({ Role: Role.Admin }),
441
- (context, user) => {
442
- // Fires for {Role: Role.Admin}, {Role: Role.Admin, UserId: 5}, etc.
443
- },
444
- );
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
+ );
445
622
 
446
- // Dispatch to specific user
447
- actions.dispatch(Actions.UserUpdated({ UserId: user.id }), user);
623
+ return actions;
624
+ }
448
625
 
449
- // Dispatch to all admin handlers
450
- actions.dispatch(Actions.UserUpdated({ Role: Role.Admin }), user);
626
+ // Dispatch to specific user.
627
+ actions.dispatch(Actions.UserUpdated({ UserId: user.id }), user);
451
628
 
452
- // Dispatch to plain action - ALL handlers fire (plain + all channeled)
629
+ // Dispatch to plain action ALL handlers fire (plain + all channeled).
453
630
  actions.dispatch(Actions.UserUpdated, user);
454
631
  ```
455
632
 
456
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.
457
634
 
458
- For scoped communication between component groups, use multicast actions with the `withScope` HOC. Each multicast action defines its own scope &ndash; pass the same action to `withScope` and to `dispatch`, no separate scope name required:
635
+ ## Multicast actions
459
636
 
460
- ```tsx
461
- 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 &mdash; `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:
462
638
 
463
- // Group multicast actions on a class named `Scope`.
464
- class Scope {
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 {
465
644
  static Update = Action<number>("Update", Distribution.Multicast);
466
645
  }
646
+ ```
467
647
 
468
- class Actions {
469
- static Increment = Action("Increment");
470
- }
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";
654
+
655
+ export const scope = app.Scope<typeof MulticastActions>();
471
656
 
472
- function ScoreArea() {
657
+ export default function ScoreArea(): React.ReactElement {
473
658
  return (
474
- <>
659
+ <scope.Boundary>
475
660
  <ScoreBoard />
476
661
  <PlayerList />
477
- </>
662
+ </scope.Boundary>
478
663
  );
479
664
  }
665
+ ```
480
666
 
481
- // Wrap the subtree where the scope applies.
482
- export default withScope(Scope.Update, ScoreArea);
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";
483
672
 
484
- // Dispatch to every component inside the scope.
485
- actions.dispatch(Scope.Update, 42);
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
+ });
689
+
690
+ actions.useAction(Actions.Increment, (context) => {
691
+ context.actions.dispatch(MulticastActions.Update, context.model.score + 1);
692
+ });
693
+
694
+ return actions;
695
+ }
486
696
  ```
487
697
 
488
- Unlike broadcast which reaches all mounted components, multicast is confined to the wrapped subtree &ndash; perfect for isolated widget groups, form sections, or distinct UI regions. Like broadcast, multicast caches dispatched values per scope &ndash; components that mount later automatically receive the cached value. See the [mount deduplication recipe](./recipes/mount-broadcast-deduplication.md) if you also fetch data in `Lifecycle.Mount()`.
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 &mdash; 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>` &mdash; 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()`.
489
704
 
490
705
  See the [multicast recipe](./recipes/multicast-actions.md) for more details.
491
706
 
492
- 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-`<Boundary>` `Store`. Declare your app's Store shape once via module augmentation, supply the initial value to `<Boundary store={...}>`, read via dot notation (`store.session`, `context.store.locale`), and write via `context.actions.produce(({ store }) => { ... })` &mdash; the same Immer-style recipe used for the model. Every `Resource` fetcher also receives a snapshot of the Store on its args object. When the view side needs to react to Store changes, subscribe to the global `Lifecycle.Store` broadcast &mdash; `actions.useAction(Lifecycle.Store, handler)` for handler-level work and `actions.stream(Lifecycle.Store, (store) => ...)` for JSX. Both seed from the initial Store on mount.
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 }) => { ... })` &mdash; 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 &mdash; `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
710
 
494
711
  ```ts
495
- import { useContext } from "march-hare";
496
-
497
- // Declare your Store's shape once. Every read/write is typed against this.
498
- declare module "march-hare" {
499
- // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
500
- interface Store {
501
- session: Session | null;
502
- operating: "idle" | "signing-out";
503
- }
504
- }
712
+ // app.ts
713
+ import { App, type Maybe } from "march-hare";
505
714
 
506
- // Wire the initial Store into Boundary at app root.
507
- <Boundary store={{ session: null, operating: "idle" }}>
508
- <App />
509
- </Boundary>;
715
+ export const app = App({
716
+ env: {
717
+ session: null as Maybe<Session>,
718
+ operating: "idle" as "idle" | "signing-out",
719
+ },
720
+ });
721
+ ```
722
+
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
+ }
510
732
 
511
- export function useAuthActions() {
512
- const context = useContext<void, typeof Actions>();
733
+ function useActions() {
734
+ const context = app.useContext<void, typeof Actions>();
513
735
  const actions = context.useActions();
514
736
 
515
737
  actions.useAction(Actions.SignOut, async (context) => {
516
- context.actions.produce(({ store }) => {
517
- store.operating = "signing-out";
738
+ context.actions.produce(({ env }) => {
739
+ env.operating = "signing-out";
518
740
  });
519
741
  await api.signOut();
520
- context.actions.produce(({ store }) => {
521
- store.session = null;
522
- store.operating = "idle";
742
+ context.actions.produce(({ env }) => {
743
+ env.session = null;
744
+ env.operating = "idle";
523
745
  });
524
746
  });
525
747
 
526
748
  actions.useAction(Actions.Refresh, async (context) => {
527
- if (context.store.operating === "signing-out") return;
749
+ if (context.env.operating === "signing-out") return;
528
750
  // ...
529
751
  });
530
752
 
@@ -532,35 +754,84 @@ export function useAuthActions() {
532
754
  }
533
755
  ```
534
756
 
757
+ For the view side, render against `Lifecycle.Env` with `actions.stream` &mdash; 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 &mdash; each `<app.Boundary>` owns its own Env with its own type.
790
+
791
+ ## Toggling boolean state
792
+
535
793
  Toggling boolean UI state &ndash; modals, sidebars, drawers &ndash; is one of the most common patterns. Bind a unicast action to a boolean field on the model with `With.Invert`:
536
794
 
537
795
  ```tsx
538
- import { useContext, Action, With } from "march-hare";
796
+ import { Action, With } from "march-hare";
797
+ import { app } from "./app";
539
798
 
540
799
  type Model = {
541
800
  paymentDialog: boolean;
542
801
  sidebar: boolean;
543
802
  };
544
803
 
804
+ const model: Model = {
805
+ paymentDialog: false,
806
+ sidebar: false,
807
+ };
808
+
545
809
  export class Actions {
546
810
  static TogglePaymentDialog = Action("TogglePaymentDialog");
547
811
  static ToggleSidebar = Action("ToggleSidebar");
548
812
  }
549
813
 
550
- const context = useContext<Model, typeof Actions>();
551
- const actions = context.useActions({
552
- paymentDialog: false,
553
- sidebar: false,
554
- });
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"));
555
820
 
556
- actions.useAction(Actions.TogglePaymentDialog, With.Invert("paymentDialog"));
557
- actions.useAction(Actions.ToggleSidebar, With.Invert("sidebar"));
821
+ return actions;
822
+ }
558
823
 
559
- // Dispatch from anywhere with access to the actions object.
560
- actions.dispatch(Actions.TogglePaymentDialog);
824
+ export default function Shell(): React.ReactElement {
825
+ const [model, actions] = useActions();
561
826
 
562
- {
563
- model.paymentDialog && <PaymentDialog />;
827
+ return (
828
+ <>
829
+ <button onClick={() => actions.dispatch(Actions.TogglePaymentDialog)}>
830
+ Pay
831
+ </button>
832
+ {model.paymentDialog && <PaymentDialog />}
833
+ </>
834
+ );
564
835
  }
565
836
  ```
566
837