core/sagas
Use when writing typed-redux-saga watchers/workers, assigning ownership and app startup placement, debouncing, retrying with retryWithTimeout, or using wrapStreamingGenerator. Route lifecycle/crash/re
SKILL.mdskills/core/sagas/SKILL.md
GitHubSagas — agent implementation rules
Use this skill when editing saga code or writing instructions for saga changes. Keep this file short and operational; @themislib/themis/docs/SAGAS.md is the canonical human-facing guide for concepts, API shape, tradeoffs, and examples.
Canonical references
- Human guide:
@themislib/themis/docs/SAGAS.md - Public API:
@themislib/themis/saga; utility leaf exports include@themislib/themis/utils/sagas/debounce-saga,@themislib/themis/utils/sagas/retry-with-timeout, and@themislib/themis/utils/sagas/wrap-async-generator. Store-utility saga internals are implementation context only. - Related skills:
core/actions,core/state-integrity,core/saga-manager,core/selector-channels,core/wait-for,core/testing
When to use sagas
- Use sagas for side effects, orchestration, persistence, timers, event listeners, and async workflows.
- Use selector-channel helpers when the trigger is a selector value change, not an action.
- Use
waitForfor one-shot suspension until state reaches a predicate; do not use it for continuous monitoring.
Do
- Use
typed-redux-sagaeffects withyield*for every effect. - Pass action creators directly to
take,takeEvery,takeLatest, andtakeLeading; do not pass.type. - Read state through named selectors with
.effect(...)inside sagas. - Import the named selectors from the owning slice's
[slice]-selectors.tsfile; saga modules must not declare localselect*functions/factories, even when they are not exported. - Subscribe with concrete action creators, action-creator arrays, or selector-channel helpers; never use
take('*')or other wildcard takes. - Search before adding watchers: trigger action, worker name, registration name, and operation terms must have one canonical owner unless fan-out is intentional and documented.
- Choose an explicit owner for app saga startup; follow Application saga startup and the linked lifecycle contract.
- Close manually-created channels in
finally. - Handle async action failures with
.failure(error)and normalize non-Errorthrows. - Settle promise-bearing requests through their instance callbacks on success, failure, and cancellation; see the explicit rejection policy in
core/actions. - Keep retried work idempotent when using
retryWithTimeout. - Keep stream handlers passed to
wrapStreamingGeneratorpackage-generic; do not add app-specific logging/reporting dependencies to the utility. - Configure optional saga monitoring through the third Store/ReactStore/StreamingStore constructor options object as
{ sagaMonitor: true }; omitted orfalsemonitors stay disabled.
Don't
- Do not put side effects in reducers or selector predicates.
- Do not call
selector.select(store.state, ...)from saga-reachable code; convert the helper path to generators or pass values in. - Do not declare or factory-construct
select*selectors inside saga modules, even as module-private locals; move them to the owning[slice]-selectors.tsand import them. - Do not subscribe to every action with
take('*'),takeEvery('*', ...), or other wildcard patterns; it wakes the saga on every dispatch and is especially harmful during streaming flows where chunk actions fire continuously. - Do not add a parallel watcher for an action already owned by another saga.
- Do not treat initialization or manager internals as app saga registration; follow Store saga lifecycle for startup and the public/internal boundary.
- Do not leave channels, retries, or long-running loops without cancellation/error paths.
- Do not assume
wrapStreamingGeneratoraborts/finalizes its source, or thatiterator.return()interrupts a pendingnext(). - Do not introduce detached
spawn; use attachedforkso child work is cancelled when the parent fails or is cancelled. - Do not monkey-patch redux-saga globally or replace Store-owned saga middleware to observe effects; pass
{ sagaMonitor: true }in Store options instead.
Implementation cues
- Default user-triggered fetch/search flows to
takeLatest; usetakeEverywhen every action must be processed andtakeLeadingwhen in-flight work should ignore newer triggers. For promise-bearing requests, cancelled workers need afinallysettlement policy. IgnoredtakeLeadingrequests never enter a worker: use non-promisecreateActiontriggers or an explicit admission/rejection owner instead. - Compose root sagas from focused watcher/worker functions rather than mixing unrelated concerns in one worker.
- For debounce, watch the real action with
takeLatestand calldelay(ms)inside the worker before the effect; usetakeLeadingplus trailingdelay(ms)only for leading/windowed behavior. - Do not add new wrapper-action debounce flows or recommend
debounceSaga/debounceWithKeySagafor new work; those exports remain for compatibility only. - For transient failures, use
retryWithTimeoutand branch on all outcomes:success,retries-exhausted, andtimeout. - For async generators, use
wrapStreamingGeneratorfrom saga code and handle stream errors locally at the call site if app reporting is needed. - For saga lifetime placement in components, services, or tests, follow Application saga startup; per-owner cancellation and whole-Store teardown belong to Store saga lifecycle.
- For saga monitoring, keep the normal constructor shape and pass
new Store(reducers, middleware, { throttledSelectorFrequency, sagaMonitor: true })or the equivalentReactStore/StreamingStoreoptions object. - For saga manager crash records and cleanup, read Core Patterns; for auto-restart and backoff, read Start, stop, restart, and backoff mechanics.
Application saga startup
Choose the lifetime owner before wiring app sagas: app-wide work belongs to the application root or service lifetime; component/layout work belongs to that component/layout lifetime; tests own their setup and cleanup explicitly. Use the selected Store family's lifecycle skill for its framework hook or runtime boundary, not a hook prescribed by core.
Place explicit app saga startup beside that owner's initialization/cleanup wiring.
Follow Store saga lifecycle for
initialization order, store.runSaga(sagaFn), matching cancel functions, and the
whole-Store disposal boundary. For slice modules and registration placement, use
Register a normal slice.
The bootstrap/lifetime-owner exception permits only startup/cleanup imports; ordinary component/service handlers still dispatch actions rather than import or call business sagas.
Examples
1. Register one canonical action watcher and read state through selector effects
import { call, put, takeLatest } from "typed-redux-saga";
function* watchLoadTodos() {
yield* takeLatest(loadTodos, loadTodosWorker);
}
function* loadTodosWorker() {
const userId = yield* selectCurrentUserId.effect();
const todos = yield* call(api.fetchTodos, userId);
yield* put(loadTodosSuccess(todos));
}
2. Debounce trailing user input with takeLatest + delay
import { call, delay, put, takeLatest } from "typed-redux-saga";
function* watchSearchInput() {
yield* takeLatest(searchInputChanged, function* searchAfterSettled(action) {
yield* delay(300);
const results = yield* call(api.search, action.payload[0]);
yield* put(searchResultsLoaded(results));
});
}
3. Debounce leading/windowed behavior with takeLeading + delay
Here refreshRequested is an ordinary createAction event, not a promise-bearing createAsyncAction: ignored triggers have no result to await.
import { call, delay, takeLeading } from "typed-redux-saga";
function* watchRefreshRequests() {
yield* takeLeading(refreshRequested, function* refreshOncePerWindow(action) {
try {
yield* call(api.refresh, action.payload[0]);
} finally {
yield* delay(250);
}
});
}
4. Branch on every retryWithTimeout outcome
import { call, put } from "typed-redux-saga";
import { retryWithTimeout } from "@themislib/themis/saga";
function* syncRemoteState() {
const outcome = yield* retryWithTimeout(
function* () { yield* call(api.syncRemoteState); },
{ maxRetries: 2, timeoutMs: 30_000, getDelayMs: (attempt) => 500 * (attempt + 1) }
);
if (outcome === "retries-exhausted") yield* put(syncFailed("retry-limit"));
if (outcome === "timeout") yield* put(syncFailed("timeout"));
}
5. Consume an async generator through wrapStreamingGenerator
import { call, put } from "typed-redux-saga";
import { wrapStreamingGenerator } from "@themislib/themis/saga";
function* streamMessages(
openStream: (signal: AbortSignal) => AsyncGenerator<MessageChunk, MessageChunk | null | undefined, unknown>
) {
const controller = new AbortController();
const stream = openStream(controller.signal);
try {
yield* wrapStreamingGenerator(
stream,
function* (chunk) { yield* put(messageChunkReceived(chunk)); },
{ timeoutMs: 30_000, onError: (error) => reportStreamError(error) }
);
} finally {
controller.abort(); // source must use this to unblock any pending next()
try {
yield* call([stream, stream.return], undefined);
} catch (error) {
reportStreamError(error); // cleanup failure must not replace the original error
}
}
}
openStream is app-provided and must honor abort, settle outstanding reads promptly, and release its resource in its own finally; reportStreamError must be non-throwing. The helper itself neither aborts nor calls return() on timeout/cancellation. Abort first: an async generator queues return() behind a pending next(), so return() alone cannot unblock I/O. Without a source abort/cancel contract this example cannot guarantee prompt cleanup. Cancellation is non-blocking; if another owner must wait for finalization, expose and await a separate source-completion signal rather than treating task.cancel()/toPromise() as a cleanup barrier.
Verification cues
- Add or update saga tests with the package testing patterns; see
core/testing. - Check for duplicate watchers/registrations with targeted searches before and after edits.
- Verify
.effect(...)selector reads are mockable withexpectSaga.provide()when tests cover saga state reads. - Run the smallest relevant test target when documentation examples changed.
See also
@themislib/themis/docs/SAGAS.md— full saga concepts, APIs, and examples.- Store saga lifecycle and Core Patterns — canonical lifecycle, crash storage/cleanup, restart, and backoff mechanics.
core/selector-channels— selector change watchers and selector-backed channels.core/wait-for— one-shot selector predicate waits.core/channel-effects— genericEventChannelconsumers.core/local-storage— persistence saga helpers.core/testing— saga test setup and mocks.