Skip to content

Latest commit

 

History

History
537 lines (407 loc) · 18.7 KB

File metadata and controls

537 lines (407 loc) · 18.7 KB

Sagas Guide

Side effect management with redux-saga generators.

Table of Contents

  1. What are Sagas?
  2. Core Effects
  3. Saga Patterns
  4. Selector Channel Effects
  5. Debounce Saga
  6. Retry and Timeout Helpers
  7. Async Generator Streams
  8. Saga Manager (Auto-Restart)
  9. Saga Monitoring (Optional)
  10. Error Handling
  11. Rules and Best Practices

What are Sagas?

Sagas are long-running generator functions that handle side effects: API calls, persistence, timers, event listeners, and complex async workflows. They listen for actions and can dispatch new actions.

This package uses typed-redux-saga for type-safe saga effects.

Why sagas?

  • Keep reducers pure — no side effects in state transitions
  • Declarative effects — easy to test and reason about
  • Cancellation support — takeLatest automatically cancels previous runs
  • Complex orchestration — coordinate multi-step workflows

Core Effects

All effects are imported from typed-redux-saga:

import { call, put, select, take, takeEvery, takeLatest,
         fork, delay, race, all } from "typed-redux-saga";

takeEvery — Handle Every Action

Spawns a new task for each matching action:

yield* takeEvery(fetchItems, function* (action) {
  const items = yield* call(api.fetchItems, action.payload[0]);
  yield* put(setItems(items));
});

takeLatest — Cancel Previous, Handle Latest

Cancels any previous running task when a new action arrives:

yield* takeLatest(searchQuery, function* (action) {
  yield* delay(300); // Natural debounce
  const results = yield* call(api.search, action.payload[0]);
  yield* put(setResults(results));
});

call — Call a Function

Calls a function and waits for its return value:

const result = yield* call(myFunction, arg1, arg2);
const data = yield* call([obj, obj.method], arg1); // Method call

put — Dispatch an Action

yield* put(setItems(items));

select — Read State

// Use named selectors with .effect()
const count = yield* selectItemCount.effect();
const item = yield* selectItemById.effect(itemId);

take — Wait for a Specific Action

const action = yield* take(submitForm);
// Execution pauses until submitForm is dispatched

fork — Non-Blocking Task

const task = yield* fork(backgroundWorker);
// Continues immediately; task runs in background

Use attached fork for child work in this package. Do not introduce detached spawn; attached children are cancelled when their parent saga fails or is cancelled.

delay — Wait for Time

yield* delay(1000); // Wait 1 second

race — First to Finish Wins

const { response, timeout } = yield* race({
  response: call(api.fetchData),
  timeout: delay(5000),
});
if (timeout) {
  yield* put(fetchTimedOut());
}

all — Run in Parallel

const [users, posts] = yield* all([
  call(api.fetchUsers),
  call(api.fetchPosts),
]);

Note: Action creators can be passed directly to takeEvery, takeLatest, takeLeading, and take. No need for .type.

Saga Patterns

Polling

function* pollData() {
  while (true) {
    yield* put(fetchData());
    yield* delay(5000);
  }
}

Multi-Step Workflow

function* wizard() {
  yield* takeEvery(startWizard, function* () {
    yield* put(showStep1());
    yield* take(step1Complete);
    yield* put(showStep2());
    yield* take(step2Complete);
    yield* put(wizardDone());
  });
}

Debounce with takeLatest

function* debounceSearch() {
  yield* takeLatest(searchInput, function* (action) {
    yield* delay(300);
    const results = yield* call(api.search, action.payload[0]);
    yield* put(setResults(results));
  });
}

Selector Channel Effects

React to selector value changes in sagas. These handle channel lifecycle automatically. Selector-channel helpers accept Store-created selectors from Svelte Store, ReactStore, and StreamingStore as long as they expose the shared .select(state, ...args) / .effect(...args) selector read shape. In saga code, pass plain, stable selector arguments just as you would to .effect(...); do not pass or subscribe to direct Svelte Readable, React ReadonlySignal, or Kefir Observable selector outputs.

Public API: @augmentcode/themis/saga

takeLatestFromSelector — Most Common

Cancel previous worker when a new value arrives:

import { takeLatestFromSelector } from "@augmentcode/themis/saga";

function* watchCurrentItem() {
  yield* takeLatestFromSelector(selectCurrentItemId, function* ({ payload, prevPayload }) {
    if (payload) {
      yield* put(loadItemData(payload));
    }
  });
}

takeEveryFromSelector — Spawn for Each Change

import { takeEveryFromSelector } from "@augmentcode/themis/saga";

function* watchAllChanges() {
  yield* takeEveryFromSelector(selectSomeValue, function* ({ payload }) {
    yield* call(handleChange, payload);
  });
}

takeLeadingFromSelector — Ignore While Running

import { takeLeadingFromSelector } from "@augmentcode/themis/saga";

function* watchWithThrottle() {
  yield* takeLeadingFromSelector(selectSomeValue, function* ({ payload }) {
    yield* call(expensiveOperation, payload);
  });
}

With Selector Arguments

Pass arguments as the second parameter:

yield* takeLatestFromSelector(selectItemById, [itemId], function* ({ payload }) {
  // Reacts when the specific item changes
});

Keep selector-channel args tuples stable. Prefer primitive scalar values such as ids, booleans, enum strings, numbers, null, or undefined; avoid fresh object, array, or function literals in the tuple. If an object/function selector argument is required, pass a stable, intentional reference such as a memoized config or existing source object rather than constructing it at the helper call site.

createChannelFromSelector — Complex Patterns

For patterns that don't fit takeEvery/takeLatest/takeLeading (e.g., races, conditional loops):

Selector channels subscribe through the actual Redux store in saga context, using getState() for reads and subscribe() for change notifications. They do not require or receive a Svelte readable state wrapper, React signal, or Kefir observable from saga context.

import { createChannelFromSelector } from "@augmentcode/themis/saga";

function* complexWatcher() {
  const channel = yield* createChannelFromSelector(mySelector);

  // Always use try/finally to ensure channel cleanup
  try {
    while (true) {
      const { payload, prevPayload } = yield* take(channel);
      // Complex logic...
    }
  } finally {
    channel.close();
  }
}

// With stable scalar arguments
function* watchSpecificItem(itemId: string) {
  const channel = yield* createChannelFromSelector(selectItemById, itemId);
  try {
    const { channelEvent, cancelled } = yield* race({
      channelEvent: take(channel),
      cancelled: take(cancelAction),
    });
    // ...
  } finally {
    channel.close();
  }
}

⚠️ Always close channels in finally blocks to prevent memory leaks.

Debounce Patterns

For new code, debounce the real action directly with takeLatest plus delay inside the worker. This keeps cancellation visible: each new action cancels the previous delayed worker before it runs stale side effects.

import { call, delay, put, takeLatest } from "typed-redux-saga";

export function* searchSaga() {
  yield* takeLatest(searchInputChanged, function* searchAfterSettled(action) {
    yield* delay(300);
    const results = yield* call(api.search, action.payload[0]);
    yield* put(searchResultsLoaded(results));
  });
}

Use takeLeading plus a trailing delay only when the first action should run immediately and newer actions should be ignored for a window:

import { call, delay, takeLeading } from "typed-redux-saga";

export function* refreshSaga() {
  yield* takeLeading(refreshRequested, function* refreshOncePerWindow(action) {
    try {
      yield* call(api.refresh, action.payload[0]);
    } finally {
      yield* delay(250);
    }
  });
}

Do not create wrapper debounce actions or use debounceSaga/debounceWithKeySaga for new work. Those exports remain available only for compatibility with existing wrapper-action flows.

Retry and Timeout Helpers

Use retryWithTimeout when a saga operation may fail transiently but should not retry forever. It retries the provided saga function up to maxRetries, races all attempts against one overall timeout, and returns an explicit outcome string.

import { call, put } from "typed-redux-saga";
import { retryWithTimeout } from "@augmentcode/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 !== "success") {
    yield* put(syncFailed(outcome));
  }
}

Outcomes are "success", "retries-exhausted", and "timeout". Keep the retried function idempotent because each retry starts it from scratch.

Async Generator Streams

Use wrapStreamingGenerator to consume an AsyncGenerator from saga code. It forwards yielded chunks, forwards a non-null final return value, supports an optional timeout, and rethrows stream errors after calling an optional package-local onError callback. It intentionally has no Sentry or Electron dependency.

import { put } from "typed-redux-saga";
import { wrapStreamingGenerator } from "@augmentcode/themis/saga";

function* consumeStream(stream: AsyncGenerator<MessageChunk, void, unknown>) {
  yield* wrapStreamingGenerator(
    stream,
    function* (chunk) {
      yield* put(messageChunkReceived(chunk));
    },
    { timeoutMs: 30_000, onError: (error) => reportStreamError(error) }
  );
}

Saga Manager

The package saga manager (src/slices/saga-manager/sagas/manager.ts) provides automatic crash recovery for app-owned sagas. Start app sagas explicitly with store.runSaga(sagaFn) after store.init(); Store starts the internal manager during initialization and derives manager names from saga functions when they are started.

Auto-Restart with Exponential Backoff

When a saga throws an unhandled error, the manager:

  1. Catches the error and logs it
  2. Records the crash in the saga's in-memory status record and dispatches a serialized crash report for Redux state
  3. Waits with exponential backoff: 1s → 2s → 4s → 8s → ... (max 10 minutes)
  4. Restarts the saga automatically
  5. If the saga stabilizes (runs for >1 minute without crashing), the restart counter gradually decreases

Reference Counting

Multiple overlapping store.runSaga(sagaFn) calls for the same derived saga name and function share the saga. The saga only stops when every returned cancel function has been invoked. This prevents accidental saga stops when components remount during navigation.

Full Store teardown is broader than a per-saga cancel: store.dispose() and the disposer returned by store.init() tear down the initialized Store runtime and stop saga tasks owned by that Store. Use the store.runSaga(sagaFn) cancel function for normal mount- or operation-scoped saga lifetimes; reserve store.dispose() for ending the whole Store lifetime.

Crash Status

Saga crash history is tracked internally by the package-owned manager so crashed sagas can restart with backoff. The raw manager status record types are internal implementation details and are not part of the public package API.

The package also stores serializable crash reports in its internal saga-manager reducer, keyed by saga name. Each report contains a primitive timestamp and a plain error object so the data can be safely kept in Redux state or app-level state persistence:

type SagaCrashReports = {
  reports: Array<{
    crashedAtTs: number;
    error: { name: string; message: string; stack?: string };
  }>;
  omittedCount: number;
};

Crash persistence is driven by package-owned actions:

  • addCrash(sagaName, report) appends the serialized report only to that saga name's entry.
  • Each saga keeps the newest 100 visible reports. When adding a crash would exceed the cap, older reports are trimmed and omittedCount increases by the number trimmed.
  • clearCrashes(sagaName) removes that saga name's crash entry. Clearing one saga does not affect reports stored for other saga names.

These actions and the reducer state path are package internals unless they are intentionally exported through a public entrypoint in a future release. App code should not import internal saga-manager files or depend on the reserved internal state shape; use documented public Store and saga APIs for application lifecycle behavior.

Saga Monitoring (Optional)

Saga monitoring is disabled by default. To observe redux-saga effects with the Store-owned monitor, pass { sagaMonitor: true } through the same third Store constructor options object used for selector scheduling:

import { Store } from "@augmentcode/themis/svelte-store";
import { counterReducer } from "./counter-slice";

export const store = new Store(
  { counter: counterReducer },
  undefined,
  { throttledSelectorFrequency: 64, sagaMonitor: true }
);

ReactStore and StreamingStore use the same final options argument. Omit sagaMonitor or pass sagaMonitor: false when monitoring is not needed; the Store still owns saga middleware creation, saga-manager startup, and store.runSaga(...) lifecycle behavior. Do not pass custom monitor objects through Store options, globally monkey-patch redux-saga, or replace the Store-owned saga middleware just to attach monitoring.

Error Handling

In Saga Workers

Wrap individual workers in try/catch:

function* handleFetchItems(action: ReturnType<typeof fetchItems>) {
  try {
    const items = yield* call(api.fetchItems, action.payload[0]);
    yield* put(fetchItems.success({ items }));
  } catch (error) {
    yield* put(fetchItems.failure(error instanceof Error ? error : new Error(String(error))));
  }
}

Async Action Error Flow

With createAsyncAction, use .success and .failure sub-actions:

export function* mySaga() {
  yield* takeEvery(fetchItems, function* (action) {
    try {
      const result = yield* call(api.fetch, action.payload[0]);
      yield* put(action.success(result));
    } catch (e) {
      yield* put(action.failure(e instanceof Error ? e : new Error(String(e))));
    }
  });
}

Channel Cleanup

Always close channels in finally blocks:

function* watchChannel() {
  const channel = yield* createChannelFromSelector(mySelector);
  try {
    // ... use channel
  } finally {
    channel.close(); // Prevents memory leaks
  }
}

Rules and Best Practices

  1. Always use named selectors — Never use yield* select((state) => ...) inline lambdas. Create a selector and use .effect().

  2. Pass action creators directlyyield* takeEvery(myAction, handler), not takeEvery(myAction.type, handler).

  3. Keep workers focused — One concern per worker function. Compose by forking multiple watchers from a root saga; do not use detached spawn for package-owned saga work.

  4. Use takeLatest for user-triggered actions and debounce — Add delay(ms) inside the worker when input should settle before side effects run.

  5. Use call() for testability — Wrap function calls in yield* call(fn, args) so they can be mocked in tests.

  6. Start sagas explicitly by functionstore.init() does not auto-start app sagas. Call store.runSaga(sagaFn) from the configured Store instance. In layouts/components, call it from onMount and return the cancel function for mount-scoped cleanup.

  7. Selectors live in [slice]-selectors.ts — Saga modules import named select* selectors from the owning slice's selectors file. Declaring select* functions or factories locally inside a saga module is invalid even when they are not exported, because it puts state-shape knowledge in the wrong layer and bypasses the architecture rules that gate selector ownership.

    // ❌ BAD — selector logic lives in the saga file
    const selectVisibleTodos = (state: AppState) => state.todos.visible;
    const selectTodoById = (todoId: string) => (state: AppState) => state.todos.map[todoId];
    
    function* watchVisibleTodos() {
      const visible = yield* select(selectVisibleTodos);
      const todo = yield* select(selectTodoById("first"));
    }
    
    // ✅ GOOD — import named selectors from the slice's selectors file and use .effect(...)
    import { selectVisibleTodos, selectTodoById } from "./todos-selectors";
    
    function* watchVisibleTodosGood() {
      const visible = yield* selectVisibleTodos.effect();
      const todo = yield* selectTodoById.effect("first");
    }
  8. Keep selector arguments stable — In .effect(...args), selector-channel args tuples, and waitFor args tuples, prefer scalar args over freshly constructed object/array/function args. Object/function args are valid only when their identity is stable and intentional.

  9. Never take('*') or other wildcard takes — Wildcard takes subscribe to every dispatched action and wake the saga on each one. They are especially harmful during streaming flows where chunk actions fire continuously, because the wildcard worker competes with the intended work and amplifies scheduler load. Use concrete action creators (or arrays of action creators), or a selector-channel helper when the trigger is a state change.

    // ❌ BAD — wakes the saga on every action, including streaming chunks
    function* watchAnything() {
      while (true) {
        const action = yield* take("*");
        yield* call(audit, action);
      }
    }
    
    // ✅ GOOD — take the concrete trigger actions
    function* watchUserEvents() {
      yield* takeEvery([userLoggedIn, userLoggedOut], auditUserEventWorker);
    }
    
    // ✅ GOOD — react to a selector value change instead of every action
    function* watchReady() {
      yield* takeLatestFromSelector(selectIsReady, function* ({ payload }) {
        if (payload) yield* call(syncReadyState);
      });
    }
<script lang="ts">
  import { onMount } from "svelte";
  import { store } from "$lib/store";

  onMount(() => store.runSaga(todosSaga));
</script>
// Imperative — returns a cancel function that stops the saga.
const cancel = store.runSaga(todosSaga);
cancel();