chizu 0.2.36 → 0.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ Strongly typed React framework using generators and efficiently updated views al
18
18
  1. [Lifecycle actions](#lifecycle-actions)
19
19
  1. [Distributed actions](#distributed-actions)
20
20
  1. [Action decorators](#action-decorators)
21
+ 1. [Utility functions](#utility-functions)
21
22
 
22
23
  ## Benefits
23
24
 
@@ -26,10 +27,9 @@ Strongly typed React framework using generators and efficiently updated views al
26
27
  - Built-in support for [optimistic updates](https://medium.com/@kyledeguzmanx/what-are-optimistic-updates-483662c3e171) within components.
27
28
  - Mostly standard JavaScript without quirky rules and exceptions.
28
29
  - Clear separation of concerns between business logic and markup.
29
- - First-class support for skeleton loading using generators.
30
30
  - Strongly typed throughout – dispatches, models, etc…
31
31
  - Easily communicate between actions using distributed actions.
32
- - Bundled decorators for common action functionality such as exclusive mode and reactive triggers.
32
+ - Bundled decorators for common action functionality such as supplant mode and reactive triggers.
33
33
  - No need to worry about referential equality – reactive dependencies use primitives only.
34
34
  - Built-in request cancellation with `AbortController` integration.
35
35
  - Granular async state tracking per model field (pending, draft, operation type).
@@ -75,6 +75,20 @@ export default function Profile(props: Props): React.ReactElement {
75
75
  }
76
76
  ```
77
77
 
78
+ Notice `createAction<string>()` takes a generic to specify the payload type. When using `useAction`, the payload is accessible as the second argument after `context`. The third generic in `useAction<Model, typeof Actions, "Name">` extracts the correct payload type from the `Actions` class:
79
+
80
+ ```tsx
81
+ export class Actions {
82
+ static Name = createAction<string>();
83
+ }
84
+
85
+ const nameAction = useAction<Model, typeof Actions, "Name">(
86
+ async (context, payload) => {
87
+ // payload is correctly typed as `string`
88
+ },
89
+ );
90
+ ```
91
+
78
92
  You can perform asynchronous operations in the action which will cause the associated view to render a second time &ndash; as we're starting to require more control in our actions we&apos;ll move to our own fine-tuned action instead of `utils.set`:
79
93
 
80
94
  ```tsx
@@ -128,21 +142,96 @@ export default function Profile(props: Props): React.ReactElement {
128
142
 
129
143
  ## Error handling
130
144
 
131
- Chizu provides a simple way to catch errors that occur within your actions. You can use the `Error` component to wrap your application and provide an error handler. This handler will be called whenever an error is thrown in an action.
145
+ Chizu provides a simple way to catch errors that occur within your actions. Use the `Error` component to wrap your application and provide an error handler. The handler receives an `ErrorDetails` object containing information about the error:
132
146
 
133
147
  ```tsx
134
- import { Error } from "chizu";
148
+ import { Error, Reason } from "chizu";
135
149
 
136
- const App = () => (
137
- <Error handler={(error) => console.error(error)}>
138
- <Profile />
139
- </Error>
140
- );
150
+ function App(): ReactElement {
151
+ return (
152
+ <Error
153
+ handler={({ reason, error, action }) => {
154
+ switch (reason) {
155
+ case Reason.Timeout:
156
+ console.warn(`Action "${action}" timed out:`, error.message);
157
+ break;
158
+ case Reason.Aborted:
159
+ console.info(`Action "${action}" was aborted`);
160
+ break;
161
+ case Reason.Error:
162
+ console.error(`Action "${action}" failed:`, error.message);
163
+ break;
164
+ }
165
+ }}
166
+ >
167
+ <Profile />
168
+ </Error>
169
+ );
170
+ }
171
+ ```
172
+
173
+ The `ErrorDetails` object contains:
174
+
175
+ - **`reason`** &ndash; One of `Reason.Timeout` (action exceeded timeout set via `@use.timeout()`), `Reason.Aborted` (action was cancelled, e.g., by `@use.supplant()`), or `Reason.Error` (an error thrown in your action handler).
176
+ - **`error`** &ndash; The `Error` object that was thrown.
177
+ - **`action`** &ndash; The name of the action that caused the error (e.g., `"Increment"`).
178
+ - **`handled`** &ndash; Whether the error was handled locally via `Lifecycle.Error`. Use this in the global `<Error>` handler to avoid duplicate handling.
179
+
180
+ ### Custom error types
181
+
182
+ The `Error` component accepts an optional generic type parameter to include custom error classes in the handler's error type &mdash; custom error types must extend the base `Error` class:
183
+
184
+ ```tsx
185
+ function App(): ReactElement {
186
+ return (
187
+ <Error<ApiError | ValidationError>
188
+ handler={({ error }) => {
189
+ if (error instanceof ApiError) {
190
+ console.error(`API error ${error.statusCode}: ${error.message}`);
191
+ } else if (error instanceof ValidationError) {
192
+ console.error(`Validation failed: ${error.field} - ${error.message}`);
193
+ } else {
194
+ console.error(`Unknown error: ${error.message}`);
195
+ }
196
+ }}
197
+ >
198
+ <Profile />
199
+ </Error>
200
+ );
201
+ }
202
+ ```
203
+
204
+ **Note:** For the `action` name to be meaningful, pass a name when creating actions:
205
+
206
+ ```ts
207
+ export class Actions {
208
+ static Increment = createAction("Increment");
209
+ static Decrement = createAction("Decrement");
210
+ }
141
211
  ```
142
212
 
213
+ ### Cross-platform error classes
214
+
215
+ Chizu provides `AbortError` and `TimeoutError` classes that work across all platforms including React Native (where `DOMException` is unavailable):
216
+
217
+ ```ts
218
+ import { AbortError, TimeoutError } from "chizu";
219
+
220
+ // Used internally by Chizu for abort/timeout handling
221
+ // Can also be used in your own code for consistency
222
+ throw new AbortError("Operation cancelled");
223
+ throw new TimeoutError("Request timed out");
224
+ ```
225
+
226
+ ### Error handling philosophy
227
+
228
+ Actions should ideally be self-contained and handle expected errors internally using patterns like [Option](https://mobily.github.io/ts-belt/api/option) or [Result](https://mobily.github.io/ts-belt/api/result) types to update the model accordingly. `Lifecycle.Error` is intended for timeouts, aborts, and uncaught catastrophic errors &ndash; not routine error handling.
229
+
230
+ The `<Error>` component is a catch-all for errors from **any** action in your application, useful for global error reporting or logging. `Lifecycle.Error` handles errors **locally** where they occurred, allowing component-specific error recovery or UI updates.
231
+
143
232
  ## Model annotations
144
233
 
145
- Model annotations allow you to track the state of async operations on individual model fields. This is useful for showing loading indicators, optimistic updates, and tracking pending changes.
234
+ Model annotations allow you to track the state of async operations on individual model fields. This is useful for showing loading indicators, optimistic updates, and tracking pending changes. Annotations are powered by [Immertation](https://github.com/Wildhoney/Immertation) &ndash; refer to its documentation for more details.
146
235
 
147
236
  Use `context.actions.annotate` to mark a value with an operation type. The view can then inspect the field to check if it's pending, get the draft value, or check the operation type:
148
237
 
@@ -173,14 +262,20 @@ import { Lifecycle } from "chizu";
173
262
  class {
174
263
  [Lifecycle.Mount] = mountAction;
175
264
  [Lifecycle.Node] = nodeAction;
265
+ [Lifecycle.Error] = errorAction;
176
266
  [Lifecycle.Unmount] = unmountAction;
177
267
  }
178
268
  ```
179
269
 
180
270
  - **`Lifecycle.Mount`** &ndash; Triggered once when the component mounts (`useLayoutEffect`).
181
271
  - **`Lifecycle.Node`** &ndash; Triggered after the component renders (`useEffect`).
272
+ - **`Lifecycle.Error`** &ndash; Triggered when an action throws an error. Receives `ErrorDetails` as payload.
182
273
  - **`Lifecycle.Unmount`** &ndash; Triggered when the component unmounts.
183
274
 
275
+ **Note:** Actions should ideally be self-contained and handle expected errors internally using patterns like [Option](https://mobily.github.io/ts-belt/api/option) or [Result](https://mobily.github.io/ts-belt/api/result) types to update the model accordingly. `Lifecycle.Error` is intended for timeouts, aborts, and uncaught catastrophic errors &ndash; not routine error handling.
276
+
277
+ The `<Error>` component is a catch-all for errors from **any** action in your application, useful for global error reporting or logging. `Lifecycle.Error` handles errors **locally** where they occurred, allowing component-specific error recovery or UI updates.
278
+
184
279
  ## Distributed actions
185
280
 
186
281
  Distributed actions allow different components to communicate with each other. Unlike regular actions which are scoped to a single component, distributed actions are broadcast to all mounted components that have defined a handler for them.
@@ -199,7 +294,21 @@ export class Actions extends DistributedActions {
199
294
  }
200
295
  ```
201
296
 
202
- Any component that defines a handler for `DistributedActions.SignedOut` will receive the action when it's dispatched from any other component. For direct access to the broadcast emitter, use `useBroadcast()`.
297
+ Any component that defines a handler for `DistributedActions.SignedOut` will receive the action when it's dispatched from any other component. For direct access to the broadcast emitter, use `useBroadcast()`:
298
+
299
+ ```ts
300
+ import { useBroadcast } from "chizu";
301
+
302
+ const broadcast = useBroadcast();
303
+
304
+ // Emit a distributed action
305
+ broadcast.emit(DistributedActions.SignedOut, payload);
306
+
307
+ // Listen for a distributed action
308
+ broadcast.on(DistributedActions.SignedOut, (payload) => {
309
+ // Handle the action...
310
+ });
311
+ ```
203
312
 
204
313
  ## Action decorators
205
314
 
@@ -209,9 +318,9 @@ Chizu provides decorators to add common functionality to your actions. Import `u
209
318
  import { use } from "chizu";
210
319
  ```
211
320
 
212
- ### `use.exclusive()`
321
+ ### `use.supplant()`
213
322
 
214
- Ensures only one instance of an action runs at a time. When a new action is dispatched, any previous running instance is automatically aborted. Use `context.signal` to cancel in-flight requests:
323
+ Ensures only one instance of an action runs at a time. When a new action is dispatched, any previous running instance is automatically aborted. Use `context.signal` to cancel in-flight requests. When an action is aborted, the error handler receives `Reason.Aborted`:
215
324
 
216
325
  ```ts
217
326
  const searchAction = useAction<Model, typeof Actions, "Search">(
@@ -225,7 +334,7 @@ const searchAction = useAction<Model, typeof Actions, "Search">(
225
334
  return useActions<Model, typeof Actions>(
226
335
  model,
227
336
  class {
228
- @use.exclusive()
337
+ @use.supplant()
229
338
  [Actions.Search] = searchAction;
230
339
  },
231
340
  );
@@ -252,3 +361,131 @@ class {
252
361
  [Actions.Submit] = submitAction;
253
362
  }
254
363
  ```
364
+
365
+ ### `use.timeout(ms)`
366
+
367
+ Aborts the action if it exceeds the specified duration. Triggers the abort signal via `context.signal`, allowing the action to clean up gracefully. Useful for preventing stuck states and enforcing response time limits. When a timeout occurs, the error handler receives `Reason.Timeout`:
368
+
369
+ ```ts
370
+ class {
371
+ @use.timeout(5_000)
372
+ [Actions.FetchData] = fetchDataAction;
373
+ }
374
+ ```
375
+
376
+ ### `use.debounce(ms)`
377
+
378
+ Delays action execution until no new dispatches occur for the specified duration. Useful for search inputs, form validation, and auto-save functionality:
379
+
380
+ ```ts
381
+ class {
382
+ @use.debounce(300)
383
+ [Actions.Search] = searchAction;
384
+ }
385
+ ```
386
+
387
+ ### `use.throttle(ms)`
388
+
389
+ Limits action execution to at most once per specified time window. The first call executes immediately, subsequent calls during the cooldown period are queued and the last one executes when the window expires. Useful for scroll handlers, resize events, and rate-limited APIs:
390
+
391
+ ```ts
392
+ class {
393
+ @use.throttle(500)
394
+ [Actions.TrackScroll] = trackScrollAction;
395
+ }
396
+ ```
397
+
398
+ ### `use.retry(intervals)`
399
+
400
+ Automatically retries failed actions with specified delay intervals. Respects the abort signal and stops retrying if aborted. Useful for network requests and other operations that may fail transiently:
401
+
402
+ ```ts
403
+ class {
404
+ @use.retry([1_000, 2_000, 4_000])
405
+ [Actions.FetchData] = fetchDataAction;
406
+ }
407
+ ```
408
+
409
+ The intervals array specifies delays between retries. The example above will retry up to 3 times: first retry after 1s, second after 2s, third after 4s. Default intervals are `[1_000, 2_000, 4_000]`.
410
+
411
+ ### Combining decorators
412
+
413
+ Decorators can be combined for powerful control flow. Apply them top-to-bottom in execution order:
414
+
415
+ ```ts
416
+ class {
417
+ @use.supplant() // 1. Cancel previous calls
418
+ @use.retry() // 2. Retry on failure
419
+ @use.timeout(5_000) // 3. Timeout each attempt
420
+ [Actions.FetchData] = fetchDataAction;
421
+ }
422
+ ```
423
+
424
+ ## Utility functions
425
+
426
+ Chizu provides a set of utility functions via the `utils` namespace to help with common patterns. Each utility also has a shorthand Greek letter alias for concise code.
427
+
428
+ ```ts
429
+ import { utils } from "chizu";
430
+ ```
431
+
432
+ ### `utils.set(property)` / `utils.λ`
433
+
434
+ Creates a generic setter action that updates a specific property in the state. Useful for simple state updates without writing a full action handler:
435
+
436
+ ```ts
437
+ class {
438
+ [Actions.Name] = utils.set("name");
439
+ // or using the alias:
440
+ [Actions.Name] = utils.λ("name");
441
+ }
442
+ ```
443
+
444
+ ### `utils.pk()` / `utils.κ`
445
+
446
+ Generates or validates primary keys. Particularly useful for optimistic updates where items need a temporary identifier before the database responds with the real ID. The symbol acts as a stable reference even if the item moves in an array due to concurrent async operations:
447
+
448
+ ```ts
449
+ // Optimistic update: add item with placeholder ID
450
+ const id = utils.pk();
451
+ context.actions.produce((draft) => {
452
+ draft.todos.push({ id, text: "New todo", status: "pending" });
453
+ });
454
+
455
+ // Later when the API responds, find and update with real ID
456
+ const response = await api.createTodo({ text: "New todo" });
457
+ context.actions.produce((draft) => {
458
+ const todo = draft.todos.find((todo) => todo.id === id);
459
+ if (todo) todo.id = response.id; // Replace symbol with real ID
460
+ });
461
+ ```
462
+
463
+ ### `utils.checksum(value)` / `utils.Σ`
464
+
465
+ Generates a deterministic hash string from any value. Returns `null` if the value cannot be serialised (e.g., circular references).
466
+
467
+ Particularly useful with `@use.reactive()` which only accepts primitives &ndash; use checksum to convert complex objects (like React Query data) into a primitive dependency:
468
+
469
+ ```ts
470
+ // Convert complex objects into primitive dependencies for @use.reactive()
471
+ const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser });
472
+
473
+ class {
474
+ @use.reactive(() => [utils.checksum(data)])
475
+ [Actions.SyncUser] = syncUserAction;
476
+ }
477
+ ```
478
+
479
+ ### `utils.sleep(ms, signal?)` / `utils.ζ`
480
+
481
+ Returns a promise that resolves after the specified milliseconds. Useful for simulating delays in actions during development or adding intentional pauses. Optionally accepts an `AbortSignal` to cancel the sleep early:
482
+
483
+ ```ts
484
+ const fetchAction = useAction<Model, typeof Actions, "Fetch">(
485
+ async (context) => {
486
+ await utils.sleep(1_000); // Simulate network delay
487
+ const data = await fetch("/api/data", { signal: context.signal });
488
+ // ...
489
+ },
490
+ );
491
+ ```
@@ -24,3 +24,23 @@ export declare function createDistributedAction<T = never>(name?: string): Paylo
24
24
  * @returns True if the action is a distributed action, false otherwise.
25
25
  */
26
26
  export declare function isDistributedAction(action: Action): boolean;
27
+ /**
28
+ * Extracts the action name from an action symbol.
29
+ *
30
+ * Parses both regular actions (`Symbol(chizu.action/Name)`) and
31
+ * distributed actions (`Symbol(chizu.action/distributed/Name)`)
32
+ * to extract just the name portion.
33
+ *
34
+ * @param action The action symbol to extract the name from.
35
+ * @returns The extracted action name, or "unknown" if parsing fails.
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const action = createAction("Increment");
40
+ * getActionName(action); // "Increment"
41
+ *
42
+ * const distributed = createDistributedAction("SignedOut");
43
+ * getActionName(distributed); // "SignedOut"
44
+ * ```
45
+ */
46
+ export declare function getActionName(action: Action): string;
@@ -1,8 +1,9 @@
1
1
  import { default as EventEmitter } from 'eventemitter3';
2
2
  import * as React from "react";
3
- export type BroadcastContext = {
4
- instance: EventEmitter;
5
- };
3
+ /**
4
+ * The broadcast context is an EventEmitter used for distributed actions across components.
5
+ */
6
+ export type BroadcastContext = EventEmitter;
6
7
  export type UseBroadcast = BroadcastContext;
7
8
  export type Props = {
8
9
  children: React.ReactNode;
@@ -5,8 +5,8 @@ import * as React from "react";
5
5
  */
6
6
  export declare const Context: React.Context<BroadcastContext>;
7
7
  /**
8
- * Hook to access the broadcast context for emitting and listening to distributed actions.
8
+ * Hook to access the broadcast EventEmitter for emitting and listening to distributed actions.
9
9
  *
10
- * @returns The broadcast context containing the EventEmitter instance.
10
+ * @returns The EventEmitter instance for distributed actions.
11
11
  */
12
12
  export declare function useBroadcast(): BroadcastContext;