zustand-sagas 0.1.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 +1295 -0
- package/dist/index.cjs +1380 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +322 -0
- package/dist/index.d.ts +322 -0
- package/dist/index.js +1304 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
# zustand-sagas
|
|
2
|
+
|
|
3
|
+
Generator-based side effect management for [Zustand](https://github.com/pmndrs/zustand). Inspired by redux-saga, redesigned for Zustand's function-based actions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install zustand-sagas zustand
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createStore } from 'zustand/vanilla';
|
|
15
|
+
import { createSaga } from 'zustand-sagas';
|
|
16
|
+
|
|
17
|
+
// Create store — actions are normal Zustand functions
|
|
18
|
+
const store = createStore((set) => ({
|
|
19
|
+
count: 0,
|
|
20
|
+
incrementAsync: () => {},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Attach sagas — root saga receives typed effects
|
|
24
|
+
const useSaga = createSaga(store, function* ({ takeEvery, delay, select, call }) {
|
|
25
|
+
yield takeEvery('incrementAsync', function* () {
|
|
26
|
+
yield delay(1000);
|
|
27
|
+
const count = yield select((s) => s.count);
|
|
28
|
+
yield call(() => store.setState({ count: count + 1 }));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Just call the action — the saga picks it up automatically
|
|
33
|
+
store.getState().incrementAsync();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
No `dispatch()`, no `{ type: 'ACTION' }` objects. Store function names **are** the action types.
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
`createSaga` wraps every function in your store state. When you call a store action like `increment(arg)`, two things happen:
|
|
41
|
+
|
|
42
|
+
1. An `ActionEvent` (`{ type: 'increment', payload: arg }`) is emitted on an internal channel
|
|
43
|
+
2. The original function runs normally (state updates happen as usual)
|
|
44
|
+
|
|
45
|
+
Sagas are generator functions that yield declarative effect descriptions. The runner interprets each effect, pausing the generator until the effect completes, then resuming it with the result.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
store.getState().increment(5)
|
|
49
|
+
|
|
|
50
|
+
|---> emit { type: 'increment', payload: 5 }
|
|
51
|
+
| |
|
|
52
|
+
| '---> ActionChannel ---> take('increment') resolves ---> saga resumes
|
|
53
|
+
|
|
|
54
|
+
'---> original increment(5) runs ---> state updates via set()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Key design decisions:**
|
|
58
|
+
|
|
59
|
+
- Actions are store functions — no string constants, no action creators
|
|
60
|
+
- State mutations happen directly in store actions, not through sagas
|
|
61
|
+
- Sagas observe and react to actions for side effects (API calls, async flows, coordination)
|
|
62
|
+
- Saga-to-saga communication goes through `put()` which emits to the channel
|
|
63
|
+
- Cancellation is cooperative — checked after each yielded effect
|
|
64
|
+
- Channels support buffering, multicast, and external event sources
|
|
65
|
+
|
|
66
|
+
### Payload convention
|
|
67
|
+
|
|
68
|
+
| Call | `payload` |
|
|
69
|
+
|-----------------------|----------------------|
|
|
70
|
+
| `increment()` | `undefined` |
|
|
71
|
+
| `addTodo('buy milk')` | `'buy milk'` |
|
|
72
|
+
| `setPosition(10, 20)` | `[10, 20]` |
|
|
73
|
+
|
|
74
|
+
## API Reference
|
|
75
|
+
|
|
76
|
+
### `createSaga(store, rootSaga)`
|
|
77
|
+
|
|
78
|
+
Attaches sagas to an existing Zustand store. Returns a `useSaga` function for accessing typed effects in child sagas.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { createStore } from 'zustand/vanilla';
|
|
82
|
+
import { createSaga } from 'zustand-sagas';
|
|
83
|
+
|
|
84
|
+
const store = createStore((set) => ({
|
|
85
|
+
count: 0,
|
|
86
|
+
increment: () => set((s) => ({ ...s, count: s.count + 1 })),
|
|
87
|
+
search: (q: string) => set((s) => ({ ...s, query: q })),
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const useSaga = createSaga(store, function* ({ takeEvery, take, call }) {
|
|
91
|
+
// take('typo') -> TS error!
|
|
92
|
+
// take('count') -> TS error! (not a function)
|
|
93
|
+
yield takeEvery('increment', function* (action) {
|
|
94
|
+
// action.payload is typed from increment's parameters
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Cancel all sagas
|
|
99
|
+
useSaga.task.cancel();
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Child sagas use the injected api from the root saga's closure:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const useSaga = createSaga(store, function* ({ take, fork }) {
|
|
106
|
+
function* watchIncrement() {
|
|
107
|
+
while (true) {
|
|
108
|
+
yield take('increment'); // typed — uses parent's take
|
|
109
|
+
// ...
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
yield fork(watchIncrement);
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For worker sagas in **separate files** (triggered by actions, not immediately during `createSaga`), call `useSaga()` to access the typed effects:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// workers.ts
|
|
120
|
+
import { useSaga } from './store';
|
|
121
|
+
|
|
122
|
+
export function* onSearch() {
|
|
123
|
+
const { select, call } = useSaga();
|
|
124
|
+
const query = yield select((s) => s.query);
|
|
125
|
+
yield call(fetchResults, query);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `sagas(rootSaga, stateCreator)` (middleware)
|
|
130
|
+
|
|
131
|
+
Alternative to `createSaga` — bakes sagas into the store creation. Adds `sagaTask` to the store API.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { create } from 'zustand';
|
|
135
|
+
import { sagas } from 'zustand-sagas';
|
|
136
|
+
|
|
137
|
+
const useStore = create(
|
|
138
|
+
sagas(
|
|
139
|
+
function* ({ takeEvery }) {
|
|
140
|
+
yield takeEvery('increment', function* () { /* ... */ });
|
|
141
|
+
},
|
|
142
|
+
(set) => ({
|
|
143
|
+
count: 0,
|
|
144
|
+
increment: () => set((s) => ({ ...s, count: s.count + 1 })),
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
useStore.sagaTask.cancel();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Effects
|
|
153
|
+
|
|
154
|
+
Effects describe side effects declaratively. Yield them from generator functions and the runner executes them.
|
|
155
|
+
|
|
156
|
+
#### `take(pattern)` / `take(channel)`
|
|
157
|
+
|
|
158
|
+
Pauses the saga until a matching action is called or a message arrives on a channel.
|
|
159
|
+
|
|
160
|
+
- `pattern: string` — matches the store function name exactly
|
|
161
|
+
- `pattern: (action) => boolean` — matches when predicate returns `true`
|
|
162
|
+
- `channel: Channel<Item>` — takes the next message from the channel; auto-terminates the saga on `END`
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
function* rootSaga({ take }) {
|
|
166
|
+
// Wait for a store action
|
|
167
|
+
const action = yield take('login');
|
|
168
|
+
console.log(action.payload);
|
|
169
|
+
|
|
170
|
+
// Wait for any action matching a predicate
|
|
171
|
+
const action2 = yield take((a) => a.type.startsWith('fetch'));
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
// Take from a channel
|
|
177
|
+
function* saga({ take }) {
|
|
178
|
+
const chan = eventChannel((emit) => {
|
|
179
|
+
const ws = new WebSocket(url);
|
|
180
|
+
ws.onmessage = (e) => emit(JSON.parse(e.data));
|
|
181
|
+
ws.onclose = () => emit(END);
|
|
182
|
+
return () => ws.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
while (true) {
|
|
186
|
+
const msg = yield take(chan); // auto-terminates on END
|
|
187
|
+
yield call(() => store.setState({ lastMessage: msg }));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
When used via the injected `SagaApi`, `take` only accepts valid action names from your store (string literals). The predicate and channel overloads still accept any value.
|
|
193
|
+
|
|
194
|
+
#### `takeMaybe(pattern)` / `takeMaybe(channel)`
|
|
195
|
+
|
|
196
|
+
Like `take`, but does **not** auto-terminate the saga when `END` is received from a channel. Instead, `END` is returned as a normal value so the saga can handle it manually.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
function* saga({ takeMaybe }) {
|
|
200
|
+
const chan = eventChannel(subscribe);
|
|
201
|
+
|
|
202
|
+
while (true) {
|
|
203
|
+
const msg = yield takeMaybe(chan);
|
|
204
|
+
if (msg === END) {
|
|
205
|
+
console.log('channel closed');
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
// process msg
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
#### `put(actionName, ...args)`
|
|
214
|
+
|
|
215
|
+
Emits an action into the saga channel. Other sagas listening via `take` will receive it. Arguments match the store function's parameters — like calling the action directly, but only through the saga channel.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
function* saga({ put }) {
|
|
219
|
+
yield put('increment'); // () => void
|
|
220
|
+
yield put('search', 'query'); // (q: string) => void
|
|
221
|
+
yield put('setPosition', 10, 20); // (x: number, y: number) => void
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Via the typed `SagaApi`, only valid store function names are accepted — `put('typo')` is a type error.
|
|
226
|
+
|
|
227
|
+
#### `putApply(actionName, args)`
|
|
228
|
+
|
|
229
|
+
Like `put`, but takes arguments as an array (similar to `Function.prototype.apply`).
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
function* saga({ putApply }) {
|
|
233
|
+
const coords = [10, 20];
|
|
234
|
+
yield putApply('setPosition', coords); // (x: number, y: number) => void
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### `putResolve(actionName, ...args)` / `putResolveApply(actionName, args)`
|
|
239
|
+
|
|
240
|
+
Blocking variants of `put`/`putApply`. Same signature and behavior, explicitly blocking — the saga waits for the effect to complete before continuing.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
function* saga({ putResolve }) {
|
|
244
|
+
yield putResolve('dataReady', result);
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
#### `call(fn, ...args)`
|
|
249
|
+
|
|
250
|
+
Calls a function and waits for its result. If `fn` returns a generator, it is run as a sub-saga. If it returns a promise, the saga waits for resolution. Arguments are type-checked against the function signature.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
function* saga({ call }) {
|
|
254
|
+
const sum = yield call((a, b) => a + b, 1, 2);
|
|
255
|
+
const data = yield call(fetchUser, userId);
|
|
256
|
+
yield call(otherSaga);
|
|
257
|
+
yield call(() => store.setState({ count: sum }));
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
#### `cps(fn, ...args)`
|
|
262
|
+
|
|
263
|
+
Like `call`, but for Node.js-style callback functions `(error, result) => void`. Wraps the callback in a promise.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
function* saga({ cps }) {
|
|
267
|
+
const content = yield cps(fs.readFile, '/path/to/file', 'utf8');
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
#### `select(selector?)`
|
|
272
|
+
|
|
273
|
+
Reads the current store state. If a selector is provided, returns its result. Otherwise returns the full state. The selector parameter is typed to your store state via `SagaApi`.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
function* saga({ select }) {
|
|
277
|
+
const count = yield select((s) => s.count); // s is typed
|
|
278
|
+
const fullState = yield select();
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
#### `fork(saga, ...args)`
|
|
283
|
+
|
|
284
|
+
Starts a new saga as an **attached** (child) task. The parent continues immediately without waiting. Returns a `Task`. Saga arguments are type-checked.
|
|
285
|
+
|
|
286
|
+
- Parent cancellation cascades to forked children
|
|
287
|
+
- Child errors propagate to the parent
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
function* rootSaga({ fork }) {
|
|
291
|
+
const task = yield fork(backgroundWorker);
|
|
292
|
+
// continues immediately
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### `spawn(saga, ...args)`
|
|
297
|
+
|
|
298
|
+
Starts a new saga as a **detached** task. Independent lifecycle. Returns a `Task`. Saga arguments are type-checked.
|
|
299
|
+
|
|
300
|
+
- Parent cancellation does **not** affect spawned tasks
|
|
301
|
+
- Errors do **not** propagate to the parent
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
function* rootSaga({ spawn }) {
|
|
305
|
+
const task = yield spawn(independentLogger);
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
#### `callWorker(fn | url, ...args)`
|
|
310
|
+
|
|
311
|
+
Runs a function in a **Web Worker** (browser) or **worker thread** (Node.js) and waits for the result. Blocking — the saga pauses until the worker completes.
|
|
312
|
+
|
|
313
|
+
The first argument is either:
|
|
314
|
+
- A **function** (sync or async) — serialized and executed in a fresh worker
|
|
315
|
+
- A **string URL/path** — worker created from that file
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
function* saga({ callWorker }) {
|
|
319
|
+
// Inline function — offload CPU-heavy work
|
|
320
|
+
const hash = yield callWorker((data: string) => {
|
|
321
|
+
let h = 0;
|
|
322
|
+
for (let i = 0; i < data.length; i++) {
|
|
323
|
+
h = (h << 5) - h + data.charCodeAt(i);
|
|
324
|
+
}
|
|
325
|
+
return h;
|
|
326
|
+
}, hugeString);
|
|
327
|
+
|
|
328
|
+
// Async function in worker
|
|
329
|
+
const data = yield callWorker(async (url: string) => {
|
|
330
|
+
const res = await fetch(url);
|
|
331
|
+
return res.json();
|
|
332
|
+
}, '/api/heavy-data');
|
|
333
|
+
|
|
334
|
+
// From a worker file
|
|
335
|
+
const result = yield callWorker('./workers/process.js', payload);
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Important:** Inline functions must be **self-contained** — no closures over external variables, no imports. Arguments and results must be structured-cloneable (no functions, DOM nodes, class instances).
|
|
340
|
+
|
|
341
|
+
#### `forkWorker(fn | url, ...args)`
|
|
342
|
+
|
|
343
|
+
Like `callWorker`, but **non-blocking** and **attached**. Returns a `Task` immediately. The worker runs in the background. Parent cancellation cascades to the worker.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
function* saga({ forkWorker, join, cancel }) {
|
|
347
|
+
const task = yield forkWorker((data: number[]) => {
|
|
348
|
+
return data.reduce((a, b) => a + b, 0);
|
|
349
|
+
}, largeArray);
|
|
350
|
+
|
|
351
|
+
// Do other work while worker runs...
|
|
352
|
+
yield delay(100);
|
|
353
|
+
|
|
354
|
+
// Wait for worker result
|
|
355
|
+
const sum = yield join(task);
|
|
356
|
+
|
|
357
|
+
// Or cancel it
|
|
358
|
+
yield cancel(task); // sends cancel signal, then terminates
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
#### `spawnWorker(fn | url, ...args)`
|
|
363
|
+
|
|
364
|
+
Like `forkWorker`, but **detached**. Parent cancellation does **not** affect the worker. Errors do **not** propagate to the parent.
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
function* saga({ spawnWorker }) {
|
|
368
|
+
yield spawnWorker(async (metrics: object) => {
|
|
369
|
+
await fetch('/api/analytics', {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
body: JSON.stringify(metrics),
|
|
372
|
+
});
|
|
373
|
+
}, analyticsData);
|
|
374
|
+
// saga continues, worker runs independently
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
#### `forkWorkerChannel(fn, ...args)` — streaming
|
|
379
|
+
|
|
380
|
+
Runs a function in a worker that can **stream values back** to the saga through a channel. The worker function receives an `emit` callback as its first argument. Returns `{ channel, task }`.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
function* saga({ forkWorkerChannel, takeMaybe, join }) {
|
|
384
|
+
const { channel: chan, task } = yield forkWorkerChannel(
|
|
385
|
+
(emit, data: number[]) => {
|
|
386
|
+
for (let i = 0; i < data.length; i++) {
|
|
387
|
+
emit({ progress: (i + 1) / data.length, item: data[i] });
|
|
388
|
+
}
|
|
389
|
+
return 'done';
|
|
390
|
+
},
|
|
391
|
+
largeDataset,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Consume streamed values (use takeMaybe to handle END manually)
|
|
395
|
+
while (true) {
|
|
396
|
+
const msg = yield takeMaybe(chan);
|
|
397
|
+
if (msg === END) break;
|
|
398
|
+
yield call(() => store.setState({ progress: msg.progress }));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const result = yield join(task); // 'done'
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
The channel receives `END` automatically when the worker function returns (or throws). Use `take(chan)` if you want the saga to auto-terminate on close, or `takeMaybe(chan)` to handle `END` explicitly.
|
|
406
|
+
|
|
407
|
+
#### `callWorkerGen(fn, handler, ...args)` — bidirectional
|
|
408
|
+
|
|
409
|
+
Runs a function in a worker with **two-way communication**. The worker function receives a `send(value): Promise<response>` function. Each `send` pauses the worker until the saga's handler processes the value and returns a response. Blocking — the saga waits until the worker completes.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
function* saga({ callWorkerGen, select, call }) {
|
|
413
|
+
const result = yield callWorkerGen(
|
|
414
|
+
// Worker side — sends values, receives responses
|
|
415
|
+
async (send, rawData: string) => {
|
|
416
|
+
const validated = await send({ step: 'validate', data: rawData });
|
|
417
|
+
const enriched = await send({ step: 'enrich', data: validated });
|
|
418
|
+
return enriched;
|
|
419
|
+
},
|
|
420
|
+
// Saga handler — runs on main thread with full effect access
|
|
421
|
+
function* (msg) {
|
|
422
|
+
if (msg.step === 'validate') {
|
|
423
|
+
return yield call(validateApi, msg.data);
|
|
424
|
+
}
|
|
425
|
+
if (msg.step === 'enrich') {
|
|
426
|
+
const config = yield select((s) => s.enrichConfig);
|
|
427
|
+
return yield call(enrichApi, msg.data, config);
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
inputData,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
The handler is a generator that runs as a sub-saga on the main thread — it has full access to all saga effects (`select`, `call`, `delay`, `put`, etc.). This is useful when the worker needs data or services that only the main thread can provide.
|
|
436
|
+
|
|
437
|
+
#### Worker protocol (URL-based workers)
|
|
438
|
+
|
|
439
|
+
When using a URL, your worker file must implement this message protocol:
|
|
440
|
+
|
|
441
|
+
**Standard** (`callWorker`, `forkWorker`, `spawnWorker`):
|
|
442
|
+
|
|
443
|
+
```
|
|
444
|
+
Main → Worker: { type: 'exec', args: [...] }
|
|
445
|
+
Worker → Main: { type: 'result', value: ... }
|
|
446
|
+
Worker → Main: { type: 'error', message: string, stack?: string }
|
|
447
|
+
Main → Worker: { type: 'cancel' }
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
**Channel** (`forkWorkerChannel`) — adds `emit`:
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
Worker → Main: { type: 'emit', value: ... } // streamed values
|
|
454
|
+
Worker → Main: { type: 'result', value: ... } // final return
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**Gen** (`callWorkerGen`) — adds `send`/`response`:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
Worker → Main: { type: 'send', value: ... } // request to handler
|
|
461
|
+
Main → Worker: { type: 'response', value: ... } // handler's response
|
|
462
|
+
Worker → Main: { type: 'result', value: ... } // final return
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
#### `join(task)`
|
|
466
|
+
|
|
467
|
+
Waits for a forked/spawned task to complete. Returns the task's result.
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
function* saga({ fork, join }) {
|
|
471
|
+
const task = yield fork(worker);
|
|
472
|
+
const result = yield join(task);
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
#### `cancel(task)`
|
|
477
|
+
|
|
478
|
+
Cancels a running task. Cancellation is cooperative — the task stops at the next yield point.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
function* saga({ fork, cancel, delay }) {
|
|
482
|
+
const task = yield fork(worker);
|
|
483
|
+
yield delay(5000);
|
|
484
|
+
yield cancel(task);
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
#### `delay(ms)`
|
|
489
|
+
|
|
490
|
+
Pauses the saga for `ms` milliseconds.
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
function* saga({ delay }) {
|
|
494
|
+
yield delay(1000);
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
#### `retry(maxTries, delayMs, fn, ...args)`
|
|
499
|
+
|
|
500
|
+
Calls a function up to `maxTries` times with `delayMs` between attempts. Throws if all attempts fail.
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
function* saga({ retry }) {
|
|
504
|
+
const data = yield retry(5, 2000, fetchApi, '/unstable-endpoint');
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
#### `race(effects)`
|
|
509
|
+
|
|
510
|
+
Runs multiple effects concurrently. Resolves with the first to complete. The result is an object where the winner's key has a value and all others are `undefined`. Losing takers are automatically cleaned up.
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
function* saga({ take, race, delay }) {
|
|
514
|
+
const result = yield race({
|
|
515
|
+
response: take('fetchComplete'),
|
|
516
|
+
timeout: delay(5000),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (result.timeout !== undefined) {
|
|
520
|
+
console.log('timed out');
|
|
521
|
+
} else {
|
|
522
|
+
console.log('got response:', result.response);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
#### `all(effects)`
|
|
528
|
+
|
|
529
|
+
Runs multiple effects concurrently and waits for all to complete. Returns an array of results in the same order. If any effect rejects, all are cancelled.
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
function* saga({ all, call }) {
|
|
533
|
+
const [users, posts] = yield all([
|
|
534
|
+
call(fetchUsers),
|
|
535
|
+
call(fetchPosts),
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
#### `actionChannel(pattern, buffer?)`
|
|
541
|
+
|
|
542
|
+
Creates a buffered channel that queues store actions matching `pattern`. Use with `take(channel)` to process actions sequentially with backpressure.
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
function* saga({ actionChannel, take, call }) {
|
|
546
|
+
// Buffer all 'request' actions
|
|
547
|
+
const chan = yield actionChannel('request');
|
|
548
|
+
|
|
549
|
+
// Process them one at a time
|
|
550
|
+
while (true) {
|
|
551
|
+
const action = yield take(chan);
|
|
552
|
+
yield call(handleRequest, action.payload);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Without `actionChannel`, rapid actions would be lost if the saga is busy processing a previous one. The channel buffers them until the saga is ready.
|
|
558
|
+
|
|
559
|
+
Optional second argument controls the buffer strategy (default: `buffers.expanding()`):
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
import { buffers } from 'zustand-sagas';
|
|
563
|
+
|
|
564
|
+
const chan = yield actionChannel('request', buffers.sliding(5));
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
#### `flush(channel)`
|
|
568
|
+
|
|
569
|
+
Drains all buffered messages from a channel and returns them as an array.
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
function* saga({ actionChannel, flush, delay }) {
|
|
573
|
+
const chan = yield actionChannel('event');
|
|
574
|
+
yield delay(1000); // let events accumulate
|
|
575
|
+
const events = yield flush(chan); // get all buffered events at once
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Channels
|
|
580
|
+
|
|
581
|
+
Channels are message queues that sagas can read from (`take`) and write to (`put`). They enable communication between sagas, integration with external event sources, and buffered action processing.
|
|
582
|
+
|
|
583
|
+
#### `channel(buffer?)`
|
|
584
|
+
|
|
585
|
+
Creates a point-to-point channel. Each message is delivered to a single taker (first registered wins).
|
|
586
|
+
|
|
587
|
+
```ts
|
|
588
|
+
import { channel } from 'zustand-sagas';
|
|
589
|
+
|
|
590
|
+
const chan = channel<string>();
|
|
591
|
+
|
|
592
|
+
// Producer saga
|
|
593
|
+
function* producer() {
|
|
594
|
+
chan.put('hello');
|
|
595
|
+
chan.put('world');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Consumer saga
|
|
599
|
+
function* consumer({ take }) {
|
|
600
|
+
const msg = yield take(chan); // 'hello'
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
Default buffer is `buffers.expanding()`. Pass a different buffer to control capacity:
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
import { channel, buffers } from 'zustand-sagas';
|
|
608
|
+
|
|
609
|
+
const chan = channel<number>(buffers.sliding(100));
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
#### `multicastChannel()`
|
|
613
|
+
|
|
614
|
+
Creates a channel where **all** registered takers receive each message (broadcast).
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
import { multicastChannel } from 'zustand-sagas';
|
|
618
|
+
|
|
619
|
+
const chan = multicastChannel<string>();
|
|
620
|
+
|
|
621
|
+
// Both sagas receive 'hello'
|
|
622
|
+
function* listener1({ take }) { const msg = yield take(chan); }
|
|
623
|
+
function* listener2({ take }) { const msg = yield take(chan); }
|
|
624
|
+
|
|
625
|
+
chan.put('hello'); // delivered to both
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
#### `eventChannel(subscribe, buffer?)`
|
|
629
|
+
|
|
630
|
+
Bridges external event sources (WebSocket, DOM events, timers, SSE) into a channel that sagas can `take` from.
|
|
631
|
+
|
|
632
|
+
The `subscribe` function receives an `emit` callback and must return an unsubscribe function. Emitting `END` closes the channel.
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
import { eventChannel, END } from 'zustand-sagas';
|
|
636
|
+
|
|
637
|
+
// WebSocket
|
|
638
|
+
const wsChannel = eventChannel<Message>((emit) => {
|
|
639
|
+
const ws = new WebSocket('wss://api.example.com');
|
|
640
|
+
ws.onmessage = (e) => emit(JSON.parse(e.data));
|
|
641
|
+
ws.onerror = () => emit(END);
|
|
642
|
+
ws.onclose = () => emit(END);
|
|
643
|
+
return () => ws.close();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Timer countdown
|
|
647
|
+
const countdown = eventChannel<number>((emit) => {
|
|
648
|
+
let n = 10;
|
|
649
|
+
const id = setInterval(() => {
|
|
650
|
+
n--;
|
|
651
|
+
if (n > 0) emit(n);
|
|
652
|
+
else {
|
|
653
|
+
emit(END);
|
|
654
|
+
clearInterval(id);
|
|
655
|
+
}
|
|
656
|
+
}, 1000);
|
|
657
|
+
return () => clearInterval(id);
|
|
658
|
+
});
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Use in a saga:
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
function* watchWebSocket({ take, call }) {
|
|
665
|
+
const chan = eventChannel((emit) => {
|
|
666
|
+
const ws = new WebSocket(url);
|
|
667
|
+
ws.onmessage = (e) => emit(JSON.parse(e.data));
|
|
668
|
+
ws.onclose = () => emit(END);
|
|
669
|
+
return () => ws.close();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
while (true) {
|
|
673
|
+
const msg = yield take(chan); // auto-terminates on END
|
|
674
|
+
yield call(() => store.setState({ lastMessage: msg }));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
#### `END`
|
|
680
|
+
|
|
681
|
+
A unique symbol that signals channel closure. When a channel is closed (via `close()` or emitting `END`):
|
|
682
|
+
|
|
683
|
+
- `take(channel)` auto-terminates the saga
|
|
684
|
+
- `takeMaybe(channel)` returns `END` as a value
|
|
685
|
+
- Further `put()` calls are ignored
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
import { END } from 'zustand-sagas';
|
|
689
|
+
|
|
690
|
+
chan.put(END); // closes the channel
|
|
691
|
+
// or
|
|
692
|
+
chan.close(); // equivalent
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### Buffers
|
|
696
|
+
|
|
697
|
+
Buffer strategies control how channels store messages when no taker is ready.
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
import { buffers } from 'zustand-sagas';
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
| Buffer | Behavior |
|
|
704
|
+
| --- | --- |
|
|
705
|
+
| `buffers.none()` | Zero capacity — items dropped if no taker is waiting |
|
|
706
|
+
| `buffers.fixed(limit?)` | Throws on overflow (default limit: 10) |
|
|
707
|
+
| `buffers.dropping(limit)` | Silently drops new items when full |
|
|
708
|
+
| `buffers.sliding(limit)` | Drops oldest item when full |
|
|
709
|
+
| `buffers.expanding(initial?)` | Grows dynamically, never drops (default) |
|
|
710
|
+
|
|
711
|
+
```ts
|
|
712
|
+
// Channel with a sliding window of 100 items
|
|
713
|
+
const chan = channel<number>(buffers.sliding(100));
|
|
714
|
+
|
|
715
|
+
// Action channel that drops overflow
|
|
716
|
+
const reqChan = yield actionChannel('request', buffers.dropping(50));
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Helpers
|
|
720
|
+
|
|
721
|
+
Higher-level patterns built on core effects. Each helper forks an internal loop, so use with plain `yield`.
|
|
722
|
+
|
|
723
|
+
#### `takeEvery(pattern, worker)`
|
|
724
|
+
|
|
725
|
+
Forks `worker` for **every** action matching `pattern`. All instances run concurrently.
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
function* rootSaga({ takeEvery }) {
|
|
729
|
+
yield takeEvery('fetchUser', fetchUserWorker);
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
#### `takeLatest(pattern, worker)`
|
|
734
|
+
|
|
735
|
+
Forks `worker` for the latest matching action. Automatically cancels any previously forked instance.
|
|
736
|
+
|
|
737
|
+
```ts
|
|
738
|
+
function* rootSaga({ takeLatest }) {
|
|
739
|
+
yield takeLatest('search', searchWorker);
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
#### `takeLeading(pattern, worker)`
|
|
744
|
+
|
|
745
|
+
Calls `worker` for the first matching action, then blocks until it completes before listening again. Actions arriving while the worker is running are dropped.
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
function* rootSaga({ takeLeading }) {
|
|
749
|
+
yield takeLeading('submitForm', submitWorker);
|
|
750
|
+
}
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
#### `debounce(ms, pattern, worker)`
|
|
754
|
+
|
|
755
|
+
Waits `ms` after the latest matching action before running `worker`. Restarts the timer on each new action.
|
|
756
|
+
|
|
757
|
+
```ts
|
|
758
|
+
function* rootSaga({ debounce }) {
|
|
759
|
+
yield debounce(300, 'search', searchWorker);
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
#### `throttle(ms, pattern, worker)`
|
|
764
|
+
|
|
765
|
+
Processes at most one action per `ms` milliseconds. Accepts the first, then ignores for the duration.
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
function* rootSaga({ throttle }) {
|
|
769
|
+
yield throttle(500, 'scroll', scrollHandler);
|
|
770
|
+
}
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
### Task
|
|
774
|
+
|
|
775
|
+
Tasks are returned by `fork`, `spawn`, and `runSaga`. They represent a running saga and provide control over its lifecycle.
|
|
776
|
+
|
|
777
|
+
```ts
|
|
778
|
+
interface Task<Result = unknown> {
|
|
779
|
+
id: number;
|
|
780
|
+
isRunning(): boolean;
|
|
781
|
+
isCancelled(): boolean;
|
|
782
|
+
result(): Result | undefined; // the return value (undefined until completion)
|
|
783
|
+
toPromise(): Promise<Result>;
|
|
784
|
+
cancel(): void;
|
|
785
|
+
}
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
## Patterns
|
|
789
|
+
|
|
790
|
+
### Async Counter
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
const store = createStore((set) => ({
|
|
794
|
+
count: 0,
|
|
795
|
+
incrementAsync: () => {},
|
|
796
|
+
}));
|
|
797
|
+
|
|
798
|
+
createSaga(store, function* ({ takeEvery, delay, select, call }) {
|
|
799
|
+
yield takeEvery('incrementAsync', function* () {
|
|
800
|
+
yield delay(1000);
|
|
801
|
+
const count = yield select((s) => s.count);
|
|
802
|
+
yield call(() => store.setState({ count: count + 1 }));
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
### Fetch with Timeout
|
|
808
|
+
|
|
809
|
+
```ts
|
|
810
|
+
const store = createStore((set) => ({
|
|
811
|
+
data: null,
|
|
812
|
+
error: null,
|
|
813
|
+
fetchData: () => {},
|
|
814
|
+
}));
|
|
815
|
+
|
|
816
|
+
createSaga(store, function* ({ take, race, call, delay }) {
|
|
817
|
+
yield take('fetchData');
|
|
818
|
+
const { data, timeout } = yield race({
|
|
819
|
+
data: call(fetchApi, '/data'),
|
|
820
|
+
timeout: delay(5000),
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
if (timeout !== undefined) {
|
|
824
|
+
yield call(() => store.setState({ error: 'Request timed out' }));
|
|
825
|
+
} else {
|
|
826
|
+
yield call(() => store.setState({ data }));
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### Sequential Request Processing
|
|
832
|
+
|
|
833
|
+
Use `actionChannel` to buffer rapid requests and process them one at a time:
|
|
834
|
+
|
|
835
|
+
```ts
|
|
836
|
+
const store = createStore((set) => ({
|
|
837
|
+
results: [],
|
|
838
|
+
processItem: (id: string) => {},
|
|
839
|
+
}));
|
|
840
|
+
|
|
841
|
+
createSaga(store, function* ({ actionChannel, take, call }) {
|
|
842
|
+
const chan = yield actionChannel('processItem');
|
|
843
|
+
|
|
844
|
+
while (true) {
|
|
845
|
+
const action = yield take(chan);
|
|
846
|
+
yield call(processOnServer, action.payload);
|
|
847
|
+
yield call(() =>
|
|
848
|
+
store.setState((s) => ({ ...s, results: [...s.results, action.payload] })),
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
### WebSocket Integration
|
|
855
|
+
|
|
856
|
+
Bridge a WebSocket into a saga using `eventChannel`:
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
import { eventChannel, END } from 'zustand-sagas';
|
|
860
|
+
|
|
861
|
+
const store = createStore((set) => ({
|
|
862
|
+
messages: [],
|
|
863
|
+
connected: false,
|
|
864
|
+
connect: () => {},
|
|
865
|
+
}));
|
|
866
|
+
|
|
867
|
+
createSaga(store, function* ({ take, fork, call }) {
|
|
868
|
+
yield take('connect');
|
|
869
|
+
|
|
870
|
+
const chan = eventChannel<Message>((emit) => {
|
|
871
|
+
const ws = new WebSocket('wss://api.example.com');
|
|
872
|
+
ws.onopen = () => store.setState({ connected: true });
|
|
873
|
+
ws.onmessage = (e) => emit(JSON.parse(e.data));
|
|
874
|
+
ws.onclose = () => emit(END);
|
|
875
|
+
return () => ws.close();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
while (true) {
|
|
879
|
+
const msg = yield take(chan); // loop ends automatically on END
|
|
880
|
+
yield call(() =>
|
|
881
|
+
store.setState((s) => ({ ...s, messages: [...s.messages, msg] })),
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
### Saga-to-Saga Communication
|
|
888
|
+
|
|
889
|
+
Sagas communicate through the channel. One saga emits an action via `put()`, another listens for it via `take()`.
|
|
890
|
+
|
|
891
|
+
```ts
|
|
892
|
+
const store = createStore((set) => ({
|
|
893
|
+
data: null,
|
|
894
|
+
dataLoaded: (data) => set({ data }),
|
|
895
|
+
}));
|
|
896
|
+
|
|
897
|
+
createSaga(store, function* ({ take, fork, call, put }) {
|
|
898
|
+
function* producer() {
|
|
899
|
+
const data = yield call(fetchData);
|
|
900
|
+
yield put('dataLoaded', data);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function* consumer() {
|
|
904
|
+
const action = yield take('dataLoaded');
|
|
905
|
+
console.log('received:', action.payload);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
yield fork(consumer); // start listening first
|
|
909
|
+
yield fork(producer); // then produce
|
|
910
|
+
});
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
### Error Handling
|
|
914
|
+
|
|
915
|
+
```ts
|
|
916
|
+
const store = createStore((set) => ({
|
|
917
|
+
data: null,
|
|
918
|
+
error: null,
|
|
919
|
+
fetchUser: (id: string) => {},
|
|
920
|
+
}));
|
|
921
|
+
|
|
922
|
+
createSaga(store, function* ({ takeEvery, call }) {
|
|
923
|
+
yield takeEvery('fetchUser', function* (action) {
|
|
924
|
+
try {
|
|
925
|
+
const data = yield call(fetchApi, action.payload);
|
|
926
|
+
yield call(() => store.setState({ data, error: null }));
|
|
927
|
+
} catch (e) {
|
|
928
|
+
yield call(() => store.setState({ error: e.message }));
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
### Cancellable Background Task
|
|
935
|
+
|
|
936
|
+
```ts
|
|
937
|
+
const store = createStore((set) => ({
|
|
938
|
+
status: null,
|
|
939
|
+
startPolling: () => {},
|
|
940
|
+
stopPolling: () => {},
|
|
941
|
+
}));
|
|
942
|
+
|
|
943
|
+
createSaga(store, function* ({ take, fork, call, cancel, delay }) {
|
|
944
|
+
function* pollServer() {
|
|
945
|
+
while (true) {
|
|
946
|
+
const data = yield call(fetchStatus);
|
|
947
|
+
yield call(() => store.setState({ status: data }));
|
|
948
|
+
yield delay(5000);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
while (true) {
|
|
953
|
+
yield take('startPolling');
|
|
954
|
+
const task = yield fork(pollServer);
|
|
955
|
+
yield take('stopPolling');
|
|
956
|
+
yield cancel(task);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
### Offload to Web Worker
|
|
962
|
+
|
|
963
|
+
```ts
|
|
964
|
+
const store = createStore((set) => ({
|
|
965
|
+
result: null,
|
|
966
|
+
processData: (data: number[]) => {},
|
|
967
|
+
}));
|
|
968
|
+
|
|
969
|
+
createSaga(store, function* ({ takeEvery, callWorker, call }) {
|
|
970
|
+
yield takeEvery('processData', function* (action) {
|
|
971
|
+
// Heavy computation runs off the main thread
|
|
972
|
+
const result = yield callWorker((data: number[]) => {
|
|
973
|
+
return data.map((n) => Math.sqrt(n)).filter((n) => n % 1 === 0);
|
|
974
|
+
}, action.payload);
|
|
975
|
+
|
|
976
|
+
yield call(() => store.setState({ result }));
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
### Worker with Progress Streaming
|
|
982
|
+
|
|
983
|
+
```ts
|
|
984
|
+
import { END } from 'zustand-sagas';
|
|
985
|
+
|
|
986
|
+
const store = createStore((set) => ({
|
|
987
|
+
progress: 0,
|
|
988
|
+
results: [],
|
|
989
|
+
startProcessing: (items: string[]) => {},
|
|
990
|
+
}));
|
|
991
|
+
|
|
992
|
+
createSaga(store, function* ({ take, forkWorkerChannel, takeMaybe, call }) {
|
|
993
|
+
yield take('startProcessing');
|
|
994
|
+
|
|
995
|
+
const { channel: chan } = yield forkWorkerChannel(
|
|
996
|
+
(emit, items: string[]) => {
|
|
997
|
+
const results = [];
|
|
998
|
+
for (let i = 0; i < items.length; i++) {
|
|
999
|
+
// Heavy per-item work happens in the worker
|
|
1000
|
+
results.push(items[i].toUpperCase());
|
|
1001
|
+
emit({ progress: (i + 1) / items.length });
|
|
1002
|
+
}
|
|
1003
|
+
return results;
|
|
1004
|
+
},
|
|
1005
|
+
store.getState().results,
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
while (true) {
|
|
1009
|
+
const msg = yield takeMaybe(chan);
|
|
1010
|
+
if (msg === END) break;
|
|
1011
|
+
yield call(() => store.setState({ progress: msg.progress }));
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
## Comparison with redux-saga
|
|
1017
|
+
|
|
1018
|
+
### Philosophy
|
|
1019
|
+
|
|
1020
|
+
redux-saga was built for Redux, where every state change is an action object dispatched through reducers. This means actions are strings by design, and sagas intercept them in flight.
|
|
1021
|
+
|
|
1022
|
+
Zustand has no actions. State changes are just function calls — `set({ count: 1 })` or `increment()`. zustand-sagas embraces this: your store functions **are** the actions. No action constants, no action creators, no dispatch.
|
|
1023
|
+
|
|
1024
|
+
### Side-by-side
|
|
1025
|
+
|
|
1026
|
+
**Redux + redux-saga:**
|
|
1027
|
+
|
|
1028
|
+
```ts
|
|
1029
|
+
// action constants
|
|
1030
|
+
const INCREMENT_ASYNC = 'INCREMENT_ASYNC';
|
|
1031
|
+
const INCREMENT = 'INCREMENT';
|
|
1032
|
+
|
|
1033
|
+
// action creators
|
|
1034
|
+
const incrementAsync = () => ({ type: INCREMENT_ASYNC });
|
|
1035
|
+
const increment = () => ({ type: INCREMENT });
|
|
1036
|
+
|
|
1037
|
+
// reducer
|
|
1038
|
+
function counterReducer(state = { count: 0 }, action) {
|
|
1039
|
+
switch (action.type) {
|
|
1040
|
+
case INCREMENT:
|
|
1041
|
+
return { count: state.count + 1 };
|
|
1042
|
+
default:
|
|
1043
|
+
return state;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// saga
|
|
1048
|
+
function* onIncrementAsync() {
|
|
1049
|
+
yield delay(1000);
|
|
1050
|
+
yield put({ type: INCREMENT });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function* rootSaga() {
|
|
1054
|
+
yield takeEvery(INCREMENT_ASYNC, onIncrementAsync);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// dispatch
|
|
1058
|
+
dispatch({ type: INCREMENT_ASYNC });
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
**Zustand + zustand-sagas:**
|
|
1062
|
+
|
|
1063
|
+
```ts
|
|
1064
|
+
const store = createStore((set) => ({
|
|
1065
|
+
count: 0,
|
|
1066
|
+
incrementAsync: () => {},
|
|
1067
|
+
}));
|
|
1068
|
+
|
|
1069
|
+
createSaga(store, function* ({ takeEvery, delay, select, call }) {
|
|
1070
|
+
yield takeEvery('incrementAsync', function* () {
|
|
1071
|
+
yield delay(1000);
|
|
1072
|
+
const count = yield select((s) => s.count);
|
|
1073
|
+
yield call(() => store.setState({ count: count + 1 }));
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// call the action directly
|
|
1078
|
+
store.getState().incrementAsync();
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
### What's different
|
|
1082
|
+
|
|
1083
|
+
| | redux-saga | zustand-sagas |
|
|
1084
|
+
| --- | --- | --- |
|
|
1085
|
+
| **Actions** | String constants + action creator functions | Store function names (automatic) |
|
|
1086
|
+
| **Dispatching** | `dispatch({ type: 'INCREMENT' })` | `store.getState().increment()` |
|
|
1087
|
+
| **State mutation** | `put()` dispatches to reducer | State updated directly in store actions |
|
|
1088
|
+
| **Saga triggers** | Intercepts dispatched action objects | Intercepts store function calls |
|
|
1089
|
+
| **Saga-to-saga** | `put({ type, payload })` | `put('actionName', ...args)` |
|
|
1090
|
+
| **Boilerplate** | Action types + action creators + reducer + saga | Store actions + saga |
|
|
1091
|
+
| **Store** | Redux | Zustand |
|
|
1092
|
+
| **TypeScript** | Partial (heavy use of `any`) | Full — action names, payloads, selectors, channels, and task results are all type-checked |
|
|
1093
|
+
|
|
1094
|
+
### What's the same
|
|
1095
|
+
|
|
1096
|
+
Both libraries share the same generator-based mental model:
|
|
1097
|
+
|
|
1098
|
+
- **`take`** — pause until a specific action happens
|
|
1099
|
+
- **`call`** — invoke a function and wait for the result
|
|
1100
|
+
- **`select`** — read current state
|
|
1101
|
+
- **`fork` / `spawn`** — start concurrent tasks (attached vs detached)
|
|
1102
|
+
- **`cancel`** / **`join`** — task lifecycle control
|
|
1103
|
+
- **`delay`** / **`retry`** — timing utilities
|
|
1104
|
+
- **`race` / `all`** — concurrency combinators
|
|
1105
|
+
- **`takeEvery`, `takeLatest`, `takeLeading`, `debounce`, `throttle`** — high-level watcher patterns
|
|
1106
|
+
- **`channel`, `eventChannel`, `actionChannel`** — buffered channels and external event sources
|
|
1107
|
+
- **`END`** — channel termination signal
|
|
1108
|
+
- **`buffers`** — buffer strategies (none, fixed, dropping, sliding, expanding)
|
|
1109
|
+
- **`cps`** — Node.js callback-style functions
|
|
1110
|
+
- **`put`** — emit actions into the saga channel
|
|
1111
|
+
- **`callWorker` / `forkWorker` / `spawnWorker`** — run functions in Web Workers / worker threads
|
|
1112
|
+
- **`forkWorkerChannel`** — stream values from a worker through a channel
|
|
1113
|
+
- **`callWorkerGen`** — bidirectional worker ↔ saga communication
|
|
1114
|
+
- **`cloneableGenerator`**, **`createMockTask`** — testing utilities
|
|
1115
|
+
- **`runSaga`** — run sagas outside of a store for testing
|
|
1116
|
+
|
|
1117
|
+
## Testing Utilities
|
|
1118
|
+
|
|
1119
|
+
### `cloneableGenerator(fn)`
|
|
1120
|
+
|
|
1121
|
+
Wraps a generator function so you can `.clone()` it at any point — useful for testing different branches from the same saga state without rerunning from the start.
|
|
1122
|
+
|
|
1123
|
+
```ts
|
|
1124
|
+
import { cloneableGenerator } from 'zustand-sagas';
|
|
1125
|
+
|
|
1126
|
+
function* mySaga(value: number) {
|
|
1127
|
+
const state = yield select();
|
|
1128
|
+
if (state > 0) {
|
|
1129
|
+
yield put('positive');
|
|
1130
|
+
return 'positive';
|
|
1131
|
+
} else {
|
|
1132
|
+
yield call(fallbackFn);
|
|
1133
|
+
return 'non-positive';
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const gen = cloneableGenerator(mySaga)(10);
|
|
1138
|
+
gen.next(); // yield select()
|
|
1139
|
+
|
|
1140
|
+
// Clone at the branch point
|
|
1141
|
+
const positive = gen.clone();
|
|
1142
|
+
const nonPositive = gen.clone();
|
|
1143
|
+
|
|
1144
|
+
positive.next(5); // takes the if branch
|
|
1145
|
+
nonPositive.next(-1); // takes the else branch
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
### `createMockTask()`
|
|
1149
|
+
|
|
1150
|
+
Creates a mock `Task` for testing sagas that use `fork`, `join`, or `cancel` without running real sagas. Returns an extended `Task` with setters to control state.
|
|
1151
|
+
|
|
1152
|
+
```ts
|
|
1153
|
+
import { createMockTask, fork, cancel, join } from 'zustand-sagas';
|
|
1154
|
+
|
|
1155
|
+
const task = createMockTask();
|
|
1156
|
+
task.isRunning(); // true
|
|
1157
|
+
task.isCancelled(); // false
|
|
1158
|
+
|
|
1159
|
+
// Control the mock
|
|
1160
|
+
task.setRunning(false);
|
|
1161
|
+
task.setResult(42);
|
|
1162
|
+
task.result(); // 42
|
|
1163
|
+
|
|
1164
|
+
// Or simulate failure
|
|
1165
|
+
task.setError(new Error('boom'));
|
|
1166
|
+
await task.toPromise(); // rejects with 'boom'
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
Use it to step through a saga generator manually:
|
|
1170
|
+
|
|
1171
|
+
```ts
|
|
1172
|
+
function* mySaga() {
|
|
1173
|
+
const task = yield fork(worker);
|
|
1174
|
+
yield delay(5000);
|
|
1175
|
+
yield cancel(task);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const gen = mySaga();
|
|
1179
|
+
gen.next(); // yield fork(worker) — returns ForkEffect
|
|
1180
|
+
|
|
1181
|
+
const mockTask = createMockTask();
|
|
1182
|
+
gen.next(mockTask); // saga receives mockTask, yield delay(5000)
|
|
1183
|
+
const cancelEffect = gen.next(); // yield cancel(mockTask)
|
|
1184
|
+
expect(cancelEffect.value).toEqual(cancel(mockTask));
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
### `runSaga(saga, env, ...args)`
|
|
1188
|
+
|
|
1189
|
+
Runs a saga outside of a store. Useful for integration-testing sagas with a real runner but without attaching to a Zustand store.
|
|
1190
|
+
|
|
1191
|
+
```ts
|
|
1192
|
+
import { runSaga, ActionChannel } from 'zustand-sagas';
|
|
1193
|
+
|
|
1194
|
+
const channel = new ActionChannel();
|
|
1195
|
+
const state = { count: 0 };
|
|
1196
|
+
|
|
1197
|
+
const task = runSaga(mySaga, {
|
|
1198
|
+
channel,
|
|
1199
|
+
getState: () => state,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// Drive the saga by emitting actions
|
|
1203
|
+
channel.emit({ type: 'increment', payload: 1 });
|
|
1204
|
+
|
|
1205
|
+
// Wait for the saga to complete
|
|
1206
|
+
const result = await task.toPromise();
|
|
1207
|
+
|
|
1208
|
+
// Cancel if needed
|
|
1209
|
+
task.cancel();
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
`runSaga` processes all effects (take, call, fork, actionChannel, etc.) the same way `createSaga` does — the only difference is that store actions aren't auto-wrapped.
|
|
1213
|
+
|
|
1214
|
+
## Type Safety
|
|
1215
|
+
|
|
1216
|
+
The `SagaApi<State>` interface derives all type information from your store's state type. Every action-related effect constrains its arguments to valid store function names and their parameter types.
|
|
1217
|
+
|
|
1218
|
+
```ts
|
|
1219
|
+
type Store = {
|
|
1220
|
+
count: number;
|
|
1221
|
+
increment: () => void;
|
|
1222
|
+
search: (q: string) => void;
|
|
1223
|
+
setPosition: (x: number, y: number) => void;
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
// Given SagaApi<StoreState>:
|
|
1227
|
+
yield take('increment'); // ✓
|
|
1228
|
+
yield take('count'); // ✗ — not a function
|
|
1229
|
+
yield take('typo'); // ✗ — doesn't exist
|
|
1230
|
+
|
|
1231
|
+
yield put('search', 'query'); // ✓
|
|
1232
|
+
yield put('search'); // ✗ — missing required arg
|
|
1233
|
+
yield put('search', 123); // ✗ — wrong arg type
|
|
1234
|
+
yield put('setPosition', 10, 20); // ✓
|
|
1235
|
+
|
|
1236
|
+
yield select((s) => s.count); // s: Store, returns number
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
Effect types are generic where it matters:
|
|
1240
|
+
|
|
1241
|
+
| Effect | Generic | Preserves |
|
|
1242
|
+
|---|---|---|
|
|
1243
|
+
| `TakeEffect<Value>` | Channel value type | `take(channel)` keeps `Value` |
|
|
1244
|
+
| `TakeMaybeEffect<Value>` | Channel value type | Same |
|
|
1245
|
+
| `JoinEffect<Result>` | Task result type | `join(task)` keeps `Result` |
|
|
1246
|
+
| `CancelEffect<Result>` | Task result type | `cancel(task)` keeps `Result` |
|
|
1247
|
+
| `FlushEffect<Value>` | Channel value type | `flush(channel)` keeps `Value` |
|
|
1248
|
+
| `Task<Result>` | Result type | `fork`/`spawn` return typed tasks |
|
|
1249
|
+
|
|
1250
|
+
All generics have defaults, so unparameterized usage (`TakeEffect`, `JoinEffect`, etc.) works unchanged.
|
|
1251
|
+
|
|
1252
|
+
## Types
|
|
1253
|
+
|
|
1254
|
+
All types are exported for use in TypeScript projects:
|
|
1255
|
+
|
|
1256
|
+
```ts
|
|
1257
|
+
import type {
|
|
1258
|
+
SagaApi, // Typed effects injected into the root saga
|
|
1259
|
+
UseSaga, // Return type of createSaga
|
|
1260
|
+
RootSagaFn, // Root saga function signature
|
|
1261
|
+
ActionEvent, // { type: string; payload?: unknown }
|
|
1262
|
+
ActionNames, // Extracts function-property keys from a store state type
|
|
1263
|
+
ActionArgs, // Extracts raw parameter tuple for a store action
|
|
1264
|
+
ActionPayload, // Derives payload type for a given action
|
|
1265
|
+
TypedActionEvent, // Typed action event for a specific store action
|
|
1266
|
+
ActionPattern,
|
|
1267
|
+
Effect,
|
|
1268
|
+
TakeEffect, // TakeEffect<Value> — generic over channel value type
|
|
1269
|
+
TakeMaybeEffect, // TakeMaybeEffect<Value>
|
|
1270
|
+
JoinEffect, // JoinEffect<Result> — generic over task result type
|
|
1271
|
+
CancelEffect, // CancelEffect<Result>
|
|
1272
|
+
FlushEffect, // FlushEffect<Value>
|
|
1273
|
+
Task, // Task<Result> — generic over result type
|
|
1274
|
+
Saga, // User-facing saga generator type: Generator<Effect, Result, any>
|
|
1275
|
+
SagaFn,
|
|
1276
|
+
Channel, // Channel interface
|
|
1277
|
+
Buffer, // Buffer interface
|
|
1278
|
+
CallWorkerEffect, // callWorker effect type
|
|
1279
|
+
ForkWorkerEffect, // forkWorker effect type
|
|
1280
|
+
SpawnWorkerEffect, // spawnWorker effect type
|
|
1281
|
+
WorkerFn, // Function or URL accepted by worker effects
|
|
1282
|
+
ForkWorkerChannelEffect, // forkWorkerChannel effect type
|
|
1283
|
+
CallWorkerGenEffect, // callWorkerGen effect type
|
|
1284
|
+
MockTask, // createMockTask return type (Task + setters)
|
|
1285
|
+
CloneableGenerator, // Cloneable generator for testing
|
|
1286
|
+
StoreSagas,
|
|
1287
|
+
RunnerEnv,
|
|
1288
|
+
} from 'zustand-sagas';
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
The middleware augments Zustand's store type to include `sagaTask` automatically.
|
|
1292
|
+
|
|
1293
|
+
## License
|
|
1294
|
+
|
|
1295
|
+
MIT
|