ccstate 4.13.0 → 5.0.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 CHANGED
@@ -136,7 +136,7 @@ const otherStore = createStore(); // another new Map()
136
136
  otherStore.get(count$); // anotherMap[$count] ?? $count.init, returns 0
137
137
  ```
138
138
 
139
- This should be easy to understand. If `Store` only needed to support `State` types, a simple Map would be sufficient. However, CCState needs to support two additional signal types. Next, let's introduce `Computed`, CCState's reactive computation unit.
139
+ This should be easy to understand. If `Store` only needed to support `State` types, a simple Map would be sufficient. However, CCState needs to support another signal type. Next, let's introduce `Computed`, CCState's reactive computation unit.
140
140
 
141
141
  ### Computed
142
142
 
@@ -191,11 +191,10 @@ Since `Command` can call the `set` method, it produces side effects on the `Stor
191
191
 
192
192
  - Calling a `Command` through `store.set`
193
193
  - Being called by the `set` method within other `Command`s
194
- - Being triggered by subscription relationships established through `store.sub`
195
194
 
196
- ### Subscribing to Changes
195
+ ### Watching to Changes
197
196
 
198
- CCState provides a `sub` method on the store to establish subscription relationships.
197
+ CCState provides a `watch` method on the store to observe state change.
199
198
 
200
199
  ```typescript
201
200
  import { createStore, state, computed, command } from 'ccstate';
@@ -204,35 +203,40 @@ const base$ = state(0);
204
203
  const double$ = computed((get) => get(base$) * 2);
205
204
 
206
205
  const store = createStore();
207
- store.sub(
208
- double$,
209
- command(({ get }) => {
210
- console.log('double', get(double$));
211
- }),
212
- );
206
+ store.watch((get) => {
207
+ console.log('double', get(double$));
208
+ });
213
209
 
214
210
  store.set(base$, 10); // will log to console 'double 20'
215
211
  ```
216
212
 
217
- There are two ways to unsubscribe:
213
+ Using an AbortSignal to cancel any watcher.
214
+
215
+ ```typescript
216
+ const ctrl = new AbortController();
217
+ store.watch(
218
+ (get) => {
219
+ console.log('double', get(double$));
220
+ },
221
+ {
222
+ signal: ctrl.signal,
223
+ },
224
+ );
218
225
 
219
- 1. Using the `unsub` function returned by `store.sub`
220
- 2. Using an AbortSignal to control the subscription
226
+ ctrl.abort(); // will cancel watch
227
+ ```
221
228
 
222
- The `sub` method is powerful but should be used carefully. In most cases, `Computed` is a better choice than `sub` because `Computed` doesn't generate new `set` operations.
229
+ Watchers intentionally do not have access to the store's `set` method. CCState encourages implementing derived computation logic through `Computed` rather than modifying data through change callbacks.
223
230
 
224
231
  ```typescript
225
- // 🙅 use sub
232
+ // 🙅 use watch & store.set
226
233
  const user$ = state(undefined);
227
234
  const userId$ = state(0);
228
- store.sub(
229
- userId$,
230
- command(({ set, get }) => {
231
- const userId = get(userId$);
232
- const user = fetch('/api/users/' + userId).then((resp) => resp.json());
233
- set(user$, user);
234
- }),
235
- );
235
+ store.watch((get) => {
236
+ const userId = get(userId$);
237
+ const user = fetch('/api/users/' + userId).then((resp) => resp.json());
238
+ store.set(user$, user);
239
+ });
236
240
 
237
241
  // ✅ use computed
238
242
  const userId$ = state(0);
@@ -252,11 +256,11 @@ Here's a simple rule of thumb:
252
256
 
253
257
  ### Comprasion
254
258
 
255
- | Type | get | set | sub target | as sub callback |
256
- | -------- | --- | --- | ---------- | --------------- |
257
- | State | ✅ | ✅ | ✅ | ❌ |
258
- | Computed | ✅ | ❌ | ✅ | ❌ |
259
- | Command | ❌ | ✅ | ❌ | ✅ |
259
+ | Type | get | set |
260
+ | -------- | --- | --- |
261
+ | State | ✅ | ✅ |
262
+ | Computed | ✅ | ❌ |
263
+ | Command | ❌ | ✅ |
260
264
 
261
265
  That's it! Next, you can learn how to use CCState in React.
262
266
 
@@ -313,13 +317,85 @@ It is easy to see that `userId` passed by the function parameters do not have re
313
317
  The `Command` has the ability to receive parameters, so we can encapsulate the above example into a `Command`:
314
318
 
315
319
  ```javascript
316
- const getUser$ = command(({ set, get }, userId, sigal) => {
320
+ const getUser$ = command(({ set, get }, userId, signal) => {
317
321
  return fetch(`/api/users/${userId}`, { signal });
318
322
  });
319
323
  ```
320
324
 
321
325
  However, this method requires that the caller must also be a `Command` in order to access the `set` method, which can easily lead to destructive passing of `Command`. If a `Command` itself does not produce side effects, then it should be considered to be written as a `Computed`.
322
326
 
327
+ ### Robust Async Flow Cancellation
328
+
329
+ CCState's design philosophy aims to use the language's native capabilities for flow control as much as possible, including direct support for async/await. For Promise cancellation issues brought by asynchronous operations, CCState prefers to handle them using AbortSignal like fetch. When the following rules are satisfied, CCState can achieve very robust Async Flow cancellation capabilities, preventing unexpected side effects from occurring after the upstream task is aborted.
330
+
331
+ #### Rule 1: await in commands must perform abort signal check
332
+
333
+ ```typescript
334
+ const updateUser$ = command(async ({ get }, signal: AbortSignal) => {
335
+ const currentUser = await get(currentUser$); // Here we assume currentUser$ returns a Promise<User>
336
+ signal.throwIfAbort(); // Must perform throwIfAbort check on the line after await
337
+ });
338
+ ```
339
+
340
+ If the function after await accepts AbortSignal as a parameter, then the caller has no responsibility to perform throwIfAbort check, as it's assumed the callee has already done such check.
341
+
342
+ ```typescript
343
+ const updateUser$ = command(async ({ get, set }, name: string, signal: AbortSignal) => {
344
+ const currentUser = await get(currentUser$);
345
+ signal.throwIfAbort();
346
+
347
+ await set(updateUserName$, currentUser.id, name, signal); // Here we pass signal to the next command, so the current command doesn't need to check
348
+ });
349
+ ```
350
+
351
+ `Computed` doesn't need such checks because Computed is side-effect free.
352
+
353
+ In `watch`, although it cannot produce side effects on the Store, it may produce external side effects. In this case, signal check is also needed.
354
+
355
+ ```typescript
356
+ store.watch(
357
+ (get, { signal }) => {
358
+ void (async () => {
359
+ const user = await get(currentUser$);
360
+ signal.throwIfAbort();
361
+
362
+ syncExternalState(user); // this code will not execute when externalAbortSignal abort
363
+ })();
364
+ },
365
+ {
366
+ signal: externalAbortSignal,
367
+ },
368
+ );
369
+ ```
370
+
371
+ ### Rule 2: Do not intercept AbortError in try/catch
372
+
373
+ ```typescript
374
+ const isAbortError = (error: unknown): boolean => {
375
+ if ((error instanceof Error || error instanceof DOMException) && error.name === 'AbortError') {
376
+ return true;
377
+ }
378
+
379
+ return false;
380
+ };
381
+
382
+ function throwIfAbort(e: unknown) {
383
+ if (isAbortError(e)) {
384
+ throw e;
385
+ }
386
+ }
387
+
388
+ const updateUser$ = command(async ({ get, set }, name: string, signal: AbortSignal) => {
389
+ // ...
390
+ try {
391
+ await set(updateUserName$, currentUser.id, name, signal);
392
+ } catch (error: unknown) {
393
+ throwIfAbort(error); // do not catch any AbortError
394
+ // .. error handling
395
+ }
396
+ });
397
+ ```
398
+
323
399
  ## Using in React
324
400
 
325
401
  [Using in React](docs/react.md)
@@ -365,12 +441,8 @@ import { createDebugStore, state, computed, command } from 'ccstate';
365
441
  const base$ = state(1, { debugLabel: 'base$' });
366
442
  const derived$ = computed((get) => get(base$) * 2);
367
443
 
368
- const store = createDebugStore([base$, 'derived'], ['set', 'sub']); // log sub & set actions
444
+ const store = createDebugStore([base$, 'derived'], ['set']); // log set actions
369
445
  store.set(base$, 1); // console: SET [V0:base$] 1
370
- store.sub(
371
- derived$,
372
- command(() => void 0),
373
- ); // console: SUB [V0:derived$]
374
446
  ```
375
447
 
376
448
  ## Concept behind CCState
@@ -397,28 +469,9 @@ Like Jotai, CCState is also an Atom State solution. However, unlike Jotai, CCSta
397
469
 
398
470
  ### Subscription System
399
471
 
400
- CCState's subscription system is different from Jotai's. First, CCState's subscription callback must be an `Command`.
472
+ CCState's subscription system is very different from Jotai's. First, the method name `watch` itself implies this is a read-only operation for observing state changes, not for modifying state.
401
473
 
402
- ```typescript
403
- export const userId$ = state(1);
404
-
405
- export const userIdChange$ = command(({ get, set }) => {
406
- const userId = get(userId$);
407
- // ...
408
- });
409
-
410
- // ...
411
- import { userId$, userIdChange$ } from './signals';
412
-
413
- function setupPage() {
414
- const store = createStore();
415
- // ...
416
- store.sub(userId$, userIdChange$);
417
- // ...
418
- }
419
- ```
420
-
421
- The consideration here is to avoid having callbacks depend on the Store object, which was a key design consideration when creating CCState. In CCState, `sub` is the only API with reactive capabilities, and CCState reduces the complexity of reactive computations by limiting Store usage.
474
+ Secondly, the subscription callback receives a `get` parameter, the consideration here is to avoid having callbacks depend on the Store object, which was a key design consideration when creating CCState. In CCState, `watch` is the only API with reactive capabilities, and CCState reduces the complexity of reactive computations by limiting Store usage.
422
475
 
423
476
  In Jotai, there are no restrictions on writing code that uses sub within a sub callback:
424
477
 
@@ -432,27 +485,26 @@ store.sub(targetAtom, () => {
432
485
  });
433
486
  ```
434
487
 
435
- In CCState, we can prevent this situation by moving the `Command` definition to a separate file and protecting the Store.
488
+ In CCState, we can prevent this situation by moving the `Watcher` definition to a separate file and protecting the Store.
436
489
 
437
490
  ```typescript
438
491
  // main.ts
439
- import { callback$ } from './callbacks'
440
- import { foo$ } from './states
492
+ import { fooWatcher } from './watchers';
441
493
 
442
494
  function initApp() {
443
- const store = createStore()
444
- store.sub(foo$, callback$)
495
+ const store = createStore();
496
+ store.watch(fooWatcher);
445
497
  // do not expose store to outside
446
498
  }
447
499
 
448
- // callbacks.ts
449
-
450
- export const callback$ = command(({ get, set }) => {
451
- // there is no way to use store sub
452
- })
500
+ // watchers.ts
501
+ export const fooWatcher = (get) => {
502
+ // there is no way to use store watch here
503
+ get(foo$);
504
+ };
453
505
  ```
454
506
 
455
- Therefore, in CCState, the capability of `sub` is intentionally limited. CCState encourages developers to handle data consistency updates within `Command`, rather than relying on subscription capabilities for reactive data updates. In fact, in a React application, CCState's `sub` is likely only used in conjunction with `useSyncExternalStore` to update views, while in all other scenarios, the code is completely moved into Commands.
507
+ Therefore, in CCState, the capability of `watcher` is intentionally limited. CCState encourages developers to handle data consistency updates within `Computed` and `Watcher`, rather than relying on subscription capabilities for reactive data updates. In fact, in a React application, CCState's `watch` is likely only used in conjunction with `useSyncExternalStore` to update views, or other external state system out of CCState.
456
508
 
457
509
  ### Avoid `useEffect` in React
458
510
 
@@ -579,12 +631,11 @@ function App() {
579
631
 
580
632
  // A more recommended approach is to enable side effects outside of React
581
633
  // main.ts
582
- store.sub(
583
- // sub is always effect-less to any State
584
- count$,
585
- command(() => {
586
- // ... onCount
587
- }),
634
+ store.watch(
635
+ // watch is always effect-less to any State
636
+ (get) => {
637
+ get(count$); // ... onCount
638
+ },
588
639
  );
589
640
  store.set(setupTimer$); // must setup effect explicitly
590
641
 
@@ -734,13 +785,10 @@ At this point, the dependency array of `derived$` is `[branch$]`, because `deriv
734
785
  store.set(branch$, 'D'); // will not trigger derived$'s read, until next get(derived$)
735
786
  ```
736
787
 
737
- Once we mount `derived$` by `sub`, all its direct and indirect dependencies will enter the _mounted_ state.
788
+ Once we mount `derived$` by `watch`, all its direct and indirect dependencies will enter the _mounted_ state.
738
789
 
739
790
  ```typescript
740
- store.sub(
741
- derived$,
742
- command(() => void 0),
743
- );
791
+ store.watch((get) => get(derived$));
744
792
  ```
745
793
 
746
794
  The mount graph in CCState is `[derived$, [branch$]]`. When `branch$` is reset, `derived$` will be re-evaluated immediately, and all subscribers will be notified.