ccstate 4.13.0 → 5.2.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
@@ -2,6 +2,7 @@
2
2
 
3
3
  ---
4
4
 
5
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/e7h4n/ccstate)
5
6
  [![Coverage Status](https://coveralls.io/repos/github/e7h4n/ccstate/badge.svg?branch=main)](https://coveralls.io/github/e7h4n/ccstate?branch=main)
6
7
  ![NPM Type Definitions](https://img.shields.io/npm/types/ccstate)
7
8
  ![NPM Version](https://img.shields.io/npm/v/ccstate)
@@ -136,7 +137,7 @@ const otherStore = createStore(); // another new Map()
136
137
  otherStore.get(count$); // anotherMap[$count] ?? $count.init, returns 0
137
138
  ```
138
139
 
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.
140
+ 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
141
 
141
142
  ### Computed
142
143
 
@@ -191,11 +192,10 @@ Since `Command` can call the `set` method, it produces side effects on the `Stor
191
192
 
192
193
  - Calling a `Command` through `store.set`
193
194
  - Being called by the `set` method within other `Command`s
194
- - Being triggered by subscription relationships established through `store.sub`
195
195
 
196
- ### Subscribing to Changes
196
+ ### Watching to Changes
197
197
 
198
- CCState provides a `sub` method on the store to establish subscription relationships.
198
+ CCState provides a `watch` method on the store to observe state change.
199
199
 
200
200
  ```typescript
201
201
  import { createStore, state, computed, command } from 'ccstate';
@@ -204,35 +204,40 @@ const base$ = state(0);
204
204
  const double$ = computed((get) => get(base$) * 2);
205
205
 
206
206
  const store = createStore();
207
- store.sub(
208
- double$,
209
- command(({ get }) => {
210
- console.log('double', get(double$));
211
- }),
212
- );
207
+ store.watch((get) => {
208
+ console.log('double', get(double$));
209
+ });
213
210
 
214
211
  store.set(base$, 10); // will log to console 'double 20'
215
212
  ```
216
213
 
217
- There are two ways to unsubscribe:
214
+ Using an AbortSignal to cancel any watcher.
215
+
216
+ ```typescript
217
+ const ctrl = new AbortController();
218
+ store.watch(
219
+ (get) => {
220
+ console.log('double', get(double$));
221
+ },
222
+ {
223
+ signal: ctrl.signal,
224
+ },
225
+ );
218
226
 
219
- 1. Using the `unsub` function returned by `store.sub`
220
- 2. Using an AbortSignal to control the subscription
227
+ ctrl.abort(); // will cancel watch
228
+ ```
221
229
 
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.
230
+ 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
231
 
224
232
  ```typescript
225
- // 🙅 use sub
233
+ // 🙅 use watch & store.set
226
234
  const user$ = state(undefined);
227
235
  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
- );
236
+ store.watch((get) => {
237
+ const userId = get(userId$);
238
+ const user = fetch('/api/users/' + userId).then((resp) => resp.json());
239
+ store.set(user$, user);
240
+ });
236
241
 
237
242
  // ✅ use computed
238
243
  const userId$ = state(0);
@@ -252,11 +257,11 @@ Here's a simple rule of thumb:
252
257
 
253
258
  ### Comprasion
254
259
 
255
- | Type | get | set | sub target | as sub callback |
256
- | -------- | --- | --- | ---------- | --------------- |
257
- | State | ✅ | ✅ | ✅ | ❌ |
258
- | Computed | ✅ | ❌ | ✅ | ❌ |
259
- | Command | ❌ | ✅ | ❌ | ✅ |
260
+ | Type | get | set |
261
+ | -------- | --- | --- |
262
+ | State | ✅ | ✅ |
263
+ | Computed | ✅ | ❌ |
264
+ | Command | ❌ | ✅ |
260
265
 
261
266
  That's it! Next, you can learn how to use CCState in React.
262
267
 
@@ -313,13 +318,85 @@ It is easy to see that `userId` passed by the function parameters do not have re
313
318
  The `Command` has the ability to receive parameters, so we can encapsulate the above example into a `Command`:
314
319
 
315
320
  ```javascript
316
- const getUser$ = command(({ set, get }, userId, sigal) => {
321
+ const getUser$ = command(({ set, get }, userId, signal) => {
317
322
  return fetch(`/api/users/${userId}`, { signal });
318
323
  });
319
324
  ```
320
325
 
321
326
  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
327
 
328
+ ### Robust Async Flow Cancellation
329
+
330
+ 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.
331
+
332
+ #### Rule 1: await in commands must perform abort signal check
333
+
334
+ ```typescript
335
+ const updateUser$ = command(async ({ get }, signal: AbortSignal) => {
336
+ const currentUser = await get(currentUser$); // Here we assume currentUser$ returns a Promise<User>
337
+ signal.throwIfAbort(); // Must perform throwIfAbort check on the line after await
338
+ });
339
+ ```
340
+
341
+ 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.
342
+
343
+ ```typescript
344
+ const updateUser$ = command(async ({ get, set }, name: string, signal: AbortSignal) => {
345
+ const currentUser = await get(currentUser$);
346
+ signal.throwIfAbort();
347
+
348
+ await set(updateUserName$, currentUser.id, name, signal); // Here we pass signal to the next command, so the current command doesn't need to check
349
+ });
350
+ ```
351
+
352
+ `Computed` doesn't need such checks because Computed is side-effect free.
353
+
354
+ 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.
355
+
356
+ ```typescript
357
+ store.watch(
358
+ (get, { signal }) => {
359
+ void (async () => {
360
+ const user = await get(currentUser$);
361
+ signal.throwIfAbort();
362
+
363
+ syncExternalState(user); // this code will not execute when externalAbortSignal abort
364
+ })();
365
+ },
366
+ {
367
+ signal: externalAbortSignal,
368
+ },
369
+ );
370
+ ```
371
+
372
+ ### Rule 2: Do not intercept AbortError in try/catch
373
+
374
+ ```typescript
375
+ const isAbortError = (error: unknown): boolean => {
376
+ if ((error instanceof Error || error instanceof DOMException) && error.name === 'AbortError') {
377
+ return true;
378
+ }
379
+
380
+ return false;
381
+ };
382
+
383
+ function throwIfAbort(e: unknown) {
384
+ if (isAbortError(e)) {
385
+ throw e;
386
+ }
387
+ }
388
+
389
+ const updateUser$ = command(async ({ get, set }, name: string, signal: AbortSignal) => {
390
+ // ...
391
+ try {
392
+ await set(updateUserName$, currentUser.id, name, signal);
393
+ } catch (error: unknown) {
394
+ throwIfAbort(error); // do not catch any AbortError
395
+ // .. error handling
396
+ }
397
+ });
398
+ ```
399
+
323
400
  ## Using in React
324
401
 
325
402
  [Using in React](docs/react.md)
@@ -365,12 +442,8 @@ import { createDebugStore, state, computed, command } from 'ccstate';
365
442
  const base$ = state(1, { debugLabel: 'base$' });
366
443
  const derived$ = computed((get) => get(base$) * 2);
367
444
 
368
- const store = createDebugStore([base$, 'derived'], ['set', 'sub']); // log sub & set actions
445
+ const store = createDebugStore([base$, 'derived'], ['set']); // log set actions
369
446
  store.set(base$, 1); // console: SET [V0:base$] 1
370
- store.sub(
371
- derived$,
372
- command(() => void 0),
373
- ); // console: SUB [V0:derived$]
374
447
  ```
375
448
 
376
449
  ## Concept behind CCState
@@ -397,28 +470,9 @@ Like Jotai, CCState is also an Atom State solution. However, unlike Jotai, CCSta
397
470
 
398
471
  ### Subscription System
399
472
 
400
- CCState's subscription system is different from Jotai's. First, CCState's subscription callback must be an `Command`.
473
+ 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
474
 
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.
475
+ 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
476
 
423
477
  In Jotai, there are no restrictions on writing code that uses sub within a sub callback:
424
478
 
@@ -432,27 +486,26 @@ store.sub(targetAtom, () => {
432
486
  });
433
487
  ```
434
488
 
435
- In CCState, we can prevent this situation by moving the `Command` definition to a separate file and protecting the Store.
489
+ In CCState, we can prevent this situation by moving the `Watcher` definition to a separate file and protecting the Store.
436
490
 
437
491
  ```typescript
438
492
  // main.ts
439
- import { callback$ } from './callbacks'
440
- import { foo$ } from './states
493
+ import { fooWatcher } from './watchers';
441
494
 
442
495
  function initApp() {
443
- const store = createStore()
444
- store.sub(foo$, callback$)
496
+ const store = createStore();
497
+ store.watch(fooWatcher);
445
498
  // do not expose store to outside
446
499
  }
447
500
 
448
- // callbacks.ts
449
-
450
- export const callback$ = command(({ get, set }) => {
451
- // there is no way to use store sub
452
- })
501
+ // watchers.ts
502
+ export const fooWatcher = (get) => {
503
+ // there is no way to use store watch here
504
+ get(foo$);
505
+ };
453
506
  ```
454
507
 
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.
508
+ 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
509
 
457
510
  ### Avoid `useEffect` in React
458
511
 
@@ -579,12 +632,11 @@ function App() {
579
632
 
580
633
  // A more recommended approach is to enable side effects outside of React
581
634
  // main.ts
582
- store.sub(
583
- // sub is always effect-less to any State
584
- count$,
585
- command(() => {
586
- // ... onCount
587
- }),
635
+ store.watch(
636
+ // watch is always effect-less to any State
637
+ (get) => {
638
+ get(count$); // ... onCount
639
+ },
588
640
  );
589
641
  store.set(setupTimer$); // must setup effect explicitly
590
642
 
@@ -734,13 +786,10 @@ At this point, the dependency array of `derived$` is `[branch$]`, because `deriv
734
786
  store.set(branch$, 'D'); // will not trigger derived$'s read, until next get(derived$)
735
787
  ```
736
788
 
737
- Once we mount `derived$` by `sub`, all its direct and indirect dependencies will enter the _mounted_ state.
789
+ Once we mount `derived$` by `watch`, all its direct and indirect dependencies will enter the _mounted_ state.
738
790
 
739
791
  ```typescript
740
- store.sub(
741
- derived$,
742
- command(() => void 0),
743
- );
792
+ store.watch((get) => get(derived$));
744
793
  ```
745
794
 
746
795
  The mount graph in CCState is `[derived$, [branch$]]`. When `branch$` is reset, `derived$` will be re-evaluated immediately, and all subscribers will be notified.