ccstate 2.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/LICENSE +21 -0
- package/README.md +647 -0
- package/core/index.cjs +1022 -0
- package/core/index.d.cts +52 -0
- package/core/index.d.ts +52 -0
- package/core/index.js +1017 -0
- package/debug/index.cjs +1427 -0
- package/debug/index.d.cts +133 -0
- package/debug/index.d.ts +133 -0
- package/debug/index.js +1419 -0
- package/index.cjs +1598 -0
- package/index.d.cts +166 -0
- package/index.d.ts +166 -0
- package/index.js +1579 -0
- package/package.json +35 -0
- package/react/index.cjs +187 -0
- package/react/index.d.cts +67 -0
- package/react/index.d.ts +67 -0
- package/react/index.js +179 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 e7h4n
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN EFFECT OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
# CCState
|
|
2
|
+
|
|
3
|
+
[](https://coveralls.io/github/e7h4n/ccstate?branch=main)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
[](https://github.com/e7h4n/ccstate/actions/workflows/ci.yaml)
|
|
8
|
+
[](https://codspeed.io/e7h4n/ccstate)
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
|
|
11
|
+
CCState is a semantic, strict, and flexible state management library suitable for medium to large single-page applications with complex state management needs.
|
|
12
|
+
|
|
13
|
+
The name CCState comes from its three basic data types: Computed, Command and State.
|
|
14
|
+
|
|
15
|
+
## Quick Features
|
|
16
|
+
|
|
17
|
+
- Simple API design with only 3 data types and 2 data operations
|
|
18
|
+
- Strict test coverage with 100% branch coverage
|
|
19
|
+
- Zero dependencies
|
|
20
|
+
- Not bound to any UI library - can be used with React or Vanilla JS
|
|
21
|
+
- High Performance
|
|
22
|
+
|
|
23
|
+
## Getting Started
|
|
24
|
+
|
|
25
|
+
### Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# npm
|
|
29
|
+
npm i ccstate
|
|
30
|
+
|
|
31
|
+
# pnpm
|
|
32
|
+
pnpm add ccstate
|
|
33
|
+
|
|
34
|
+
# yarn
|
|
35
|
+
yarn add ccstate
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Create Atoms
|
|
39
|
+
|
|
40
|
+
Use `state` to create a simple value unit, and use `computed` to create a derived computation logic:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// atom.js
|
|
44
|
+
import { state, computed } from 'ccstate';
|
|
45
|
+
|
|
46
|
+
export const userId$ = state('');
|
|
47
|
+
|
|
48
|
+
export const user$ = computed(async (get) => {
|
|
49
|
+
const userId = get(userId$);
|
|
50
|
+
if (!userId) return null;
|
|
51
|
+
|
|
52
|
+
const resp = await fetch(`https://api.github.com/users/${userId}`);
|
|
53
|
+
return resp.json();
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Use Atoms in React
|
|
58
|
+
|
|
59
|
+
Use `useGet` and `useSet` hooks in React to get/set atoms, and use `useResolved` to get Promise value.
|
|
60
|
+
|
|
61
|
+
```jsx
|
|
62
|
+
// App.js
|
|
63
|
+
import { useGet, useSet, useResolved } from 'ccstate';
|
|
64
|
+
import { userId$, user$ } from './atom';
|
|
65
|
+
|
|
66
|
+
export default function App() {
|
|
67
|
+
const userId = useGet(userId$);
|
|
68
|
+
const setUserId = useSet(userId$);
|
|
69
|
+
const user = useResolved(user$);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div>
|
|
73
|
+
<div>
|
|
74
|
+
<input type="text" value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="github username" />
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<img src={user?.avatar_url} width="48" />
|
|
78
|
+
<div>
|
|
79
|
+
{user?.name}
|
|
80
|
+
{user?.company}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Use `createStore` and `StoreProvider` to provide a CCState store to React, all states and computations will only affect this isolated store.
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
// main.jsx
|
|
92
|
+
import { createStore, StoreProvider } from 'ccstate';
|
|
93
|
+
import { StrictMode } from 'react';
|
|
94
|
+
import { createRoot } from 'react-dom/client';
|
|
95
|
+
|
|
96
|
+
import App from './App';
|
|
97
|
+
|
|
98
|
+
const rootElement = document.getElementById('root');
|
|
99
|
+
const root = createRoot(rootElement);
|
|
100
|
+
|
|
101
|
+
const store = createStore();
|
|
102
|
+
root.render(
|
|
103
|
+
<StrictMode>
|
|
104
|
+
<StoreProvider value={store}>
|
|
105
|
+
<App />
|
|
106
|
+
</StoreProvider>
|
|
107
|
+
</StrictMode>,
|
|
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 is an atomic state management library that 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 callback$ = state<(() => void) | undefined>(undefined);
|
|
134
|
+
store.set(callback$, () => {
|
|
135
|
+
console.log('awesome ccstate');
|
|
136
|
+
});
|
|
137
|
+
store.get(callback$)(); // console log 'awesome ccstate'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
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 Atom type from other regular types. Atom types must be accessed through the store's get/set methods, and since it's common to convert an Atom type to a regular type using get, the `$` suffix helps avoid naming conflicts.
|
|
141
|
+
|
|
142
|
+
### Store
|
|
143
|
+
|
|
144
|
+
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`.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const count$ = state(0); // count$: { init: 0 }
|
|
148
|
+
|
|
149
|
+
const store = createStore(); // imagine this as new Map()
|
|
150
|
+
store.set(count$, 10); // simply imagine as map[count$] = 10
|
|
151
|
+
|
|
152
|
+
const otherStore = createStore(); // another new Map()
|
|
153
|
+
otherStore.get(count$); // anotherMap[$count] ?? $count.init, returns 0
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
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 atomic types. Next, let's introduce `Computed`, CCState's reactive computation unit.
|
|
157
|
+
|
|
158
|
+
### Computed
|
|
159
|
+
|
|
160
|
+
`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.
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { computed, createStore } from 'ccstate';
|
|
164
|
+
|
|
165
|
+
const userId$ = state(0);
|
|
166
|
+
const user$ = computed(async (get) => {
|
|
167
|
+
const userId = get(userId$);
|
|
168
|
+
const resp = await fetch('/api/users/' + userId);
|
|
169
|
+
return resp.json();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const store = createStore();
|
|
173
|
+
const user = await store.get(user$);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Does this example seem less intuitive than `State`? Here's a mental model that might help you better understand what's happening:
|
|
177
|
+
|
|
178
|
+
- `computed(fn)` returns an object `{read: fn}`, which is assigned to `user$`
|
|
179
|
+
- When `store.get(user$)` encounters an object which has a read function, it calls that function: `user$.read(store.get)`
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
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`.
|
|
184
|
+
|
|
185
|
+
### Command
|
|
186
|
+
|
|
187
|
+
`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 Atom values but also modify `State` or call other `Command`.
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import { command, createStore } from 'ccstate';
|
|
191
|
+
|
|
192
|
+
const user$ = state<UserInfo | undefined>(undefined);
|
|
193
|
+
const updateUser$ = command(async ({ set }, userId) => {
|
|
194
|
+
const user = await fetch('/api/users/' + userId).then((resp) => resp.json());
|
|
195
|
+
set(user$, user);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const store = createStore();
|
|
199
|
+
store.set(updateUser$, 10); // fetchUserInfo(userId=10) and set to user$
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Similarly, we can imagine the set operation like this:
|
|
203
|
+
|
|
204
|
+
- `command(fn)` returns an object `{write: fn}` which is assigned to `updateUser$`
|
|
205
|
+
- 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)`
|
|
206
|
+
|
|
207
|
+
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:
|
|
208
|
+
|
|
209
|
+
- Calling a `Command` through `store.set`
|
|
210
|
+
- Being called by the `set` method within other `Command`s
|
|
211
|
+
- Being triggered by subscription relationships established through `store.sub`
|
|
212
|
+
|
|
213
|
+
### Subscribing to Changes
|
|
214
|
+
|
|
215
|
+
CCState provides a `sub` method on the store to establish subscription relationships.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
import { createStore, state, computed, command } from 'ccstate';
|
|
219
|
+
|
|
220
|
+
const base$ = state(0);
|
|
221
|
+
const double$ = computed((get) => get(base$) * 2);
|
|
222
|
+
|
|
223
|
+
const store = createStore();
|
|
224
|
+
store.sub(
|
|
225
|
+
double$,
|
|
226
|
+
command(({ get }) => {
|
|
227
|
+
console.log('double', get(double$));
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
store.set(base$, 10); // will log to console 'double 20'
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
There are two ways to unsubscribe:
|
|
235
|
+
|
|
236
|
+
1. Using the `unsub` function returned by `store.sub`
|
|
237
|
+
2. Using an AbortSignal to control the subscription
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
// 🙅 use sub
|
|
243
|
+
const user$ = state(undefined);
|
|
244
|
+
const userId$ = state(0);
|
|
245
|
+
store.sub(
|
|
246
|
+
userId$,
|
|
247
|
+
command(({ set, get }) => {
|
|
248
|
+
const userId = get(userId$);
|
|
249
|
+
const user = fetch('/api/users/' + userId).then((resp) => resp.json());
|
|
250
|
+
set(user$, user);
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// ✅ use computed
|
|
255
|
+
const userId$ = state(0);
|
|
256
|
+
const user$ = computed(async (get) => {
|
|
257
|
+
return await fetch('/api/users/' + get(userId$)).then((resp) => resp.json());
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Using `Computed` to write reactive logic has several advantages:
|
|
262
|
+
|
|
263
|
+
- No need to manage unsubscription
|
|
264
|
+
- No need to worry about it modifying other `State`s or calling other `Command`
|
|
265
|
+
|
|
266
|
+
Here's a simple rule of thumb:
|
|
267
|
+
|
|
268
|
+
> if some logic can be written as a `Computed`, it should be written as a `Computed`.
|
|
269
|
+
|
|
270
|
+
### Comprasion
|
|
271
|
+
|
|
272
|
+
| Type | get | set | sub target | as sub callback |
|
|
273
|
+
| -------- | --- | --- | ---------- | --------------- |
|
|
274
|
+
| State | ✅ | ✅ | ✅ | ❌ |
|
|
275
|
+
| Computed | ✅ | ❌ | ✅ | ❌ |
|
|
276
|
+
| Command | ❌ | ✅ | ❌ | ✅ |
|
|
277
|
+
|
|
278
|
+
That's it! Next, you can learn how to use CCState in React.
|
|
279
|
+
|
|
280
|
+
## Using in React
|
|
281
|
+
|
|
282
|
+
To begin using CCState in a React application, you must utilize the `StoreProvider` to provide a store for the hooks.
|
|
283
|
+
|
|
284
|
+
```jsx
|
|
285
|
+
// main.tsx
|
|
286
|
+
import { createStore, StoreProvider } from 'ccstate';
|
|
287
|
+
import { App } from './App';
|
|
288
|
+
import { StrictMode } from 'react';
|
|
289
|
+
import { createRoot } from 'react-dom/client';
|
|
290
|
+
|
|
291
|
+
const store = createStore();
|
|
292
|
+
|
|
293
|
+
createRoot(document.getElementById('root')).render(
|
|
294
|
+
<StrictMode>
|
|
295
|
+
<StoreProvider value={store}>
|
|
296
|
+
<App />
|
|
297
|
+
</StoreProvider>
|
|
298
|
+
</StrictMode>,
|
|
299
|
+
);
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
All descendant components within the `StoreProvider` will use the provided store as the caller for `get` and `set` operations.
|
|
303
|
+
|
|
304
|
+
You can place the `StoreProvider` inside or outside of `StrictMode`; the functionality is the same.
|
|
305
|
+
|
|
306
|
+
### Retrieving Atom Values
|
|
307
|
+
|
|
308
|
+
The most basic usage is to use `useGet` to retrieve the value of an Atom.
|
|
309
|
+
|
|
310
|
+
```jsx
|
|
311
|
+
// atoms/count.ts
|
|
312
|
+
import { state } from 'ccstate';
|
|
313
|
+
export const count$ = state(0);
|
|
314
|
+
|
|
315
|
+
// App.tsx
|
|
316
|
+
import { useGet } from 'ccstate';
|
|
317
|
+
import { count$ } from './atoms/count';
|
|
318
|
+
|
|
319
|
+
function App() {
|
|
320
|
+
const count = useGet(count$);
|
|
321
|
+
return <div>{count}</div>;
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`useGet` returns a `State` or a `Computed` value, and when the value changes, `useGet` triggers a re-render of the component.
|
|
326
|
+
|
|
327
|
+
`useGet` does not do anything special with `Promise` values. In fact, `useGet` is equivalent to a single `store.get` call, plus a `store.sub` to ensure reactive updates to the React component.
|
|
328
|
+
|
|
329
|
+
Two other useful hooks are available when dealing with `Promise` values. First, we introduce `useLoadable`.
|
|
330
|
+
|
|
331
|
+
```jsx
|
|
332
|
+
// atoms/user.ts
|
|
333
|
+
import { computed } from 'ccstate';
|
|
334
|
+
|
|
335
|
+
export const user$ = computed(async () => {
|
|
336
|
+
return fetch('/api/users/current').then((res) => res.json());
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// App.tsx
|
|
340
|
+
import { useLoadable } from 'ccstate';
|
|
341
|
+
import { user$ } from './atoms/user';
|
|
342
|
+
|
|
343
|
+
function App() {
|
|
344
|
+
const user_ = useLoadable(user$);
|
|
345
|
+
if (user_.state === 'loading') return <div>Loading...</div>;
|
|
346
|
+
if (user_.state === 'error') return <div>Error: {user_.error.message}</div>;
|
|
347
|
+
|
|
348
|
+
return <div>{user_.data.name}</div>;
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`useLoadable` accepts an Atom that returns a `Promise` and wraps the result in a `Loadable` structure.
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
type Loadable<T> =
|
|
356
|
+
| {
|
|
357
|
+
state: 'loading';
|
|
358
|
+
}
|
|
359
|
+
| {
|
|
360
|
+
state: 'hasData';
|
|
361
|
+
data: T;
|
|
362
|
+
}
|
|
363
|
+
| {
|
|
364
|
+
state: 'hasError';
|
|
365
|
+
error: unknown;
|
|
366
|
+
};
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
This allows you to render loading and error states in JSX based on the state. `useLoadable` suppresses exceptions, so it will not trigger an `ErrorBoundary`.
|
|
370
|
+
|
|
371
|
+
Another useful hook is `useResolved`, which always returns the resolved value of a `Promise`.
|
|
372
|
+
|
|
373
|
+
```jsx
|
|
374
|
+
// App.tsx
|
|
375
|
+
import { useResolved } from 'ccstate';
|
|
376
|
+
import { user$ } from './atoms/user';
|
|
377
|
+
|
|
378
|
+
function App() {
|
|
379
|
+
const user = useResolved(user$);
|
|
380
|
+
return <div>{user?.name}</div>;
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`useResolved` only returns the parameter passed to the resolve function so that it will return `undefined` during loading and when encountering error values. Like `useLoadable`, `useResolved` also suppresses exceptions. In fact, `useResolved` is a simple wrapper around `useLoadable`.
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
// useResolved.ts
|
|
388
|
+
import { useLoadable } from './useLoadable';
|
|
389
|
+
import type { Computed, State } from '../core';
|
|
390
|
+
|
|
391
|
+
export function useResolved<T>(atom: State<Promise<T>> | Computed<Promise<T>>): T | undefined {
|
|
392
|
+
const loadable = useLoadable(atom);
|
|
393
|
+
return loadable.state === 'hasData' ? loadable.data : undefined;
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### useLastLoadable & useLastResolved
|
|
398
|
+
|
|
399
|
+
In some scenarios, we want a refreshable Promise Atom to maintain its previous result during the refresh process instead of showing a loading state. CCState provides `useLastLoadable` and `useLastResolved` to achieve this functionality.
|
|
400
|
+
|
|
401
|
+
```jsx
|
|
402
|
+
import { useLoadable } from 'ccstate';
|
|
403
|
+
import { user$ } from './atoms/user';
|
|
404
|
+
|
|
405
|
+
function App() {
|
|
406
|
+
const user_ = useLastLoadable(user$); // Keep the previous result during new user$ request, without triggering loading state
|
|
407
|
+
if (user_.state === 'loading') return <div>Loading...</div>;
|
|
408
|
+
if (user_.state === 'error') return <div>Error: {user_.error.message}</div>;
|
|
409
|
+
|
|
410
|
+
return <div>{user_.data.name}</div>;
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`useLastResolved` behaves similarly - it always returns the last resolved value from a Promise Atom and won't reset to `undefined` when a new Promise is generated.
|
|
415
|
+
|
|
416
|
+
### Updating Atom Values / Triggering Funcs
|
|
417
|
+
|
|
418
|
+
The `useSet` hook can be used to update the value of an Atom. It returns a function equivalent to `store.set` when called.
|
|
419
|
+
|
|
420
|
+
```jsx
|
|
421
|
+
// App.tsx
|
|
422
|
+
import { useSet } from 'ccstate';
|
|
423
|
+
import { count$ } from './atoms/count';
|
|
424
|
+
|
|
425
|
+
function App() {
|
|
426
|
+
const setCount = useSet(count$);
|
|
427
|
+
// setCount(x => x + 1) is equivalent to store.set(count$, x => x + 1)
|
|
428
|
+
return <button onClick={() => setCount((x) => x + 1)}>Increment</button>;
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Testing & Debugg
|
|
433
|
+
|
|
434
|
+
Testing Atoms should be as simple as testing a Map.
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// counter.test.ts
|
|
438
|
+
import { test } from 'vitest';
|
|
439
|
+
import { createStore, state } from 'ccstate';
|
|
440
|
+
|
|
441
|
+
test('test counter', () => {
|
|
442
|
+
const store = createStore();
|
|
443
|
+
const count$ = state(0);
|
|
444
|
+
store.set(count$, 10);
|
|
445
|
+
expect(store.get(count$)).toBe(10);
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Here are some tips to help you better debug during testing.
|
|
450
|
+
|
|
451
|
+
### ConsoleInterceptor
|
|
452
|
+
|
|
453
|
+
Use `ConsoleInterceptor` to log most store behaviors to the console during testing:
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
import { ConsoleInterceptor, createDebugStore, state, computed, command } from 'ccstate';
|
|
457
|
+
|
|
458
|
+
const base$ = state(1, { debugLabel: 'base$' });
|
|
459
|
+
const derived$ = computed((get) => get(base$) * 2);
|
|
460
|
+
|
|
461
|
+
const interceptor = new ConsoleInterceptor([
|
|
462
|
+
{
|
|
463
|
+
target: base$,
|
|
464
|
+
actions: new Set(['set']), // will only log set actions
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
target: derived$, // will log all actions
|
|
468
|
+
},
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
const store = createDebugStore(interceptor);
|
|
472
|
+
store.set(base$, 1); // console: SET [V0:base$] 1
|
|
473
|
+
store.sub(
|
|
474
|
+
derived$,
|
|
475
|
+
command(() => void 0),
|
|
476
|
+
); // console: SUB [V0:derived$]
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
## Concept behind CCState
|
|
480
|
+
|
|
481
|
+
CCState is inspired by Jotai. 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:
|
|
482
|
+
|
|
483
|
+
- Too many combinations of atom init/setter/getter methods, need simplification to reduce team's mental overhead
|
|
484
|
+
- Should reduce reactive capabilities, especially the `onMount` capability - the framework shouldn't provide this ability
|
|
485
|
+
- Some implicit magic operations, especially Promise wrapping, make the application execution process less transparent
|
|
486
|
+
|
|
487
|
+
To address these issues, I created CCState to express my thoughts on state management. Before detailing the differences from Jotai, we need to understand CCState's Atom types and subscription system.
|
|
488
|
+
|
|
489
|
+
### More Semantic Atom Types
|
|
490
|
+
|
|
491
|
+
Like Jotai, CCState is also an Atom State solution. However, unlike Jotai, CCState doesn't expose Raw Atom, instead dividing Atoms into three types:
|
|
492
|
+
|
|
493
|
+
- `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.
|
|
494
|
+
- `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.
|
|
495
|
+
- `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.
|
|
496
|
+
|
|
497
|
+
### Subscription System
|
|
498
|
+
|
|
499
|
+
CCState's subscription system is different from Jotai's. First, CCState's subscription callback must be an `Command`.
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
export const userId$ = state(1);
|
|
503
|
+
|
|
504
|
+
export const userIdChange$ = command(({ get, set }) => {
|
|
505
|
+
const userId = get(userId$);
|
|
506
|
+
// ...
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ...
|
|
510
|
+
import { userId$, userIdChange$ } from './atoms';
|
|
511
|
+
|
|
512
|
+
function setupPage() {
|
|
513
|
+
const store = createStore();
|
|
514
|
+
// ...
|
|
515
|
+
store.sub(userId$, userIdChange$);
|
|
516
|
+
// ...
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
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.
|
|
521
|
+
|
|
522
|
+
CCState does not have APIs like `onMount`. This is because CCState considers `onMount` to be fundamentally an effect, and providing APIs like `onMount` in `computed` would make the computation process non-idempotent.
|
|
523
|
+
|
|
524
|
+
### Avoid `useEffect` in React
|
|
525
|
+
|
|
526
|
+
While Reactive Programming like `useEffect` has natural advantages in decoupling View Components, it causes many complications for editor applications like [Motiff](https://motiff.com).
|
|
527
|
+
|
|
528
|
+
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.
|
|
529
|
+
|
|
530
|
+
```jsx
|
|
531
|
+
// App.jsx
|
|
532
|
+
// Reactive Programming in React
|
|
533
|
+
export function App() {
|
|
534
|
+
const userId = useUserId(); // an common hook to takeout userId from current location search params
|
|
535
|
+
const [user, setUser] = useState();
|
|
536
|
+
const [loading, setLoading] = useState();
|
|
537
|
+
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
setLoading(true);
|
|
540
|
+
fetch('/api/users/' + userId)
|
|
541
|
+
.then((resp) => resp.json())
|
|
542
|
+
.then((u) => {
|
|
543
|
+
setLoading(false);
|
|
544
|
+
setUser(u);
|
|
545
|
+
});
|
|
546
|
+
}, [userId]);
|
|
547
|
+
|
|
548
|
+
if (loading) {
|
|
549
|
+
return <div>Loading...</div>;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return <>{user?.name}</>;
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
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.
|
|
557
|
+
|
|
558
|
+
```jsx
|
|
559
|
+
// atoms.js
|
|
560
|
+
export const userId$ = state(0)
|
|
561
|
+
export const init$ = command(({set}) => {
|
|
562
|
+
const userId = // ... parse userId from location search
|
|
563
|
+
set(userId$, userId)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
export const user$ = computed(get => {
|
|
567
|
+
const userId = get(userId$)
|
|
568
|
+
return fetch('/api/users/' + userId).then(resp => resp.json())
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// App.jsx
|
|
572
|
+
export function App() {
|
|
573
|
+
const user = useLastResolved(user$);
|
|
574
|
+
return <>{user?.name}</>;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// main.jsx
|
|
578
|
+
const store = createStore();
|
|
579
|
+
store.set(init$)
|
|
580
|
+
|
|
581
|
+
const rootElement = document.getElementById('root')!;
|
|
582
|
+
const root = createRoot(rootElement);
|
|
583
|
+
root.render(
|
|
584
|
+
<StoreProvider value={store}>
|
|
585
|
+
<App />
|
|
586
|
+
</StoreProvider>,
|
|
587
|
+
);
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
## Practices
|
|
591
|
+
|
|
592
|
+
### Naming
|
|
593
|
+
|
|
594
|
+
Add the suffix `$` to atoms. Since we often need to get values from Atoms in many scenarios, adding the suffix after Atom can avoid naming conflicts.
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
const count$ = state(0);
|
|
598
|
+
const double$ = computed((get) => get(count$) * 2);
|
|
599
|
+
const updateCount$ = command(({ get, set }, val) => {
|
|
600
|
+
set(count$, val);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// ...
|
|
604
|
+
const count = get(count$) // will not conflict with normal value
|
|
605
|
+
|
|
606
|
+
// in react component
|
|
607
|
+
const updateCount = useSet(updateCount$) // Command suffix is useful for this
|
|
608
|
+
|
|
609
|
+
return <button onClick={() => updateCount(10)}>update</button>
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### Internal Atom
|
|
613
|
+
|
|
614
|
+
Feel free to create internal Atom. Atom is very lightweight. Creating an Atom should be just like creating a variable. Atoms don't necessarily need to be persisted or defined in the top-level scope - it's perfectly fine to create Atoms inside closures or pass new Atoms through containers.
|
|
615
|
+
|
|
616
|
+
## Changelog & TODO
|
|
617
|
+
|
|
618
|
+
[Changelog](packages/ccstate/CHANGELOG.md)
|
|
619
|
+
|
|
620
|
+
Here are some new ideas:
|
|
621
|
+
|
|
622
|
+
- Integration with svelte / solid.js
|
|
623
|
+
- Enhance devtools
|
|
624
|
+
- Support viewing current subscription graph and related atom values
|
|
625
|
+
- Enable logging and breakpoints for specific atoms in devtools
|
|
626
|
+
- Performance improvements
|
|
627
|
+
- Mount atomState directly on atoms when there's only one store in the application to reduce WeakMap lookup overhead
|
|
628
|
+
- Support static declaration of upstream dependencies for Computed to improve performance by disabling runtime dependency analysis
|
|
629
|
+
|
|
630
|
+
## Contributing
|
|
631
|
+
|
|
632
|
+
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.
|
|
633
|
+
|
|
634
|
+
```bash
|
|
635
|
+
pnpm install
|
|
636
|
+
pnpm husky # setup commit hooks to verify commit
|
|
637
|
+
pnpm vitest # to run all tests
|
|
638
|
+
pnpm lint # check code style & typing
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
## Special Thanks
|
|
642
|
+
|
|
643
|
+
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.
|
|
644
|
+
|
|
645
|
+
## License
|
|
646
|
+
|
|
647
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|