ccstate-vue 3.0.0 → 4.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/CHANGELOG.md CHANGED
@@ -1,10 +1,15 @@
1
1
  # ccstate-vue
2
2
 
3
- ## 3.0.0
3
+ ## 4.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3e45895: feat: add default store
4
8
 
5
9
  ### Patch Changes
6
10
 
7
- - Updated dependencies [932cb80]
8
- - Updated dependencies [d3b721c]
9
- - Updated dependencies [7035e82]
10
- - ccstate@3.0.0
11
+ - Updated dependencies [b32ad0a]
12
+ - Updated dependencies [3e45895]
13
+ - ccstate@4.0.0
14
+
15
+ ## 3.0.0
package/dist/index.cjs CHANGED
@@ -8,11 +8,9 @@ var provideStore = function provideStore(store) {
8
8
  vue.provide(StoreKey, store);
9
9
  };
10
10
  var useStore = function useStore() {
11
- var store = vue.inject(StoreKey);
12
- if (store === undefined) {
13
- throw new Error('Store context not found - did you forget to wrap your app with StoreProvider?');
14
- }
15
- return store;
11
+ return vue.inject(StoreKey, function () {
12
+ return ccstate.getDefaultStore();
13
+ }, true);
16
14
  };
17
15
 
18
16
  function useGet(atom) {
package/dist/index.js CHANGED
@@ -1,16 +1,14 @@
1
1
  import { provide, inject, shallowRef, getCurrentInstance, onScopeDispose, shallowReadonly } from 'vue';
2
- import { command } from 'ccstate';
2
+ import { getDefaultStore, command } from 'ccstate';
3
3
 
4
4
  var StoreKey = Symbol('ccstate-vue-store');
5
5
  var provideStore = function provideStore(store) {
6
6
  provide(StoreKey, store);
7
7
  };
8
8
  var useStore = function useStore() {
9
- var store = inject(StoreKey);
10
- if (store === undefined) {
11
- throw new Error('Store context not found - did you forget to wrap your app with StoreProvider?');
12
- }
13
- return store;
9
+ return inject(StoreKey, function () {
10
+ return getDefaultStore();
11
+ }, true);
14
12
  };
15
13
 
16
14
  function useGet(atom) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccstate-vue",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "CCState Vue Hooks",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,12 +18,11 @@
18
18
  },
19
19
  "peerDependencies": {
20
20
  "vue": ">=3.2.0",
21
- "ccstate": "^3.0.0"
21
+ "ccstate": "^4.0.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@babel/preset-env": "^7.26.0",
25
25
  "@babel/preset-typescript": "^7.26.0",
26
- "@codspeed/vitest-plugin": "^3.1.1",
27
26
  "@rollup/plugin-babel": "^6.0.4",
28
27
  "@rollup/plugin-node-resolve": "^15.3.0",
29
28
  "@testing-library/jest-dom": "^6.6.3",
@@ -2,7 +2,7 @@
2
2
  import '@testing-library/jest-dom/vitest';
3
3
  import { fireEvent, render, cleanup, screen } from '@testing-library/vue';
4
4
  import { afterEach, expect, it } from 'vitest';
5
- import { command, createStore, state } from 'ccstate';
5
+ import { command, createStore, getDefaultStore, state } from 'ccstate';
6
6
  import { provideStore } from '../provider';
7
7
  import { useGet, useSet } from '..';
8
8
 
@@ -87,8 +87,9 @@ it('call command by useSet', async () => {
87
87
  expect(screen.getByText('Times clicked: 30')).toBeInTheDocument();
88
88
  });
89
89
 
90
- it('throw if can not find store', () => {
90
+ it('should use default store if no provider', () => {
91
91
  const count$ = state(0);
92
+ getDefaultStore().set(count$, 10);
92
93
 
93
94
  const Component = {
94
95
  setup() {
@@ -102,10 +103,10 @@ it('throw if can not find store', () => {
102
103
  `,
103
104
  };
104
105
 
105
- expect(() => {
106
- render({
107
- components: { Component },
108
- template: `<div><Component /></div>`,
109
- });
110
- }).toThrow();
106
+ render({
107
+ components: { Component },
108
+ template: `<div><Component /></div>`,
109
+ });
110
+
111
+ expect(screen.getByText('10')).toBeInTheDocument();
111
112
  });
package/src/provider.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { inject, provide, type InjectionKey } from 'vue';
2
+ import { getDefaultStore } from 'ccstate';
2
3
  import type { Store } from 'ccstate';
3
4
 
4
5
  export const StoreKey = Symbol('ccstate-vue-store') as InjectionKey<Store>;
@@ -8,10 +9,11 @@ export const provideStore = (store: Store) => {
8
9
  };
9
10
 
10
11
  export const useStore = (): Store => {
11
- const store = inject(StoreKey);
12
- if (store === undefined) {
13
- throw new Error('Store context not found - did you forget to wrap your app with StoreProvider?');
14
- }
15
-
16
- return store;
12
+ return inject(
13
+ StoreKey,
14
+ () => {
15
+ return getDefaultStore();
16
+ },
17
+ true,
18
+ );
17
19
  };
package/dist/README.md DELETED
@@ -1,849 +0,0 @@
1
- <img src="https://github.com/user-attachments/assets/590797c8-6edf-45cc-8eae-028aef0b2cb3" width="240" >
2
-
3
- ---
4
-
5
- [![Coverage Status](https://coveralls.io/repos/github/e7h4n/ccstate/badge.svg?branch=main)](https://coveralls.io/github/e7h4n/ccstate?branch=main)
6
- ![NPM Type Definitions](https://img.shields.io/npm/types/ccstate)
7
- ![NPM Version](https://img.shields.io/npm/v/ccstate)
8
- ![npm package minimized gzipped size](https://img.shields.io/bundlejs/size/ccstate)
9
- [![CI](https://github.com/e7h4n/ccstate/actions/workflows/ci.yaml/badge.svg)](https://github.com/e7h4n/ccstate/actions/workflows/ci.yaml)
10
- [![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/e7h4n/ccstate)
11
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
12
-
13
- CCState is a semantic, strict, and flexible state management library suitable for medium to large single-page applications with complex state management needs.
14
-
15
- The name of CCState comes from three basic data types: computed, command, and state.
16
-
17
- ## Quick Features
18
-
19
- - 💯 Simple & Intuitive: Crystal-clear API design with just 3 data types and 2 operations
20
- - ✅ Rock-solid Reliability: Comprehensive test coverage reaching 100% branch coverage
21
- - 🪶 Ultra-lightweight: Zero dependencies, only 500 lines of core code
22
- - 💡 Framework Agnostic: Seamlessly works with [React](docs/react.md), [Vue](docs/vue.md), or any UI framework
23
- - 🚀 Blazing Fast: Optimized performance from day one, 2x-7x faster than Jotai across scenarios
24
-
25
- ## Getting Started
26
-
27
- ### Installation
28
-
29
- ```bash
30
- # npm
31
- npm i ccstate
32
-
33
- # pnpm
34
- pnpm add ccstate
35
-
36
- # yarn
37
- yarn add ccstate
38
- ```
39
-
40
- ### Create Data
41
-
42
- Use `state` to store a simple value unit, and use `computed` to create a derived computation logic:
43
-
44
- ```ts
45
- // data.js
46
- import { state, computed } from 'ccstate';
47
-
48
- export const userId$ = state('');
49
-
50
- export const user$ = computed(async (get) => {
51
- const userId = get(userId$);
52
- if (!userId) return null;
53
-
54
- const resp = await fetch(`https://api.github.com/users/${userId}`);
55
- return resp.json();
56
- });
57
- ```
58
-
59
- ### Use data in React
60
-
61
- Use `useGet` and `useSet` hooks in React to get/set data, and use `useResolved` to get Promise value.
62
-
63
- ```jsx
64
- // App.js
65
- import { useGet, useSet, useResolved } from 'ccstate-react';
66
- import { userId$, user$ } from './data';
67
-
68
- export default function App() {
69
- const userId = useGet(userId$);
70
- const setUserId = useSet(userId$);
71
- const user = useResolved(user$);
72
-
73
- return (
74
- <div>
75
- <div>
76
- <input type="text" value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="github username" />
77
- </div>
78
- <div>
79
- <img src={user?.avatar_url} width="48" />
80
- <div>
81
- {user?.name}
82
- {user?.company}
83
- </div>
84
- </div>
85
- </div>
86
- );
87
- }
88
- ```
89
-
90
- Use `createStore` and `StoreProvider` to provide a CCState store to React, all states and computations will only affect this isolated store.
91
-
92
- ```tsx
93
- // main.jsx
94
- import { createStore } from 'ccstate';
95
- import { StoreProvider } from 'ccstate-react';
96
- import { createRoot } from 'react-dom/client';
97
-
98
- import App from './App';
99
-
100
- const rootElement = document.getElementById('root');
101
- const root = createRoot(rootElement);
102
-
103
- const store = createStore();
104
- root.render(
105
- <StoreProvider value={store}>
106
- <App />
107
- </StoreProvider>,
108
- );
109
- ```
110
-
111
- That's it! [Click here to see the full example](https://codesandbox.io/p/sandbox/cr3xg6).
112
-
113
- Through these examples, you should have understood the basic usage of CCState. Next, you can read to learn about CCState's core APIs.
114
-
115
- ## Core APIs
116
-
117
- CCState provides several simple concepts to help developers better manage application states. And it can be used as an external store to drive UI frameworks like React.
118
-
119
- ### State
120
-
121
- `State` is the most basic value unit in CCState. A `State` can store any type of value, which can be accessed or modified through the store's `get`/`set` methods. Before explaining why it's designed this way, let's first look at the basic capabilities of `State`.
122
-
123
- ```typescript
124
- import { store, state } from 'ccstate';
125
-
126
- const store = createStore();
127
-
128
- const userId$ = state(0);
129
- store.get(userId$); // 0
130
- store.set(userId$, 100);
131
- store.get(userId$); // 100
132
-
133
- const user$ = state<({
134
- name: 'e7h4n',
135
- avatar: 'https://avatars.githubusercontent.com/u/813596',
136
- } | undefined>(undefined);
137
- store.set({
138
- name: 'yc-kanyun',
139
- avatar: 'https://avatars.githubusercontent.com/u/168416598'
140
- });
141
- ```
142
-
143
- These examples should be very easy to understand. You might notice a detail in the examples: all variables returned by `state` have a `$` suffix. This is a naming convention used to distinguish an CCState data type from other regular types. CCState data types must be accessed through the store's get/set methods, and since it's common to convert an CCState data type to a regular type using get, the `$` suffix helps avoid naming conflicts.
144
-
145
- ### Store
146
-
147
- In CCState, declaring a `State` doesn't mean the value will be stored within the `State` itself. In fact, a `State` acts like a key in a Map, and CCState needs to create a Map to store the corresponding value for each `State` - this Map is the `Store`.
148
-
149
- ```typescript
150
- const count$ = state(0); // count$: { init: 0 }
151
-
152
- const store = createStore(); // imagine this as new Map()
153
- store.set(count$, 10); // simply imagine as map[count$] = 10
154
-
155
- const otherStore = createStore(); // another new Map()
156
- otherStore.get(count$); // anotherMap[$count] ?? $count.init, returns 0
157
- ```
158
-
159
- 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 data types. Next, let's introduce `Computed`, CCState's reactive computation unit.
160
-
161
- ### Computed
162
-
163
- `Computed` is CCState's reactive computation unit. You can write derived computation logic in `Computed`, such as sending HTTP requests, data transformation, data aggregation, etc.
164
-
165
- ```typescript
166
- import { computed, createStore } from 'ccstate';
167
-
168
- const userId$ = state(0);
169
- const user$ = computed(async (get) => {
170
- const userId = get(userId$);
171
- const resp = await fetch('/api/users/' + userId);
172
- return resp.json();
173
- });
174
-
175
- const store = createStore();
176
- const user = await store.get(user$);
177
- ```
178
-
179
- Does this example seem less intuitive than `State`? Here's a mental model that might help you better understand what's happening:
180
-
181
- - `computed(fn)` returns an object `{read: fn}`, which is assigned to `user$`
182
- - When `store.get(user$)` encounters an object which has a read function, it calls that function: `user$.read(store.get)`
183
-
184
- This way, `Computed` receives a get accessor that can access other data in the store. This get accessor is similar to `store.get` and can be used to read both `State` and `Computed`. The reason CCState specifically passes a get method to `Computed`, rather than allowing direct access to the store within `Computed`, is to shield the logic within `Computed` from other store methods like `store.set`. The key characteristic of `Computed` is that it can only read states from the store but cannot modify them. In other words, `Computed` is side-effect free.
185
-
186
- In most cases, side-effect free computation logic is extremely useful. They can be executed any number of times and have few requirements regarding execution timing. `Computed` is one of the most powerful features in CCState, and you should try to write your logic as `Computed` whenever possible, unless you need to perform set operations on the `Store`.
187
-
188
- ### Command
189
-
190
- `Command` is CCState's logic unit for organizing side effects. It has both `set` and `get` accessors from the store, allowing it to not only read other data types but also modify `State` or call other `Command`.
191
-
192
- ```typescript
193
- import { command, createStore } from 'ccstate';
194
-
195
- const user$ = state<UserInfo | undefined>(undefined);
196
- const updateUser$ = command(async ({ set }, userId) => {
197
- const user = await fetch('/api/users/' + userId).then((resp) => resp.json());
198
- set(user$, user);
199
- });
200
-
201
- const store = createStore();
202
- store.set(updateUser$, 10); // fetchUserInfo(userId=10) and set to user$
203
- ```
204
-
205
- Similarly, we can imagine the set operation like this:
206
-
207
- - `command(fn)` returns an object `{write: fn}` which is assigned to `updateUser$`
208
- - When `store.set(updateUser$)` encounters an object which has a `write` function, it calls that function: `updateUser$.write({set: store.set, get: store.get}, userId)`
209
-
210
- Since `Command` can call the `set` method, it produces side effects on the `Store`. Therefore, its execution timing must be explicitly specified through one of these ways:
211
-
212
- - Calling a `Command` through `store.set`
213
- - Being called by the `set` method within other `Command`s
214
- - Being triggered by subscription relationships established through `store.sub`
215
-
216
- ### Subscribing to Changes
217
-
218
- CCState provides a `sub` method on the store to establish subscription relationships.
219
-
220
- ```typescript
221
- import { createStore, state, computed, command } from 'ccstate';
222
-
223
- const base$ = state(0);
224
- const double$ = computed((get) => get(base$) * 2);
225
-
226
- const store = createStore();
227
- store.sub(
228
- double$,
229
- command(({ get }) => {
230
- console.log('double', get(double$));
231
- }),
232
- );
233
-
234
- store.set(base$, 10); // will log to console 'double 20'
235
- ```
236
-
237
- There are two ways to unsubscribe:
238
-
239
- 1. Using the `unsub` function returned by `store.sub`
240
- 2. Using an AbortSignal to control the subscription
241
-
242
- 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.
243
-
244
- ```typescript
245
- // 🙅 use sub
246
- const user$ = state(undefined);
247
- const userId$ = state(0);
248
- store.sub(
249
- userId$,
250
- command(({ set, get }) => {
251
- const userId = get(userId$);
252
- const user = fetch('/api/users/' + userId).then((resp) => resp.json());
253
- set(user$, user);
254
- }),
255
- );
256
-
257
- // ✅ use computed
258
- const userId$ = state(0);
259
- const user$ = computed(async (get) => {
260
- return await fetch('/api/users/' + get(userId$)).then((resp) => resp.json());
261
- });
262
- ```
263
-
264
- Using `Computed` to write reactive logic has several advantages:
265
-
266
- - No need to manage unsubscription
267
- - No need to worry about it modifying other `State`s or calling other `Command`
268
-
269
- Here's a simple rule of thumb:
270
-
271
- > if some logic can be written as a `Computed`, it should be written as a `Computed`.
272
-
273
- ### Comprasion
274
-
275
- | Type | get | set | sub target | as sub callback |
276
- | -------- | --- | --- | ---------- | --------------- |
277
- | State | ✅ | ✅ | ✅ | ❌ |
278
- | Computed | ✅ | ❌ | ✅ | ❌ |
279
- | Command | ❌ | ✅ | ❌ | ✅ |
280
-
281
- That's it! Next, you can learn how to use CCState in React.
282
-
283
- ## Using in React
284
-
285
- [Using in React](docs/react.md)
286
-
287
- ## Using in Vue
288
-
289
- [Using in Vue](docs/vue.md)
290
-
291
- ### Testing & Debugging
292
-
293
- Testing Value/Computed should be as simple as testing a Map.
294
-
295
- ```typescript
296
- // counter.test.ts
297
- import { test } from 'vitest';
298
- import { createStore, state } from 'ccstate';
299
-
300
- test('test counter', () => {
301
- const store = createStore();
302
- const count$ = state(0);
303
- store.set(count$, 10);
304
- expect(store.get(count$)).toBe(10);
305
- });
306
- ```
307
-
308
- Here are some tips to help you better debug during testing.
309
-
310
- ### ConsoleInterceptor
311
-
312
- Use `ConsoleInterceptor` to log most store behaviors to the console during testing:
313
-
314
- ```typescript
315
- import { createDebugStore, state, computed, command } from 'ccstate';
316
-
317
- const base$ = state(1, { debugLabel: 'base$' });
318
- const derived$ = computed((get) => get(base$) * 2);
319
-
320
- const store = createDebugStore([base$, 'derived'], ['set', 'sub']); // log sub & set actions
321
- store.set(base$, 1); // console: SET [V0:base$] 1
322
- store.sub(
323
- derived$,
324
- command(() => void 0),
325
- ); // console: SUB [V0:derived$]
326
- ```
327
-
328
- ## Concept behind CCState
329
-
330
- CCState is inspired by Jotai. So everyone will ask questions: What's the ability of CCState that Jotai doesn't have?
331
-
332
- The answer is: CCState intentionally has fewer features, simpler concepts, and less "magic" under the hood.
333
-
334
- While Jotai is a great state management solution that has benefited the Motiff project significantly, as our project grew larger, especially with the increasing number of states (10k~100k atoms), we felt that some of Jotai's design choices needed adjustments, mainly in these aspects:
335
-
336
- - Too many combinations of atom init/setter/getter methods, need simplification to reduce team's mental overhead
337
- - Should reduce reactive capabilities, especially the `onMount` capability - the framework shouldn't provide this ability
338
- - Some implicit magic operations, especially Promise wrapping, make the application execution process less transparent
339
-
340
- To address these issues, I got an idea: "What concepts in Jotai are essential? And which concepts create mental overhead for developers?". Rather than just discussing it theoretically, I decided to try implementing it myself. So I created CCState to express my thoughts on state management. Before detailing the differences from Jotai, we need to understand CCState's data types and subscription system.
341
-
342
- ### More semantic data types
343
-
344
- Like Jotai, CCState is also an Atom State solution. However, unlike Jotai, CCState doesn't expose Raw Atom, instead dividing Atoms into three types:
345
-
346
- - `State` (equivalent to "Primitive Atom" in Jotai): `State` is a readable and writable "variable", similar to a Primitive Atom in Jotai. Reading a `State` involves no computation process, and writing to a `State` just like a map.set.
347
- - `Computed` (equivalent to "Read-only Atom" in Jotai): `Computed` is a readable computed variable whose calculation process should be side-effect free. As long as its dependent Atoms don't change, repeatedly reading the value of a `Computed` should yield identical results. `Computed` is similar to a Read-only Atom in Jotai.
348
- - `Command` (equivalent to "Write-only Atom" in Jotai): `Command` is used to encapsulate a process code block. The code inside an Command only executes when an external `set` call is made on it. `Command` is also the only type in ccstate that can modify value without relying on a store.
349
-
350
- ### Subscription System
351
-
352
- CCState's subscription system is different from Jotai's. First, CCState's subscription callback must be an `Command`.
353
-
354
- ```typescript
355
- export const userId$ = state(1);
356
-
357
- export const userIdChange$ = command(({ get, set }) => {
358
- const userId = get(userId$);
359
- // ...
360
- });
361
-
362
- // ...
363
- import { userId$, userIdChange$ } from './data';
364
-
365
- function setupPage() {
366
- const store = createStore();
367
- // ...
368
- store.sub(userId$, userIdChange$);
369
- // ...
370
- }
371
- ```
372
-
373
- 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.
374
-
375
- In Jotai, there are no restrictions on writing code that uses sub within a sub callback:
376
-
377
- ```typescript
378
- store.sub(targetAtom, () => {
379
- if (store.get(fooAtom)) {
380
- store.sub(barAtom, () => {
381
- // ...
382
- });
383
- }
384
- });
385
- ```
386
-
387
- In CCState, we can prevent this situation by moving the `Command` definition to a separate file and protecting the Store.
388
-
389
- ```typescript
390
- // main.ts
391
- import { callback$ } from './callbacks'
392
- import { foo$ } from './states
393
-
394
- function initApp() {
395
- const store = createStore()
396
- store.sub(foo$, callback$)
397
- // do not expose store to outside
398
- }
399
-
400
- // callbacks.ts
401
-
402
- export const callback$ = command(({ get, set }) => {
403
- // there is no way to use store sub
404
- })
405
- ```
406
-
407
- 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.
408
-
409
- ### Avoid `useEffect` in React
410
-
411
- While Reactive Programming like `useEffect` has natural advantages in decoupling View Components, it causes many complications for editor applications like [Motiff](https://motiff.com).
412
-
413
- Regardless of the original design semantics of `useEffect`, in the current environment, `useEffect`'s semantics are deeply bound to React's rendering behavior. When engineers use `useEffect`, they subconsciously think "callback me when these things change", especially "callback me when some async process is done". While it's easy to write such waiting code using `async/await`, it feels unnatural in React.
414
-
415
- ```jsx
416
- // App.jsx
417
- // Reactive Programming in React
418
- export function App() {
419
- const userId = useUserId(); // an common hook to takeout userId from current location search params
420
- const [user, setUser] = useState();
421
- const [loading, setLoading] = useState();
422
-
423
- useEffect(() => {
424
- setLoading(true);
425
- fetch('/api/users/' + userId)
426
- .then((resp) => resp.json())
427
- .then((u) => {
428
- setLoading(false);
429
- setUser(u);
430
- });
431
- }, [userId]);
432
-
433
- if (loading) {
434
- return <div>Loading...</div>;
435
- }
436
-
437
- return <>{user?.name}</>;
438
- }
439
- ```
440
-
441
- When designing CCState, we wanted the trigger points for value changes to be completely detached from React's Mount/Unmount lifecycle and completely decoupled from React's rendering behavior.
442
-
443
- ```jsx
444
- // data.js
445
- export const userId$ = state(0)
446
- export const init$ = command(({set}) => {
447
- const userId = // ... parse userId from location search
448
- set(userId$, userId)
449
- })
450
-
451
- export const user$ = computed(get => {
452
- const userId = get(userId$)
453
- return fetch('/api/users/' + userId).then(resp => resp.json())
454
- })
455
-
456
- // App.jsx
457
- export function App() {
458
- const user = useLastResolved(user$);
459
- return <>{user?.name}</>;
460
- }
461
-
462
- // main.jsx
463
- const store = createStore();
464
- store.set(init$)
465
-
466
- const rootElement = document.getElementById('root')!;
467
- const root = createRoot(rootElement);
468
- root.render(
469
- <StoreProvider value={store}>
470
- <App />
471
- </StoreProvider>,
472
- );
473
- ```
474
-
475
- ### Less Magic
476
-
477
- #### No `onMount`: Maintaining Pure State Semantics
478
-
479
- CCState intentionally omits `onMount` to preserve the side-effect-free nature of `Computed` and `State`. This design choice emphasizes clarity and predictability over convenience.
480
-
481
- Let's examine a common pattern in Jotai and understand why CCState takes a different approach. [Consider the following scenario](https://codesandbox.io/p/sandbox/gkk43v):
482
-
483
- ```typescript
484
- // atom.ts
485
- const countAtom = atom(0);
486
- countAtom.onMount = (setAtom) => {
487
- const timer = setInterval(() => {
488
- setAtom((x) => x + 1);
489
- }, 1000);
490
- return () => {
491
- clearInterval(timer);
492
- };
493
- };
494
-
495
- // App.tsx
496
- function App() {
497
- const count = useAtomValue(countAtom)
498
- return <div>{count}</div>
499
- }
500
- ```
501
-
502
- It looks pretty cool, right? Just by using `useAtomValue` in React, you get an auto-incrementing timer. However, this means that subscribing to a `State` can potentially have side effects. Because it has side effects, we need to be very careful handling these side effects in scenarios like `useExternalStore` and `StrictMode`. In CCState, such timer auto-increment operations can only be placed in a `Command`.
503
-
504
- ```tsx
505
- // logic.ts
506
- export const count$ = state(0); // state is always effect-less
507
-
508
- export const setupTimer$ = command(({ set }) => {
509
- // command is considered to always have side effects
510
- const timer = setInterval(() => {
511
- set(count$, (x) => x + 1);
512
- }, 1000);
513
- return () => {
514
- clearInterval(timer);
515
- };
516
- });
517
-
518
- // Must explicitly enable side effects in React
519
- // App.tsx
520
- function App() {
521
- const count = useGet(count$);
522
- const setupTimer = useSet(setupTimer$);
523
-
524
- // Rendering App has side effects, so we explicitly enable them
525
- useEffect(() => {
526
- return setupTimer();
527
- }, []);
528
-
529
- return <div>{count}</div>;
530
- }
531
-
532
- // A more recommended approach is to enable side effects outside of React
533
- // main.ts
534
- store.sub(
535
- // sub is always effect-less to any State
536
- count$,
537
- command(() => {
538
- // ... onCount
539
- }),
540
- );
541
- store.set(setupTimer$); // must setup effect explicitly
542
-
543
- // ...
544
-
545
- // The pure effect-less rendering process
546
- root.render(function App() {
547
- const count = useGet(count$);
548
-
549
- return <div>{count}</div>;
550
- });
551
- ```
552
-
553
- I'm agree with [explicit is better than implicit](https://peps.python.org/pep-0020/), so CCState removes the `onMount` capability.
554
-
555
- #### No `loadable` & `unwrap`
556
-
557
- Jotai provides `loadable` and `unwrap` to handle Promise Atom, to convert them to a flat loading state atom. To implement this functionality, it inevitably needs to use `onMount` to subscribe to Promise changes and then modify its own return value.
558
-
559
- As mentioned in the previous section, CCState does not provide `onMount`, so `loadable` and `unwrap` are neither present nor necessary in CCState. Instead, React hooks `useLoadable` and `useResolved` are provided as alternatives. The reason for this design is that I noticed a detail - only within a subscription system (like React's rendering part) do we need to convert a Promise into a loading state:
560
-
561
- ```tsx
562
- // Jotai's example, since try/catch and async/await cannot be used in JSX, loadable is required to flatten the Promise
563
- const userLoadableAtom = loadable(user$);
564
- function User() {
565
- const user = useAtomValue(userLoadableAtom);
566
- if (user.state === 'loading') return <div>Loading...</div>;
567
- if (user.state === 'error') return <div>Error: {user.error.message}</div>;
568
- return <div>{user.data.name}</div>;
569
- }
570
- ```
571
-
572
- Or use loadable in the sub callback.
573
-
574
- ```ts
575
- // Jotai's example
576
- const userLoadableAtom = loadable(user$);
577
-
578
- store.sub(userLoadableAtom, () => {
579
- // Notice how similar this is to the JSX code above
580
- const user = store.get(userLoadableAtom);
581
- if (user.state === 'loading') return;
582
- if (user.state === 'error') return;
583
-
584
- // ...
585
- });
586
- ```
587
-
588
- CCState intentionally avoids overuse of the subscription pattern, encouraging developers to write state changes where they originate rather than where they are responded to.
589
-
590
- ```ts
591
- // CCState's example, avoid use sub pattern to invoke effect
592
- const updateUserId$ = command(({ set, get }) => {
593
- // retrieve userId from somewhere
594
- set(userId$, USER_ID)
595
-
596
- set(connectRoom$)
597
- })
598
-
599
- const connectRoom$ = command({ set, get }) => {
600
- const user = await get(user$)
601
- // ... prepare connection for room
602
- })
603
- ```
604
-
605
- In React's subscription-based rendering system, I use `useEffect` to introduce subscription to Promises. The code below shows the actual implementation of `useLoadable`.
606
-
607
- ```ts
608
- function useLastLoadable<T>(atom: State<Promise<T>> | Computed<Promise<T>>): Loadable<T> {
609
- const promise = useGet(atom);
610
-
611
- const [promiseResult, setPromiseResult] = useState<Loadable<T>>({
612
- state: 'loading',
613
- });
614
-
615
- useEffect(() => {
616
- const ctrl = new AbortController();
617
- const signal = ctrl.signal;
618
-
619
- void promise
620
- .then((ret) => {
621
- if (signal.aborted) return;
622
-
623
- setPromiseResult({
624
- state: 'hasData',
625
- data: ret,
626
- });
627
- })
628
- .catch((error: unknown) => {
629
- // ...
630
- });
631
-
632
- return () => {
633
- ctrl.abort();
634
- };
635
- }, [promise]);
636
-
637
- return promiseResult;
638
- }
639
- ```
640
-
641
- Finally, CCState only implements Promise flattening in the React-related range, that's enough. By making these design choices, CCState maintains a cleaner separation of concerns, makes side effects more explicit, and reduces the overall complexity of state management. While this might require slightly more explicit code in some cases, it leads to more maintainable and predictable applications.
642
-
643
- ## Technical Details
644
-
645
- ### When Computed Values Are Evaluated
646
-
647
- The execution of `read` function in `Computed` has several strategies:
648
-
649
- 1. If the `Computed` is not directly or indirectly subscribed, it only be evaluated when accessed by `get`
650
- 1. If the version number of other `Computed` | `State` accessed by the previous `read` is unchanged, use the result of the last `read` without re-evaluating it
651
- 2. Otherwise, re-evaluate `read` and mark its version number +1
652
- 2. Otherwise, if the `Computed` is directly or indirectly subscribed, it will constantly be re-evaluated when its dependency changes
653
-
654
- I mentioned "directly or indirectly subscribed" twice. Here, we use a simpler term to express it. If a `Computed | Value` is directly or indirectly subscribed, we consider it to be _mounted_. Otherwise, it is deemed to be _unmounted_.
655
-
656
- Consider this example:
657
-
658
- ```typescript
659
- const base$ = state(0);
660
- const branch$ = state('A');
661
- const derived$ = computed((get) => {
662
- if (get(branch$) !== 'B') {
663
- return 0;
664
- } else {
665
- return get(base$) * 2;
666
- }
667
- });
668
- ```
669
-
670
- In this example, `derived$` is not directly or indirectly subscribed, so it is always in the _unmounted_ state. At the same time, it has not been read, so it has no dependencies. At this point, resetting `base$` / `branch$` will not trigger the recomputation of `derived$`.
671
-
672
- ```
673
- store.set(base$, 1) // will not trigger derived$'s read
674
- store.set(branch$, 'C') // will not trigger derived$'s too
675
- ```
676
-
677
- Once we read `derived$`, it will automatically record a dependency array.
678
-
679
- ```typescript
680
- store.get(derived$); // return 0 because of branch$ === 'A'
681
- ```
682
-
683
- At this point, the dependency array of `derived$` is `[branch$]`, because `derived$` did not access `base$` in the previous execution. Although CCState knows that `derived$` depends on `branch$`, because `branch$` is not mounted, the re-evaluation of `derived$` is lazy.
684
-
685
- ```typescript
686
- store.set(branch$, 'D'); // will not trigger derived$'s read, until next get(derived$)
687
- ```
688
-
689
- Once we mount `derived$` by `sub`, all its direct and indirect dependencies will enter the _mounted_ state.
690
-
691
- ```typescript
692
- store.sub(
693
- derived$,
694
- command(() => void 0),
695
- );
696
- ```
697
-
698
- The mount graph in CCState is `[derived$, [branch$]]`. When `branch$` is reset, `derived$` will be re-evaluated immediately, and all subscribers will be notified.
699
-
700
- ```typescript
701
- store.set(branch$, 'B'); // will trigger derived$'s read
702
- ```
703
-
704
- In this re-evaluation, the dependency array of `derived$` is updated to `[branch$, base$]`, so `base$` will also be _mounted_. Any modification to `base$` will immediately trigger the re-evaluation of `derived$`.
705
-
706
- ```typescript
707
- store.set(base$, 1); // will trigger derived$'s read and notify all subscribers
708
- ```
709
-
710
- [Here's an example](https://codesandbox.io/p/sandbox/ds6p44). Open preview in an independent window to check the console output. If you hide the double output and click increment, you will only see the `set` log.
711
-
712
- ```
713
- [R][SET] V1:count$
714
- arg: – [function] (1)
715
- ret: – undefined
716
- ```
717
-
718
- Click show to make double enter the display state, and you can see the `set` `showDouble$` log and the `double$` evaluation log.
719
-
720
- ```
721
- [R][SET] V0:showDouble$
722
- arg: – [function] (1)
723
- ret: – undefined
724
-
725
- [R][CPT] C2:doubleCount$
726
- ret: – 14
727
- ```
728
-
729
- The abbreviation `CPT` represents `Computed` evaluation, not just a simple read operation. You can also try modifying the parameters of `createDebugStore` in the code to include `get` in the logs, and you'll find that not every `get` triggers a `Computed` evaluation.
730
-
731
- Click increment to see the `set` trigger the `Computed` evaluation.
732
-
733
- ```
734
- [R][SET] V1:count$
735
- arg: – [function] (1)
736
- [R][CPT] C2:doubleCount$
737
- ret: – 16
738
- ret: – undefined
739
- ```
740
-
741
- ### How to Isolate Effect-less Code
742
-
743
- CCState strives to isolate effect-less code through API capability restrictions and thus introduces two accessor APIs: `get` and `set`. I remember when I first saw Jotai, I raised a question: why can't we directly use the Atom itself to read and write state, just like signals do?
744
-
745
- Most state libraries allow you to directly read and write state once you get the state object:
746
-
747
- ```typescript
748
- // Zustand
749
- const useStore = create((set) => {
750
- return {
751
- count: 0,
752
- updateCount: () => {
753
- set({
754
- count: (x) => x + 1,
755
- });
756
- },
757
- };
758
- });
759
- useStore.getState().count; // read count is effect-less
760
- useStore.getState().updateCount(); // update count invoke effect
761
-
762
- // RxJS
763
- const count$ = new BehaviorSubject(0);
764
- count$.value; // read count is effect-less
765
- count$.next(1); // next count invoke effect
766
-
767
- // Signals
768
- const counter = signal(0);
769
- counter.value; // read value is effect-less
770
- counter.value = 1; // write value invoke effect
771
- ```
772
-
773
- So, these libraries cannot isolate effect-less code. Jotai and CCState choose to add a wrapper layer to isolate effect-less code.
774
-
775
- ```typescript
776
- const count$ = state(0);
777
- const double$ = computed((get) => {
778
- get(count$); // read count$ is effect-less
779
- // In this scope, we can't update any state
780
- });
781
- const updateDouble$ = command(({ get, set }) => {
782
- // This scope can update the state because it has `set` method
783
- set(count$, get(count$) * 2);
784
- });
785
- ```
786
-
787
- Isolating effect-less code is very useful in large projects, but is there a more straightforward way to write it? For example, a straightforward idea is to mark the current state of the `Store` as read-only when entering the `Computed` code block and then restore it to writable when exiting. In read-only mode, all `set` operations are blocked.
788
-
789
- ```typescript
790
- const counter = state(0);
791
- const double = computed(() => {
792
- // set store to read-only
793
- const result = counter.value * 2; // direct read value from counter instead of get(counter)
794
- // counter.value = 4; // any write operation in read-only mode will raise an error
795
- return result;
796
- }); // exit computed restore store to writable
797
-
798
- double.value; // will enter read-only mode, evaluate double logic, get the result, and exit read-only mode
799
- ```
800
-
801
- Unfortunately, this design will fail when encountering asynchronous callback functions in the current JavaScript language capabilities.
802
-
803
- ```typescript
804
- const double = computed(async () => {
805
- // set store to read-only
806
- await delay(TIME_TO_DELAY);
807
- // How to restore the store to read-only here?
808
- // ...
809
- });
810
- ```
811
-
812
- When encountering `await`, the execution of `double.value` will end, and the framework code in `Computed` can restore the `Store` to writable. If we don't do this, the subsequent set operation will raise an error. But if we do this, when `await` re-enters the `double` read function, it will not be able to restore the `Store` to read-only.
813
-
814
- Now, we are in the execution context that persists across async tasks; we hope to restore the Store's context to read-only when the async callback re-enters the `read` function. This direction has been proven to have many problems by [zone.js](https://github.com/angular/angular/tree/main/packages/zone.js). This is a dead end.
815
-
816
- So, I think the only way to implement `Computed`'s effect-less is to separate the atom and the accessor.
817
-
818
- ## Changelog & TODO
819
-
820
- [Changelog](packages/ccstate/CHANGELOG.md)
821
-
822
- Here are some new ideas:
823
-
824
- - Integration with svelte / solid.js
825
- - Enhance devtools
826
- - Support viewing current subscription graph and related atom values
827
- - Enable logging and breakpoints for specific atoms in devtools
828
- - Performance improvements
829
- - Mount atomState directly on atoms when there's only one store in the application to reduce WeakMap lookup overhead
830
- - Support static declaration of upstream dependencies for Computed to improve performance by disabling runtime dependency analysis
831
-
832
- ## Contributing
833
-
834
- CCState welcomes any suggestions and Pull Requests. If you're interested in improving CCState, here are some basic steps to help you set up a CCState development environment.
835
-
836
- ```bash
837
- pnpm install
838
- pnpm husky # setup commit hooks to verify commit
839
- pnpm vitest # to run all tests
840
- pnpm lint # check code style & typing
841
- ```
842
-
843
- ## Special Thanks
844
-
845
- Thanks [Jotai](https://github.com/pmndrs/jotai) for the inspiration and some code snippets, especially the test cases. Without their work, this project would not exist.
846
-
847
- ## License
848
-
849
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
package/dist/package.json DELETED
@@ -1,24 +0,0 @@
1
- {
2
- "name": "ccstate-vue",
3
- "version": "2.2.0",
4
- "description": "CCState Vue Hooks",
5
- "private": false,
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/e7h4n/ccstate.git"
9
- },
10
- "license": "MIT",
11
- "type": "module",
12
- "main": "./index.cjs",
13
- "module": "./index.js",
14
- "exports": {
15
- ".": {
16
- "import": "./index.js",
17
- "require": "./index.cjs"
18
- }
19
- },
20
- "peerDependencies": {
21
- "vue": ">=3.2.0",
22
- "ccstate": "workspace:^"
23
- }
24
- }
File without changes