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 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