Back to skills
SkillHub ClubShip Full StackFull StackBackendTesting

redux-saga

Redux-Saga best practices, patterns, and API guidance for building, testing, and debugging generator-based side-effect middleware in Redux applications. Covers effect creators, fork model, channels, testing with redux-saga-test-plan, concurrency, cancellation, and modern Redux Toolkit integration. Baseline: redux-saga 1.4.2. Triggers on: saga files, redux-saga imports, generator-based middleware, mentions of "saga", "takeEvery", "takeLatest", "fork model", or "channels".

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,127
Hot score
99
Updated
March 20, 2026
Overall rating
C4.6
Composite score
4.6
Best-practice grade
C62.8

Install command

npx @skill-hub/cli install openclaw-skills-redux-saga-skill

Repository

openclaw/skills

Skill path: skills/anivar/redux-saga-skill

Redux-Saga best practices, patterns, and API guidance for building, testing, and debugging generator-based side-effect middleware in Redux applications. Covers effect creators, fork model, channels, testing with redux-saga-test-plan, concurrency, cancellation, and modern Redux Toolkit integration. Baseline: redux-saga 1.4.2. Triggers on: saga files, redux-saga imports, generator-based middleware, mentions of "saga", "takeEvery", "takeLatest", "fork model", or "channels".

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Backend, Testing, Integration.

Target audience: everyone.

License: MIT.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install redux-saga into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding redux-saga to shared team environments
  • Use redux-saga for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: redux-saga
description: >
  Redux-Saga best practices, patterns, and API guidance for building,
  testing, and debugging generator-based side-effect middleware in Redux
  applications. Covers effect creators, fork model, channels, testing
  with redux-saga-test-plan, concurrency, cancellation, and modern
  Redux Toolkit integration. Baseline: redux-saga 1.4.2.
  Triggers on: saga files, redux-saga imports, generator-based middleware,
  mentions of "saga", "takeEvery", "takeLatest", "fork model", or "channels".
license: MIT
user-invocable: false
agentic: false
compatibility: "JavaScript/TypeScript projects using redux-saga ^1.4.2 with Redux Toolkit"
metadata:
  author: Anivar Aravind
  author_url: https://anivar.net
  source_url: https://github.com/anivar/redux-saga-skill
  version: 1.0.0
  tags: redux-saga, redux, redux-toolkit, side-effects, generators, middleware, async, channels, testing
---

# Redux-Saga

**IMPORTANT:** Your training data about `redux-saga` may be outdated or incorrect — API behavior, middleware setup patterns, and RTK integration have changed. Always rely on this skill's rule files and the project's actual source code as the source of truth. Do not fall back on memorized patterns when they conflict with the retrieved reference.

## When to Use Redux-Saga

Sagas are for **workflow orchestration** — complex async flows with concurrency, cancellation, racing, or long-running background processes. For simpler patterns, prefer:

| Need | Recommended Tool |
|------|-----------------|
| Data fetching + caching | RTK Query |
| Simple async (submit → status) | `createAsyncThunk` |
| Reactive logic within slices | `createListenerMiddleware` |
| Complex workflows, parallel tasks, cancellation, channels | **Redux-Saga** |

## Rule Categories by Priority

| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Effects & Yielding | CRITICAL | `effect-` |
| 2 | Fork Model & Concurrency | CRITICAL | `fork-` |
| 3 | Error Handling | HIGH | `error-` |
| 4 | Recipes & Patterns | MEDIUM | `recipe-` |
| 5 | Channels & External I/O | MEDIUM | `channel-` |
| 6 | RTK Integration | MEDIUM | `rtk-` |
| 7 | Troubleshooting | LOW | `troubleshoot-` |

## Quick Reference

### 1. Effects & Yielding (CRITICAL)

- `effect-always-yield` — Every effect must be yielded; missing yield freezes the app
- `effect-use-call` — Use `yield call()` for async functions; never call directly
- `effect-take-concurrency` — Choose `takeEvery`/`takeLatest`/`takeLeading` based on concurrency needs
- `effect-select-usage` — Use selector functions with `select()`; never access state paths directly
- `effect-race-patterns` — Use `race` for timeouts and cancellation; only blocking effects inside

### 2. Fork Model & Concurrency (CRITICAL)

- `fork-attached-vs-detached` — `fork` shares lifecycle/errors with parent; `spawn` is independent
- `fork-error-handling` — Errors from forks bubble to parent's caller; can't catch at fork site
- `fork-no-race` — Never use `fork` inside `race`; fork is non-blocking and always wins
- `fork-nonblocking-login` — Use fork+take+cancel for auth flows that stay responsive to logout

### 3. Error Handling (HIGH)

- `error-saga-cleanup` — Use `try/finally` with `cancelled()` for proper cancellation cleanup
- `error-root-saga` — Use `spawn` in root saga for error isolation; avoid `all` for critical watchers

### 4. Recipes & Patterns (MEDIUM)

- `recipe-throttle-debounce` — Rate-limiting with `throttle`, `debounce`, `retry`, exponential backoff
- `recipe-polling` — Cancellable polling with error backoff using fork+take+cancel
- `recipe-optimistic-update` — Optimistic UI with undo using race(undo, delay)

### 5. Channels & External I/O (MEDIUM)

- `channel-event-channel` — Bridge WebSockets, DOM events, timers into sagas via `eventChannel`
- `channel-action-channel` — Buffer Redux actions for sequential or worker-pool processing

### 6. RTK Integration (MEDIUM)

- `rtk-configure-store` — Integrate saga middleware with RTK's `configureStore` without breaking defaults
- `rtk-with-slices` — Use action creators from `createSlice` for type-safe saga triggers

### 7. Troubleshooting (LOW)

- `troubleshoot-frozen-app` — Frozen apps, missed actions, bad stack traces, TypeScript yield types

## Effect Creators Quick Reference

| Effect | Blocking | Purpose |
|--------|----------|---------|
| `take(pattern)` | Yes | Wait for matching action |
| `takeMaybe(pattern)` | Yes | Like `take`, receives `END` |
| `takeEvery(pattern, saga)` | No | Concurrent on every match |
| `takeLatest(pattern, saga)` | No | Cancel previous, run latest |
| `takeLeading(pattern, saga)` | No | Ignore until current completes |
| `put(action)` | No | Dispatch action |
| `putResolve(action)` | Yes | Dispatch, wait for promise |
| `call(fn, ...args)` | Yes | Call, wait for result |
| `apply(ctx, fn, [args])` | Yes | Call with context |
| `cps(fn, ...args)` | Yes | Node-style callback |
| `fork(fn, ...args)` | No | Attached fork |
| `spawn(fn, ...args)` | No | Detached fork |
| `join(task)` | Yes | Wait for task |
| `cancel(task)` | No | Cancel task |
| `cancel()` | No | Self-cancel |
| `select(selector)` | Yes | Query store state |
| `actionChannel(pattern)` | No | Buffer actions |
| `flush(channel)` | Yes | Drain buffered messages |
| `cancelled()` | Yes | Check cancellation in `finally` |
| `delay(ms)` | Yes | Pause execution |
| `throttle(ms, pattern, saga)` | No | Rate-limit |
| `debounce(ms, pattern, saga)` | No | Wait for silence |
| `retry(n, delay, fn)` | Yes | Retry with backoff |
| `race(effects)` | Yes | First wins |
| `all([effects])` | Yes | Parallel, wait all |
| `setContext(props)` / `getContext(prop)` | No / Yes | Saga context |

## Pattern Matching

`take`, `takeEvery`, `takeLatest`, `takeLeading`, `throttle`, `debounce` accept:

| Pattern | Matches |
|---------|---------|
| `'*'` or omitted | All actions |
| `'ACTION_TYPE'` | Exact `action.type` match |
| `[type1, type2]` | Any type in array |
| `fn => boolean` | Custom predicate |

## How to Use

Read individual rule files for detailed explanations and code examples:

```
rules/effect-always-yield.md
rules/fork-attached-vs-detached.md
```

Each rule file contains:

- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and decision tables

## References

| Priority | Reference | When to read |
|----------|-----------|-------------|
| 1 | `references/effects-and-api.md` | Writing or debugging any saga |
| 2 | `references/fork-model.md` | Concurrency, error propagation, cancellation |
| 3 | `references/testing.md` | Writing or reviewing saga tests |
| 4 | `references/channels.md` | External I/O, buffering, worker pools |
| 5 | `references/recipes.md` | Throttle, debounce, retry, undo, batching, polling |
| 6 | `references/anti-patterns.md` | Common mistakes to avoid |
| 7 | `references/troubleshooting.md` | Debugging frozen apps, missed actions, stack traces |

## Full Compiled Document

For the complete guide with all rules expanded: `AGENTS.md`


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### rules/effect-always-yield.md

```markdown
---
title: Always Yield Effects
impact: CRITICAL
description: Every effect creator must be yielded. Missing yield freezes the app.
tags: effects, yield, freeze, blocking
---

# Always Yield Effects

## Problem

Calling an effect creator without `yield` executes synchronously, returns a plain object, and the middleware never processes it. In a `while(true)` loop, this freezes the app.

## Incorrect

```javascript
function* watchRequests() {
  while (true) {
    // BUG: missing yield — infinite synchronous loop, app freezes
    const action = take('FETCH_REQUESTED')
    call(fetchData, action.payload)
  }
}
```

## Correct

```javascript
function* watchRequests() {
  while (true) {
    const action = yield take('FETCH_REQUESTED')
    yield call(fetchData, action.payload)
  }
}
```

## Why

`take()` and `call()` without `yield` return plain JS objects but never pause the generator. The middleware never gets a chance to process them, so the `while` loop runs infinitely on the main thread.

```

### references/effects-and-api.md

```markdown
---
title: Effects and API Reference
impact: CRITICAL
tags: effects, api, call, put, take, fork, spawn, select, race, all
---

# Effects and API Reference

## Middleware Setup

```javascript
import createSagaMiddleware from 'redux-saga'
import { configureStore } from '@reduxjs/toolkit'
import rootSaga from './sagas'

const sagaMiddleware = createSagaMiddleware({
  // Optional: context available via getContext/setContext
  context: { api: apiClient },
  // Optional: handle uncaught errors
  onError: (error, { sagaStack }) => {
    console.error('Saga error:', error)
    console.error('Saga stack:', sagaStack)
  },
})

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefault) => getDefault().concat(sagaMiddleware),
})

sagaMiddleware.run(rootSaga)
```

`middleware.run(saga, ...args)` returns a Task descriptor. Only call after store is created.

## Effect Creators — Detailed

### take(pattern)

Suspends the generator until a matching action is dispatched.

```javascript
// Wait for specific action
const action = yield take('USER_REQUESTED')

// Wait for any of several actions
const action = yield take(['LOGOUT', 'LOGIN_ERROR'])

// Custom predicate
const action = yield take(action => action.type.startsWith('USER_'))

// Wait for all actions
const action = yield take('*')
```

`takeMaybe(pattern)` — same but receives `END` object instead of auto-terminating.

### takeEvery(pattern, saga, ...args)

Spawns a saga on every matching action. Multiple instances run concurrently.

```javascript
function* watchFetch() {
  yield takeEvery('FETCH_REQUESTED', fetchData)
}

function* fetchData(action) {
  const data = yield call(api.fetch, action.payload.url)
  yield put({ type: 'FETCH_SUCCEEDED', data })
}
```

### takeLatest(pattern, saga, ...args)

Spawns a saga on matching action, auto-cancels any previous running instance. Use when only the latest result matters (e.g., search-as-you-type).

```javascript
yield takeLatest('SEARCH_INPUT_CHANGED', performSearch)
```

### takeLeading(pattern, saga, ...args)

Spawns a saga on first matching action, ignores subsequent actions until the saga completes. Use for preventing duplicate submissions.

```javascript
yield takeLeading('SUBMIT_FORM', submitForm)
```

### put(action)

Dispatch an action to the Redux store. Non-blocking.

```javascript
yield put({ type: 'FETCH_SUCCEEDED', payload: data })

// Action creator
yield put(fetchSucceeded(data))
```

`putResolve(action)` — blocking variant that waits for the dispatch promise to resolve.

### call(fn, ...args)

Call a function and block until it returns/resolves.

```javascript
// Regular function
const result = yield call(myFunction, arg1, arg2)

// Promise-returning function
const data = yield call(fetch, '/api/users')

// Generator function (runs as sub-saga)
yield call(anotherSaga, param)

// Method invocation with context
yield call([obj, obj.method], arg1)
yield call([obj, 'methodName'], arg1)
yield call({ context: obj, fn: obj.method }, arg1)
```

`apply(context, fn, [args])` — alias for `call([context, fn], ...args)`.

### cps(fn, ...args)

Call a Node-style callback function: `fn(...args, (error, result) => {})`.

```javascript
const content = yield cps(fs.readFile, '/path/to/file')
```

### fork(fn, ...args)

Non-blocking. Creates an attached child task. Returns a Task object.

```javascript
const task = yield fork(backgroundSync)

// Parent waits for all forks to complete before returning
// Errors in forks bubble up and cancel the parent
```

### spawn(fn, ...args)

Non-blocking. Creates a detached task — independent lifecycle, errors don't bubble.

```javascript
const task = yield spawn(independentWorker)
// Parent cancellation does NOT cancel spawned tasks
// Errors in spawned tasks do NOT affect parent
```

### join(task) / join([...tasks])

Block until a forked task completes. Returns the task result.

```javascript
const task = yield fork(longRunning)
// ... do other work ...
const result = yield join(task)
```

### cancel(task) / cancel([...tasks]) / cancel()

Cancel a running task. The cancelled generator jumps to its `finally` block.

```javascript
const task = yield fork(bgSync)
yield take('STOP_SYNC')
yield cancel(task)

// Self-cancellation
yield cancel() // cancels the current saga
```

### select(selector, ...args)

Query the Redux store state.

```javascript
// Full state
const state = yield select()

// With selector
const user = yield select(getUser)
const item = yield select(getItemById, itemId)
```

### delay(ms, [val])

Block for `ms` milliseconds, then return `val` (default `true`).

```javascript
yield delay(1000) // pause 1 second
```

### actionChannel(pattern, [buffer])

Create a channel that buffers actions matching the pattern.

```javascript
const chan = yield actionChannel('REQUEST')
while (true) {
  const action = yield take(chan)
  yield call(handleRequest, action) // sequential processing
}
```

### flush(channel)

Extract all buffered messages from a channel.

```javascript
const queuedActions = yield flush(chan)
```

### cancelled()

Check if the current saga was cancelled. Use in `finally` blocks.

```javascript
function* saga() {
  try {
    // ...work...
  } finally {
    if (yield cancelled()) {
      // cancellation-specific cleanup
    }
    // common cleanup
  }
}
```

### setContext(props) / getContext(prop)

```javascript
yield setContext({ locale: 'en-US' })
const locale = yield getContext('locale')
```

## Effect Combinators

### race(effects)

Run multiple effects, first to complete wins. Losers are auto-cancelled.

```javascript
const { response, timeout } = yield race({
  response: call(fetchApi, url),
  timeout: delay(5000),
})

if (timeout) {
  yield put({ type: 'TIMEOUT_ERROR' })
}
```

Array form: `const [res1, res2] = yield race([effect1, effect2])` — winner gets result, losers get `undefined`.

### all([...effects]) / all(effects)

Run effects in parallel, wait for all to complete (like `Promise.all`).

```javascript
const [users, repos] = yield all([
  call(fetchUsers),
  call(fetchRepos),
])

// Object form
const { users, repos } = yield all({
  users: call(fetchUsers),
  repos: call(fetchRepos),
})
```

If any effect rejects, remaining effects are cancelled and the error is thrown.

## Concurrency Helpers

### throttle(ms, pattern, saga, ...args)

Spawn saga on matching action, then ignore further actions for `ms` milliseconds.

```javascript
yield throttle(500, 'SCROLL_EVENT', handleScroll)
```

### debounce(ms, pattern, saga, ...args)

Wait for `ms` of silence after last matching action, then spawn saga.

```javascript
yield debounce(300, 'SEARCH_INPUT', performSearch)
```

### retry(maxTries, delay, fn, ...args)

Retry a function up to `maxTries` times with `delay` between attempts.

```javascript
const data = yield retry(3, 2000, fetchApi, '/endpoint')
```

## runSaga(options, saga, ...args)

Execute a saga outside Redux middleware. For connecting to external I/O or testing.

```javascript
import { runSaga, stdChannel } from 'redux-saga'
import { EventEmitter } from 'events'

const emitter = new EventEmitter()
const channel = stdChannel()
emitter.on('action', channel.put)

const task = runSaga(
  {
    channel,
    dispatch: (output) => emitter.emit('action', output),
    getState: () => currentState,
  },
  mySaga,
)
```

## Blocking vs Non-Blocking

| Blocking (saga pauses) | Non-Blocking (saga continues) |
|------------------------|------------------------------|
| take, takeMaybe | takeEvery, takeLatest, takeLeading |
| call, apply, cps | put, fork, spawn, cancel |
| join, select, flush | actionChannel, throttle, debounce |
| cancelled, delay, retry | setContext |
| race, all | |

```

### references/fork-model.md

```markdown
---
title: Fork Model and Concurrency
impact: CRITICAL
tags: fork, spawn, cancellation, error-propagation, concurrency, task-lifecycle
---

# Fork Model and Concurrency

## Two Types of Forks

### Attached Forks (`fork`)

Created with `yield fork(fn, ...args)`. Attached to the parent saga.

**Lifecycle rules:**
1. Parent saga does not complete until all attached forks complete
2. Errors in any attached fork bubble up to the parent
3. When an error bubbles up, all sibling forks are cancelled
4. Cancelling the parent cancels all attached forks

```javascript
function* parentSaga() {
  // These are attached forks — parent waits for both
  const taskA = yield fork(workerA)
  const taskB = yield fork(workerB)
  // Parent body continues but won't "return" until both tasks finish

  // If workerA throws, workerB is cancelled and the error reaches here
}
```

**You cannot catch errors from forks directly.** The error propagates to whoever called the parent saga (the `call` site).

```javascript
// This does NOT work:
try {
  yield fork(failingSaga) // error won't be caught here
} catch (e) { /* never reached */ }

// Instead, catch at the call site:
function* rootSaga() {
  try {
    yield call(parentSaga) // errors from forks within parentSaga are caught HERE
  } catch (e) {
    console.error(e)
  }
}
```

### Detached Forks (`spawn`)

Created with `yield spawn(fn, ...args)`. Independent from parent.

**Lifecycle rules:**
1. Parent does not wait for spawned tasks
2. Errors do not bubble up
3. Cancelling the parent does not cancel spawned tasks
4. Behaves like a root saga started with `middleware.run()`

```javascript
function* parentSaga() {
  yield spawn(independentWorker) // fire and forget
  // If independentWorker fails, parent is unaffected
}
```

## Cancellation Semantics

### Manual Cancellation

```javascript
function* loginFlow() {
  while (true) {
    const { user, password } = yield take('LOGIN_REQUEST')
    const task = yield fork(authorize, user, password)

    const action = yield take(['LOGOUT', 'LOGIN_ERROR'])
    if (action.type === 'LOGOUT') {
      yield cancel(task) // cancel the auth task
    }
  }
}
```

### Cancellation Propagation

Cancellation propagates **downward** through the entire task tree:
- Cancelling a parent cancels all its attached forks
- Cancelling a task cancels any pending effect inside it
- Deeply nested `call` chains are unwound

Results and errors propagate **upward**.

### Cleanup After Cancellation

```javascript
function* backgroundSync() {
  try {
    while (true) {
      const data = yield call(fetchData)
      yield put(syncSuccess(data))
      yield delay(5000)
    }
  } finally {
    if (yield cancelled()) {
      // Cancellation-specific cleanup
      yield put(syncCancelled())
    }
    // Common cleanup runs on both normal exit and cancellation
  }
}
```

### Automatic Cancellation

Two scenarios trigger auto-cancellation:

1. **`race` effect** — losing effects are cancelled when the winner resolves
2. **`all` effect** — remaining effects are cancelled when any rejects

### Promise Cancellation

If a `call` effect is pending on a Promise when cancelled, redux-saga will invoke `promise[CANCEL]()` if defined. Libraries can use this for cleanup.

## Concurrency Patterns

### takeEvery — Concurrent Processing

Multiple saga instances run simultaneously. No ordering guarantee.

```javascript
yield takeEvery('FETCH_USER', fetchUser)
// If 3 FETCH_USER actions fire rapidly, 3 fetchUser instances run concurrently
```

**Use when:** all responses are needed, order doesn't matter.

### takeLatest — Latest Wins

New action cancels previous running instance.

```javascript
yield takeLatest('SEARCH', performSearch)
// Only the most recent search result is used
```

**Use when:** only the latest result matters (search, autocomplete).

### takeLeading — First Wins

Ignores actions while a saga instance is running.

```javascript
yield takeLeading('SUBMIT_ORDER', processOrder)
// Prevents duplicate order submissions
```

**Use when:** preventing duplicate work (form submissions, payments).

### actionChannel — Sequential Processing

Buffer actions and process one at a time.

```javascript
function* watchRequests() {
  const chan = yield actionChannel('REQUEST')
  while (true) {
    const action = yield take(chan)
    yield call(handleRequest, action) // blocks until done, then takes next
  }
}
```

**Use when:** requests must be processed in order, one at a time.

### Worker Pool — Limited Parallelism

Fork a fixed number of workers consuming from a shared channel.

```javascript
function* watchRequests() {
  const chan = yield actionChannel('REQUEST')
  // Fork 3 workers
  for (let i = 0; i < 3; i++) {
    yield fork(handleRequest, chan)
  }
}

function* handleRequest(chan) {
  while (true) {
    const action = yield take(chan)
    yield call(processRequest, action)
  }
}
```

## Root Saga Patterns

### Using `all` (blocks, error terminates all)

```javascript
export default function* rootSaga() {
  yield all([
    watchAuth(),
    watchFetch(),
    watchNotifications(),
  ])
}
```

If any saga throws, all are terminated. Simple but fragile.

### Using `fork` (non-blocking, error terminates all)

```javascript
export default function* rootSaga() {
  yield fork(watchAuth)
  yield fork(watchFetch)
  yield fork(watchNotifications)
}
```

Same error behavior as `all` but forks are non-blocking.

### Using `spawn` (isolated error boundaries)

```javascript
export default function* rootSaga() {
  yield spawn(watchAuth)
  yield spawn(watchFetch)
  yield spawn(watchNotifications)
}
```

Each saga is independent. Failure in one does not affect others. You must handle errors within each spawned saga.

### Spawn with Restart (use with caution)

```javascript
function* resilient(saga) {
  yield spawn(function* () {
    while (true) {
      try {
        yield call(saga)
        break // normal completion
      } catch (e) {
        console.error(`Saga ${saga.name} crashed, restarting:`, e)
      }
    }
  })
}

export default function* rootSaga() {
  yield resilient(watchAuth)
  yield resilient(watchFetch)
}
```

**Warning:** restarted sagas miss one-time actions dispatched before the crash. Use controlled failure over blanket restarts.

```

### references/testing.md

```markdown
---
title: Testing Redux Sagas
impact: HIGH
tags: testing, redux-saga-test-plan, expectSaga, testSaga, providers, vitest, jest
---

# Testing Redux Sagas

## Recommended Approach

Use `redux-saga-test-plan` with `expectSaga` for integration tests. It covers all three testing approaches (step-by-step, recorded effects, integration) and doesn't couple tests to implementation order.

```
npm install --save-dev redux-saga-test-plan
```

## Integration Testing with expectSaga

### Basic Pattern

```javascript
import { expectSaga } from 'redux-saga-test-plan'
import * as matchers from 'redux-saga-test-plan/matchers'
import { throwError } from 'redux-saga-test-plan/providers'

it('fetches and stores user', () => {
  const user = { id: 1, name: 'Alice' }

  return expectSaga(fetchUserSaga, { payload: { userId: 1 } })
    .provide([
      [matchers.call.fn(api.fetchUser), user],
    ])
    .put(fetchUserSuccess(user))
    .run()
})
```

### Assertions Available

```javascript
expectSaga(saga)
  .put(action)              // dispatches this action
  .put.like({ action: { type: 'FOO' } }) // partial match
  .call(fn, ...args)        // calls this function with these args
  .call.fn(fn)              // calls this function (any args)
  .fork(fn, ...args)        // forks this function
  .select(selector)         // selects with this selector
  .take(pattern)            // takes this pattern
  .dispatch(action)         // simulate incoming action during test
  .not.put(action)          // does NOT dispatch this action
  .returns(value)           // saga returns this value
  .run()                    // execute (returns Promise)
```

### Providing Mock Values (Static Providers)

```javascript
.provide([
  // Exact match
  [call(api.fetchUser, 1), { id: 1, name: 'Alice' }],

  // Partial match by function (ignore args)
  [matchers.call.fn(api.fetchUser), { id: 1, name: 'Alice' }],

  // Mock select
  [matchers.select.selector(getAuthToken), 'mock-token'],

  // Simulate error
  [matchers.call.fn(api.fetchUser), throwError(new Error('500'))],
])
```

### Dynamic Providers

```javascript
.provide({
  call(effect, next) {
    if (effect.fn === api.fetchUser) {
      return { id: 1, name: 'Alice' }
    }
    return next() // pass through to other providers or real execution
  },
  select({ selector }, next) {
    if (selector === getAuthToken) return 'mock-token'
    return next()
  },
})
```

### Testing with Reducer

```javascript
import userReducer from './userSlice'

it('loads user into state', () => {
  return expectSaga(fetchUserSaga, action)
    .withReducer(userReducer)
    .withState({ user: null, loading: false })
    .provide([[matchers.call.fn(api.fetchUser), user]])
    .hasFinalState({ user, loading: false })
    .run()
})
```

### Dispatching Actions During Test

```javascript
it('handles login then logout', () => {
  return expectSaga(loginFlowSaga)
    .provide([
      [matchers.call.fn(api.login), { token: 'abc' }],
    ])
    .dispatch({ type: 'LOGIN', payload: credentials })
    .dispatch({ type: 'LOGOUT' })
    .put(loginSuccess({ token: 'abc' }))
    .put(loggedOut())
    .run()
})
```

## Unit Testing with testSaga

For when effect ordering is part of the contract:

```javascript
import { testSaga } from 'redux-saga-test-plan'

it('processes in correct order', () => {
  testSaga(checkoutSaga, action)
    .next()
    .put(showLoading())        // must come first
    .next()
    .call(api.validateCart)     // then validate
    .next({ valid: true })
    .call(api.processPayment)  // then charge
    .next({ id: 'txn_123' })
    .put(checkoutSuccess('txn_123'))
    .next()
    .isDone()
})
```

### Branching with clone

```javascript
import { testSaga } from 'redux-saga-test-plan'

it('handles both valid and invalid cart', () => {
  const saga = testSaga(checkoutSaga, action)
    .next()
    .call(api.validateCart)

  // Clone before branch point
  const validBranch = saga.clone()
  const invalidBranch = saga.clone()

  validBranch
    .next({ valid: true })
    .call(api.processPayment)

  invalidBranch
    .next({ valid: false })
    .put(cartInvalid())
})
```

## Testing Without Libraries

### Using runSaga

```javascript
import { runSaga } from 'redux-saga'

async function recordSaga(saga, initialAction, state = {}) {
  const dispatched = []
  await runSaga(
    {
      dispatch: (action) => dispatched.push(action),
      getState: () => state,
    },
    saga,
    initialAction,
  ).toPromise()
  return dispatched
}

it('dispatches success action', async () => {
  jest.spyOn(api, 'fetchUser').mockResolvedValue({ id: 1 })
  const dispatched = await recordSaga(fetchUserSaga, fetchUser(1))
  expect(dispatched).toContainEqual(fetchUserSuccess({ id: 1 }))
})
```

### Manual Generator Testing

```javascript
it('yields correct effects', () => {
  const gen = fetchUserSaga({ payload: { userId: 1 } })

  // Step through
  expect(gen.next().value).toEqual(call(api.fetchUser, 1))

  // Feed result
  const user = { id: 1, name: 'Alice' }
  expect(gen.next(user).value).toEqual(put(fetchUserSuccess(user)))

  // Saga is done
  expect(gen.next().done).toBe(true)
})

// Test error path
it('handles errors', () => {
  const gen = fetchUserSaga({ payload: { userId: 1 } })
  gen.next() // advance to call
  const error = new Error('Not found')
  expect(gen.throw(error).value).toEqual(put(fetchUserFailure('Not found')))
})
```

## Testing Patterns

### Test Cancellation

```javascript
it('cancels on logout', () => {
  return expectSaga(loginFlowSaga)
    .provide([
      [matchers.call.fn(api.login), { token: 'abc' }],
    ])
    .dispatch({ type: 'LOGIN', payload: credentials })
    .dispatch({ type: 'LOGOUT' })
    .put(loginCancelled())
    .run()
})
```

### Test Race Effects

```javascript
it('times out after 5 seconds', () => {
  return expectSaga(fetchWithTimeoutSaga)
    .provide([
      [matchers.call.fn(api.fetch), delay(10000)], // simulate slow response
      [matchers.race.fn, { timeout: true }],
    ])
    .put(timeoutError())
    .run()
})
```

### Test Throttle/Debounce

```javascript
it('debounces search', () => {
  return expectSaga(watchSearchSaga)
    .dispatch({ type: 'SEARCH', query: 'a' })
    .dispatch({ type: 'SEARCH', query: 'ab' })
    .dispatch({ type: 'SEARCH', query: 'abc' })
    .provide([[matchers.call.fn(api.search), results]])
    // Only the last search should trigger
    .put(searchResults(results))
    .run({ timeout: 1000 })
})
```

## Vitest vs Jest

Both work identically with `redux-saga-test-plan`. The library returns Promises from `.run()`:

```javascript
// Jest: return the promise
it('works', () => {
  return expectSaga(saga).run()
})

// Vitest: async/await
it('works', async () => {
  await expectSaga(saga).run()
})
```

```

### references/channels.md

```markdown
---
title: Channels and External I/O
impact: MEDIUM
tags: channels, eventChannel, actionChannel, multicast, websocket, external-io, buffers
---

# Channels and External I/O

## Channel Types

### actionChannel — Buffer Redux Actions

Queue Redux actions for sequential processing.

```javascript
import { actionChannel, take, call } from 'redux-saga/effects'

function* watchSaves() {
  const chan = yield actionChannel('SAVE_DOCUMENT')
  while (true) {
    const action = yield take(chan)
    yield call(saveDocument, action) // one at a time
  }
}
```

### eventChannel — External Event Sources

Bridge WebSockets, DOM events, timers into sagas.

```javascript
import { eventChannel, END } from 'redux-saga'

function createSocketChannel(socket) {
  return eventChannel((emit) => {
    socket.on('message', (data) => emit(data))
    socket.on('error', (err) => emit({ error: err }))
    socket.on('close', () => emit(END))
    return () => socket.close()
  })
}

function* watchSocket(socket) {
  const channel = yield call(createSocketChannel, socket)
  try {
    while (true) {
      const payload = yield take(channel)
      if (payload.error) {
        yield put(socketError(payload.error))
      } else {
        yield put(messageReceived(payload))
      }
    }
  } finally {
    channel.close()
  }
}
```

### channel — Generic Saga-to-Saga Communication

Manual put/take for direct communication between sagas.

```javascript
import { channel } from 'redux-saga'

function* producer(chan) {
  while (true) {
    const data = yield call(fetchData)
    yield put(chan, data)
    yield delay(5000)
  }
}

function* consumer(chan) {
  while (true) {
    const data = yield take(chan)
    yield call(processData, data)
  }
}

function* rootSaga() {
  const chan = yield call(channel)
  yield fork(producer, chan)
  yield fork(consumer, chan)
}
```

### multicastChannel — Broadcast to Multiple Consumers

Each message is delivered to all takers, not just one.

```javascript
import { multicastChannel } from 'redux-saga'

function* root() {
  const chan = yield call(multicastChannel)
  yield fork(workerA, chan) // both receive every message
  yield fork(workerB, chan)
  yield put(chan, { type: 'BROADCAST' })
}
```

## Buffer Strategies

```javascript
import { buffers } from 'redux-saga'

// No buffer: drops messages when no taker
eventChannel(sub, buffers.none())

// Fixed buffer: errors if queue exceeds limit
eventChannel(sub, buffers.fixed(10))

// Expanding buffer: grows dynamically
eventChannel(sub, buffers.expanding(10))

// Dropping buffer: silently drops new messages on overflow
eventChannel(sub, buffers.dropping(10))

// Sliding buffer: drops oldest messages on overflow
eventChannel(sub, buffers.sliding(10))
```

| Buffer | On Overflow | Use When |
|--------|------------|----------|
| `none()` | Drops message | Fire-and-forget events |
| `fixed(n)` | Throws error | Must process all, overflow is a bug |
| `expanding(n)` | Grows | Must process all, variable load |
| `dropping(n)` | Drops newest | Latest data not critical |
| `sliding(n)` | Drops oldest | Only latest messages matter |

## flush — Drain a Channel

```javascript
function* processBatch(chan) {
  while (true) {
    yield delay(5000) // wait 5 seconds
    const messages = yield flush(chan) // grab everything buffered
    if (messages.length > 0) {
      yield call(api.sendBatch, messages)
    }
  }
}
```

## runSaga — Connect to External I/O

Run a saga outside Redux, connected to any event system:

```javascript
import { runSaga, stdChannel } from 'redux-saga'
import { EventEmitter } from 'events'

const emitter = new EventEmitter()
const channel = stdChannel()
emitter.on('action', channel.put)

const task = runSaga(
  {
    channel,
    dispatch: (output) => emitter.emit('action', output),
    getState: () => ({ /* app state */ }),
  },
  mySaga,
)
```

```

### references/recipes.md

```markdown
---
title: Recipes and Common Patterns
impact: MEDIUM
tags: recipes, throttle, debounce, retry, undo, batching, login-flow, polling
---

# Recipes and Common Patterns

## Login / Logout Flow

Non-blocking auth with cancellation support:

```javascript
function* loginFlow() {
  while (true) {
    const { payload: { user, password } } = yield take('LOGIN_REQUEST')
    const task = yield fork(authorize, user, password)

    const action = yield take(['LOGOUT', 'LOGIN_ERROR'])
    if (action.type === 'LOGOUT') {
      yield cancel(task)
      yield call(api.clearToken)
    }
  }
}

function* authorize(user, password) {
  try {
    const token = yield call(api.login, user, password)
    yield put({ type: 'LOGIN_SUCCESS', token })
    yield call(api.saveToken, token)
  } catch (e) {
    yield put({ type: 'LOGIN_ERROR', error: e.message })
  } finally {
    if (yield cancelled()) {
      // cleanup on cancellation
    }
  }
}
```

## Polling

```javascript
function* pollSaga(action) {
  while (true) {
    try {
      const data = yield call(api.fetchStatus, action.payload.id)
      yield put(statusUpdated(data))

      if (data.status === 'complete') return

      yield delay(3000)
    } catch (e) {
      yield put(pollError(e.message))
      yield delay(10000) // back off on error
    }
  }
}

function* watchPoll() {
  while (true) {
    const action = yield take('START_POLLING')
    const task = yield fork(pollSaga, action)

    yield take('STOP_POLLING')
    yield cancel(task)
  }
}
```

## Optimistic Updates with Undo

```javascript
function* deleteSaga(action) {
  const { itemId } = action.payload

  // Optimistic: remove from UI immediately
  yield put(removeItem(itemId))
  yield put(showUndoToast(itemId))

  const { undo } = yield race({
    undo: take((a) => a.type === 'UNDO_DELETE' && a.payload.itemId === itemId),
    commit: delay(5000),
  })

  if (undo) {
    yield put(restoreItem(itemId))
  } else {
    yield call(api.deleteItem, itemId)
  }
}
```

## Pagination / Infinite Scroll

```javascript
function* loadMoreSaga() {
  let page = 1
  let hasMore = true

  while (hasMore) {
    yield take('LOAD_MORE')
    yield put(setLoading(true))

    const { items, total } = yield call(api.fetchItems, { page, limit: 20 })
    yield put(appendItems(items))

    page += 1
    hasMore = items.length > 0 && (page - 1) * 20 < total
    yield put(setLoading(false))
  }
}
```

## Request Deduplication

```javascript
const pending = new Map()

function* fetchWithDedup(action) {
  const key = action.payload.url

  if (pending.has(key)) {
    // Wait for the existing request
    const result = yield join(pending.get(key))
    yield put(fetchSuccess(result))
    return
  }

  const task = yield fork(function* () {
    try {
      const result = yield call(api.fetch, key)
      pending.delete(key)
      return result
    } catch (e) {
      pending.delete(key)
      throw e
    }
  })

  pending.set(key, task)
  const result = yield join(task)
  yield put(fetchSuccess(result))
}
```

## Batching Actions

Process multiple buffered actions as a single batch:

```javascript
function* batchProcessor() {
  const chan = yield actionChannel('ANALYTICS_EVENT')

  while (true) {
    yield delay(5000) // wait 5 seconds
    const events = yield flush(chan)
    if (events.length > 0) {
      yield call(api.sendAnalyticsBatch, events)
    }
  }
}
```

## Parallel Data Loading

```javascript
function* dashboardSaga() {
  yield put(dashboardLoading())

  const [users, stats, notifications] = yield all([
    call(api.fetchUsers),
    call(api.fetchStats),
    call(api.fetchNotifications),
  ])

  yield put(dashboardLoaded({ users, stats, notifications }))
}
```

## Timeout Wrapper

```javascript
function* withTimeout(saga, ms, ...args) {
  const { result, timeout } = yield race({
    result: call(saga, ...args),
    timeout: delay(ms),
  })

  if (timeout) {
    throw new Error(`Saga timed out after ${ms}ms`)
  }

  return result
}

// Usage
function* fetchSaga() {
  const data = yield call(withTimeout, api.fetchData, 5000)
  yield put(dataLoaded(data))
}
```

```

### references/anti-patterns.md

```markdown
# Redux-Saga Anti-Patterns

## Table of Contents

- Missing yield
- Direct async calls
- Fork inside race
- Catching fork errors in parent
- Blanket auto-restart
- Wrong watcher for the job
- Mixing async patterns
- State path coupling
- Non-blocking inside race
- Side effects in generators without call
- Ignoring cancellation cleanup
- Monolithic root saga

## Missing yield

```javascript
// BAD: infinite synchronous loop — app freezes
while (true) {
  const action = take('REQUEST')
  call(fetchData, action.payload)
}

// GOOD: yield pauses the generator for the middleware
while (true) {
  const action = yield take('REQUEST')
  yield call(fetchData, action.payload)
}
```

## Direct async calls

```javascript
// BAD: not cancellable, not testable with deepEqual
function* fetchSaga() {
  const data = yield api.fetchData()
  yield put(dataLoaded(data))
}

// GOOD: use call() for middleware control
function* fetchSaga() {
  const data = yield call(api.fetchData)
  yield put(dataLoaded(data))
}
```

## Fork inside race

```javascript
// BAD: fork is non-blocking — always wins immediately
const { task, timeout } = yield race({
  task: fork(longRunning),
  timeout: delay(5000),
})

// GOOD: use blocking call inside race
const { result, timeout } = yield race({
  result: call(longRunning),
  timeout: delay(5000),
})

// GOOD: fork separately, join in race
const task = yield fork(longRunning)
const { done, timeout } = yield race({
  done: join(task),
  timeout: delay(5000),
})
if (timeout) yield cancel(task)
```

## Catching fork errors in parent

```javascript
// BAD: catch never reached — fork returns immediately
function* parent() {
  try {
    yield fork(failingSaga)
  } catch (e) {
    // NEVER REACHED
  }
}

// GOOD: catch at the call site
function* root() {
  try {
    yield call(parent)
  } catch (e) {
    // Errors from forks inside parent arrive here
  }
}

// GOOD: use spawn for isolation
function* root() {
  yield spawn(function* () {
    try {
      yield call(riskyWork)
    } catch (e) {
      yield put(workFailed(e.message))
    }
  })
}
```

## Blanket auto-restart

```javascript
// BAD: restarted sagas miss one-time actions (INIT, REHYDRATE)
function* restartable(saga) {
  yield spawn(function* () {
    while (true) {
      try {
        yield call(saga)
        break
      } catch (e) {
        console.error(`Restarting ${saga.name}`)
      }
    }
  })
}

// GOOD: handle errors explicitly, fail predictably
function* safeSaga(saga) {
  yield spawn(function* () {
    try {
      yield call(saga)
    } catch (e) {
      console.error(`${saga.name} failed:`, e)
      yield put(sagaCrashed(saga.name, e.message))
    }
  })
}
```

## Wrong watcher for the job

```javascript
// BAD: stale search results overwrite latest
yield takeEvery('SEARCH', performSearch)

// GOOD: only latest matters
yield takeLatest('SEARCH', performSearch)

// BAD: duplicate form submissions
yield takeEvery('SUBMIT_ORDER', processOrder)

// GOOD: ignore while processing
yield takeLeading('SUBMIT_ORDER', processOrder)
```

## Mixing async patterns

```javascript
// BAD: some features use thunks, some sagas, some direct fetch
// No consistent pattern for the team

// GOOD: pick one default
// - RTK Query for data fetching
// - Sagas only for complex workflows
// Document the decision and be consistent
```

## State path coupling

```javascript
// BAD: hardcoded state paths
function* saga() {
  const state = yield select()
  const user = state.auth.user
  const items = state.cart.items
}

// GOOD: reusable selectors
const getUser = (state) => state.auth.user
const getCartItems = (state) => state.cart.items

function* saga() {
  const user = yield select(getUser)
  const items = yield select(getCartItems)
}
```

## Non-blocking effects inside race

```javascript
// BAD: put is non-blocking — always resolves immediately
const { dispatched, timeout } = yield race({
  dispatched: put(action), // instant
  timeout: delay(5000),    // never reached
})

// GOOD: only use blocking effects in race
const { result, timeout } = yield race({
  result: call(asyncWork),
  timeout: delay(5000),
})
```

## Side effects without call

```javascript
// BAD: side effects inside the generator without call
function* saga() {
  localStorage.setItem('key', 'value') // not testable, not cancellable
  console.log('done') // can't be intercepted
}

// GOOD: wrap all side effects in call
function* saga() {
  yield call([localStorage, 'setItem'], 'key', 'value')
  yield call(console.log, 'done')
}
```

## Ignoring cancellation cleanup

```javascript
// BAD: leaves UI in loading state when cancelled
function* fetchSaga() {
  yield put(setLoading(true))
  const data = yield call(api.fetch)
  yield put(setLoading(false))
  yield put(dataLoaded(data))
  // If cancelled during api.fetch, setLoading(false) never fires
}

// GOOD: cleanup in finally
function* fetchSaga() {
  try {
    yield put(setLoading(true))
    const data = yield call(api.fetch)
    yield put(dataLoaded(data))
  } catch (e) {
    yield put(fetchFailed(e.message))
  } finally {
    yield put(setLoading(false))
    if (yield cancelled()) {
      yield put(fetchCancelled())
    }
  }
}
```

## Monolithic root saga

```javascript
// BAD: one crash kills everything
export default function* rootSaga() {
  yield all([
    watchAuth(),
    watchFetch(),
    watchAnalytics(),
  ])
}

// GOOD: isolated error boundaries
export default function* rootSaga() {
  yield spawn(watchAuth)
  yield spawn(watchFetch)
  yield spawn(watchAnalytics)
}
```

```

### references/troubleshooting.md

```markdown
---
title: Troubleshooting
impact: LOW
tags: troubleshooting, debugging, freeze, missed-actions, stack-traces, common-errors
---

# Troubleshooting

## App Freezes After Adding a Saga

**Cause:** Missing `yield` before an effect in a loop.

```javascript
// BUG: synchronous infinite loop
while (true) {
  const action = take('REQUEST') // missing yield
}

// FIX
while (true) {
  const action = yield take('REQUEST')
}
```

## Saga Misses Dispatched Actions

**Cause:** Saga blocked on a `call` effect when the action arrives.

**Fix:** Use `fork` for non-blocking execution so the saga remains responsive to other actions.

```javascript
// Instead of blocking call:
const result = yield call(api.fetch, url) // blocked — misses actions

// Use fork + take:
const task = yield fork(fetchWorker, url)
const action = yield take(['CANCEL', 'SUCCESS'])
if (action.type === 'CANCEL') yield cancel(task)
```

## Unreadable Error Stack Traces

**Fix 1:** Use `onError` with `sagaStack`:

```javascript
createSagaMiddleware({
  onError: (error, { sagaStack }) => {
    console.error(error.message)
    console.error(sagaStack)
  },
})
```

**Fix 2:** Add `babel-plugin-redux-saga` for dev:

```json
{ "plugins": ["redux-saga"] }
```

## Saga Doesn't Start

**Cause:** `sagaMiddleware.run()` called before store is created.

```javascript
// WRONG order
sagaMiddleware.run(rootSaga)
const store = configureStore(...)

// CORRECT order
const store = configureStore(...)
sagaMiddleware.run(rootSaga)
```

## TypeScript: yield Returns `any`

**Cause:** TypeScript can't infer generator yield types.

**Fix:** Type the result manually:

```typescript
const user: User = yield call(api.fetchUser, id)
```

Or use `typed-redux-saga`:

```typescript
import { call } from 'typed-redux-saga'
const user = yield* call(api.fetchUser, id) // properly typed
```

## "Saga was provided undefined action" Warning

**Cause:** Action creator returns undefined instead of an action object.

**Fix:** Verify your action creators return `{ type: string, payload?: any }`.

## Effect Not Executing

**Cause:** Using effect creator result without yielding it.

```javascript
// BUG: creates effect object but doesn't execute it
put(myAction()) // returns { PUT: { action } } — never dispatched

// FIX
yield put(myAction())
```

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### README.md

```markdown
# Redux-Saga Skill

Created by **[Anivar Aravind](https://anivar.net)**

An AI agent skill for writing, testing, and debugging Redux-Saga code with modern best practices.

## The Problem

AI agents often generate outdated or incorrect redux-saga code — missing `yield` on effects, calling async functions directly instead of using `call()`, using `fork` inside `race`, or setting up root sagas that crash entirely when one watcher fails. These are subtle bugs that pass linting but break at runtime.

## This Solution

22 rules with incorrect→correct code examples that teach agents the actual redux-saga API behavior, fork model semantics, and modern Redux Toolkit integration patterns. Each rule targets a specific mistake and shows exactly how to fix it.

## Install

```bash
npx skills add anivar/redux-saga-skill -g
```

Or with full URL:

```bash
npx skills add https://github.com/anivar/redux-saga-skill
```

## Baseline

- redux-saga ^1.4.2
- @reduxjs/toolkit (recommended)
- redux-saga-test-plan ^5.x (for testing rules)
- Jest or Vitest

## What's Inside

### 22 Rules Across 7 Categories

| Priority | Category | Rules | Impact |
|----------|----------|-------|--------|
| 1 | Effects & Yielding | 5 | CRITICAL |
| 2 | Fork Model & Concurrency | 4 | CRITICAL |
| 3 | Error Handling | 2 | HIGH |
| 4 | Testing Patterns | 4 | HIGH |
| 5 | Recipes & Patterns | 3 | MEDIUM |
| 6 | Channels & External I/O | 2 | MEDIUM |
| 7 | RTK Integration | 2 | MEDIUM |

Each rule file contains:
- Why it matters
- Incorrect code with explanation
- Correct code with explanation
- Decision tables and additional context

### 7 Deep-Dive References

| Reference | Covers |
|-----------|--------|
| `effects-and-api.md` | All effect creators, blocking vs non-blocking, pattern matching |
| `fork-model.md` | Attached vs detached forks, error propagation, cancellation semantics |
| `testing.md` | expectSaga, testSaga, providers, matchers, reducer integration |
| `channels.md` | eventChannel, actionChannel, buffers, worker pools |
| `recipes.md` | Throttle, debounce, retry, polling, optimistic updates, batching |
| `anti-patterns.md` | 12 common mistakes with BAD/GOOD examples |
| `troubleshooting.md` | Frozen apps, missed actions, bad stack traces, TypeScript yield types |

## Structure

```
├── SKILL.md                          # Entry point for AI agents
├── AGENTS.md                         # Compiled guide with all rules expanded
├── rules/                            # Individual rules (Incorrect→Correct)
│   ├── effect-*                      # Effects & yielding (CRITICAL)
│   ├── fork-*                        # Fork model & concurrency (CRITICAL)
│   ├── error-*                       # Error handling (HIGH)
│   ├── testing-*                     # Testing patterns (HIGH)
│   ├── recipe-*                      # Common patterns (MEDIUM)
│   ├── channel-*                     # Channels & external I/O (MEDIUM)
│   ├── rtk-*                         # Redux Toolkit integration (MEDIUM)
│   └── troubleshoot-*               # Debugging (LOW)
└── references/                       # Deep-dive reference docs
    ├── effects-and-api.md
    ├── fork-model.md
    ├── testing.md
    ├── channels.md
    ├── recipes.md
    ├── anti-patterns.md
    └── troubleshooting.md
```

## Ecosystem — Skills by [Anivar Aravind](https://anivar.net)

### Testing Skills
| Skill | What it covers | Install |
|-------|---------------|---------|
| [jest-skill](https://github.com/anivar/jest-skill) | Jest best practices — mock design, async testing, matchers, timers, snapshots | `npx skills add anivar/jest-skill -g` |
| [redux-saga-testing](https://github.com/anivar/redux-saga-testing) | Redux-Saga testing — expectSaga, testSaga, providers | `npx skills add anivar/redux-saga-testing -g` |
| [zod-testing](https://github.com/anivar/zod-testing) | Zod schema testing — safeParse, mock data, property-based | `npx skills add anivar/zod-testing -g` |
| [msw-skill](https://github.com/anivar/msw-skill) | MSW 2.0 API mocking — handlers, responses, GraphQL | `npx skills add anivar/msw-skill -g` |

### Library & Framework Skills
| Skill | What it covers | Install |
|-------|---------------|---------|
| [zod-skill](https://github.com/anivar/zod-skill) | Zod v4 schema validation, parsing, error handling | `npx skills add anivar/zod-skill -g` |
| [msw-skill](https://github.com/anivar/msw-skill) | MSW 2.0 handlers, responses, migration | `npx skills add anivar/msw-skill -g` |

### Engineering Analysis
| Skill | What it covers | Install |
|-------|---------------|---------|
| [contributor-codebase-analyzer](https://github.com/anivar/contributor-codebase-analyzer) | Code analysis, annual reviews, promotion readiness | `npx skills add anivar/contributor-codebase-analyzer -g` |

## Author

**[Anivar Aravind](https://anivar.net)** — Building AI agent skills for modern JavaScript/TypeScript development.

## License

MIT

```

### _meta.json

```json
{
  "owner": "anivar",
  "slug": "redux-saga-skill",
  "displayName": "Redux Saga",
  "latest": {
    "version": "1.0.1",
    "publishedAt": 1772684716183,
    "commit": "https://github.com/openclaw/skills/commit/afe31ebfe3468536b1ddb2f6bed94c82a5cb6eca"
  },
  "history": []
}

```

redux-saga | SkillHub