npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vue-sagas

v0.0.0

Published

Scalable side-effect management for Vue and Pinia, powered by generator-based sagas.

Readme

vue-sagas

Scalable side-effect management for Vue and Pinia, powered by generator-based sagas.

vue-sagas brings the saga pattern to the Vue ecosystem. Sagas are long-running generator functions that listen for store actions and reactive state changes, then orchestrate complex async workflows: sequential operations, parallel fetches, retries, timeouts, debouncing, cancellation, Web Worker offload, and more. Each effect is a plain object that your saga yields, making the entire flow testable without mocks.

The library ships as three packages:

| Package | Purpose | |---|---| | @vue-sagas/core | Framework-agnostic saga runtime, effects, channels, buffers, and testing utilities | | @vue-sagas/vue | Pure Vue integration with reactive effects, composable store, and component-scoped sagas | | @vue-sagas/pinia | Pinia plugin integration via attachSaga or automatic piniaWithSagas() plugin |


Table of Contents


Install

# Vue integration (includes core)
npm install @vue-sagas/vue

# Pinia integration (includes core)
npm install @vue-sagas/pinia

# Core only (framework-agnostic)
npm install @vue-sagas/core

@vue-sagas/vue requires vue >= 3.3.0 as a peer dependency. @vue-sagas/pinia requires vue >= 3.3.0 and pinia >= 2.1.0 as peer dependencies.


Quick Start

Vue (standalone store)

import { ref } from 'vue'
import { createSagaStore, call, delay } from '@vue-sagas/vue'

// 1. Define your store with a setup function (just like a Vue composable)
// 2. Define a root saga that reacts to store actions

const { store, sagaTask, api } = createSagaStore(
  // Setup function: return refs (state) and functions (actions)
  () => {
    const count = ref(0)
    const message = ref('')
    const increment = () => { count.value++ }
    const decrement = () => { count.value-- }
    return { count, message, increment, decrement }
  },
  // Root saga: receives typed api + reactive store proxy
  function* (api, store) {
    yield* api.takeEvery('increment', function* () {
      // Direct mutation via reactive proxy
      store.message = `Count is now ${store.count}`
      // Call async functions
      yield* call(saveToServer, store.count)
    })

    yield* api.takeLatest('decrement', function* () {
      yield* delay(500)
      store.message = 'Decremented!'
    })
  },
)

// Use in templates
// store.count, store.message, store.increment(), store.decrement()

Pinia

import { ref } from 'vue'
import { defineStore } from 'pinia'
import { attachSaga, call } from '@vue-sagas/pinia'

const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const increment = () => { count.value++ }
  return { count, increment }
})

// In a component or setup context:
const store = useCounterStore()
const task = attachSaga(store, function* (api) {
  yield* api.takeEvery('increment', function* () {
    console.log('Count:', store.count)
    yield* call(analytics.track, 'increment', store.count)
  })
})

// Cancel when done:
// task.cancel()

Pinia (plugin mode)

import { createPinia, defineStore } from 'pinia'
import { piniaWithSagas } from '@vue-sagas/pinia'

const pinia = createPinia()
pinia.use(piniaWithSagas())

// Sagas are attached automatically when the store defines a `sagas` option
const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() { this.count++ },
  },
  sagas: function* (api) {
    yield* api.takeEvery('increment', function* () {
      console.log('incremented via plugin!')
    })
  },
})

// The saga task is available on the store instance
const store = useCounterStore()
console.log(store.$sagaTask?.isRunning()) // true

How It Works

Action Wrapping

When you create a saga store with createSagaStore, every function in the setup return object is automatically wrapped. Calling store.increment() does two things:

  1. Executes the original function (which mutates the refs)
  2. Emits an action event { type: 'increment', payload: ... } to the saga channel

Sagas listen for these events via take, takeEvery, takeLatest, and other helpers.

For Pinia stores, attachSaga uses Pinia's built-in $onAction hook to intercept actions. The action event is emitted after the action completes, so sagas always see fresh state via select().

Payload Convention

Actions carry their arguments as a payload:

  • Zero arguments: payload is undefined
  • One argument: payload is that argument directly
  • Multiple arguments: payload is the arguments array
store.setUser('alice')       // { type: 'setUser', payload: 'alice' }
store.setRange(1, 10)        // { type: 'setRange', payload: [1, 10] }
store.reset()                // { type: 'reset' }

Reactive Integration

vue-sagas integrates deeply with Vue's reactivity system:

  • The store object in the root saga is a reactive proxy. You can read and write state directly: store.count = 42.
  • select() returns a non-reactive snapshot (deep-unwrapped via toRaw), safe for saga logic that should not trigger watchers.
  • Vue-specific effects (reactiveTake, watchEvery, watchLatest, watchLeading) use Vue's watch() under the hood, so they respond to any reactive change, not just dispatched actions.

Composable Store Splitting

Large stores can be composed from multiple setup functions:

function counterState() {
  const count = ref(0)
  const increment = () => { count.value++ }
  return { count, increment }
}

function userState() {
  const username = ref('')
  const setUser = (name: string) => { username.value = name }
  return { username, setUser }
}

const { store } = createSagaStore(
  () => ({ ...counterState(), ...userState() }),
  rootSaga,
)

Effect Descriptors and yield*

Every effect creator returns an object that is both a plain effect descriptor and an iterable. This enables two usage styles:

// yield — untyped, result is `any`
const action = yield take('increment')

// yield* — fully typed, result matches the effect's type parameter
const action = yield* api.take('increment')
//    ^? TypedActionEvent<State, 'increment'>

Always prefer yield* for type safety.


API Reference

Store Creation

createSagaStore(setup, rootSaga, options?)

Creates a reactive store and starts the root saga immediately.

import { ref } from 'vue'
import { createSagaStore } from '@vue-sagas/vue'

const { store, sagaTask, api, channel } = createSagaStore(
  () => {
    const items = ref<string[]>([])
    const addItem = (item: string) => { items.value.push(item) }
    return { items, addItem }
  },
  function* (api, store) {
    yield* api.takeEvery('addItem', function* (action) {
      console.log('Added:', action.payload)
    })
  },
)

Parameters:

| Parameter | Type | Description | |---|---|---| | setup | () => T | Setup function returning refs and action functions | | rootSaga | (api: SagaApi<T>, store: UnwrapRef<T>) => Generator | Root saga generator | | options? | CreateSagaStoreOptions | Optional configuration |

Options:

| Option | Type | Description | |---|---|---| | monitor? | SagaMonitor | Saga monitor for logging/debugging |

Returns: SagaStore<T>

| Property | Type | Description | |---|---|---| | store | UnwrapRef<T> | Reactive store proxy | | sagaTask | Task<void> | Root saga task handle | | api | SagaApi<T> | Typed saga API | | channel | ActionChannel | Internal action channel (advanced) |

snapshot(value)

Recursively unwraps refs and reactive proxies to produce a plain snapshot. Used internally by select().

import { ref, reactive } from 'vue'
import { snapshot } from '@vue-sagas/vue'

const state = reactive({ count: ref(5), nested: { value: ref(10) } })
const plain = snapshot(state)
// { count: 5, nested: { value: 10 } }

Pinia Integration

attachSaga(store, rootSaga, options?)

Attaches a saga to an existing Pinia store instance. Uses $onAction to intercept actions and emit them to the saga channel.

import { ref } from 'vue'
import { defineStore } from 'pinia'
import { attachSaga } from '@vue-sagas/pinia'

const useAuthStore = defineStore('auth', () => {
  const user = ref<string | null>(null)
  const login = (name: string) => { user.value = name }
  const logout = () => { user.value = null }
  return { user, login, logout }
})

const store = useAuthStore()
const task = attachSaga(store, function* (api) {
  yield* api.takeEvery('login', function* (action) {
    console.log('User logged in:', action.payload)
  })

  yield* api.takeEvery('logout', function* () {
    console.log('User logged out')
  })
})

Parameters:

| Parameter | Type | Description | |---|---|---| | store | Store | Pinia store instance | | rootSaga | (api: PiniaSagaApi<Store>) => Generator | Root saga generator | | options? | AttachSagaOptions | Optional configuration |

Returns: Task<void> -- the root saga task handle.

piniaWithSagas(options?)

Pinia plugin that automatically attaches sagas to stores that define a sagas option.

import { createPinia } from 'pinia'
import { piniaWithSagas } from '@vue-sagas/pinia'

const pinia = createPinia()
pinia.use(piniaWithSagas())

When installed, any store definition that includes a sagas property will have its saga started automatically when the store is first used. The saga task is available on the store as store.$sagaTask.

// Options-style store with sagas
const useTimerStore = defineStore('timer', {
  state: () => ({ elapsed: 0 }),
  actions: {
    start() { /* noop trigger */ },
    tick() { this.elapsed++ },
  },
  sagas: function* (api) {
    yield* api.takeLeading('start', function* () {
      while (true) {
        yield* api.delay(1000)
        yield* api.put('tick')
      }
    })
  },
})

Core Effects

All effect creators are available from @vue-sagas/vue, @vue-sagas/pinia, or @vue-sagas/core.

take(pattern)

Blocks until an action matching pattern is dispatched.

// Wait for a specific action
const action = yield* api.take('increment')

// Wait for any of several actions
const action = yield* api.take(['increment', 'decrement'] as const)

// Wait with predicate
const action = yield* api.take((a) => a.type.startsWith('user/'))

When take receives END from a channel, the saga terminates (returns from the current generator).

take(channel)

Blocks until a value is available on a channel.

const chan = yield* api.actionChannel('increment')
const action = yield* api.take(chan)

takeMaybe(pattern)

Like take, but does not auto-terminate on END. Instead, the END symbol is delivered to the saga as a normal value.

const result = yield* api.takeMaybe('someAction')
if (result === END) {
  console.log('Channel closed')
}

put(type, ...args)

Dispatches an action to the saga channel. Other sagas listening for this action type will receive it.

yield* api.put('increment')
yield* api.put('setUser', 'alice')
yield* api.put('setRange', 1, 10)

The action is constructed from the arguments using the same convention as action wrapping: zero args = no payload, one arg = payload, multiple args = array payload.

call(fn, ...args)

Calls a function and blocks until it completes. Works with regular functions, async functions, and generator functions.

// Async function
const data = yield* call(fetch, '/api/users')

// Generator function (sub-saga)
function* fetchUser(id: number) {
  const user = yield* call(api.getUser, id)
  return user
}
const user = yield* call(fetchUser, 42)

// Regular function
const result = yield* call(Math.max, 1, 2, 3)

cps(fn, ...args)

Calls a Node.js-style callback function (error-first callback as the last argument).

const data = yield* cps(fs.readFile, '/path/to/file', 'utf8')

The function is called with (...args, callback) where callback has the signature (error, result) => void.

select(selector?)

Returns a snapshot of the current store state. If a selector function is provided, returns the result of calling it with the state.

// Get entire state (non-reactive snapshot)
const state = yield* api.select()

// Get a specific value
const count = yield* api.select((s) => s.count)

The returned value is a deep-unwrapped snapshot (via toRaw), not a reactive proxy. This is intentional -- saga logic should not inadvertently trigger Vue watchers.

fork(saga, ...args)

Starts a child saga as a forked task. Forked tasks run concurrently with the parent. If a forked child throws an uncaught error, the parent is also cancelled.

function* watchIncrement() {
  while (true) {
    yield* api.take('increment')
    console.log('incremented')
  }
}

function* watchDecrement() {
  while (true) {
    yield* api.take('decrement')
    console.log('decremented')
  }
}

function* rootSaga(api, store) {
  yield* fork(watchIncrement)
  yield* fork(watchDecrement)
}

Returns a Task object that can be joined or cancelled.

spawn(saga, ...args)

Like fork, but creates a detached task. Errors in a spawned child do not propagate to the parent, and the parent does not wait for spawned tasks to complete.

// Fire-and-forget background work
yield* spawn(function* () {
  while (true) {
    yield* delay(60000)
    yield* call(heartbeat)
  }
})

join(task)

Blocks until the given task completes and returns its result. If the task fails, the error is thrown into the joining saga.

const task = yield* fork(backgroundWork)
// ... do other things ...
const result = yield* join(task)

cancel(task)

Cancels a running task. The cancelled task's generator has .return() called, and any pending effects (delays, takes, etc.) are cleaned up.

const task = yield* fork(pollServer)
yield* api.take('stopPolling')
yield* cancel(task)

delay(ms)

Blocks for the specified number of milliseconds.

yield* delay(1000) // wait 1 second

retry(maxTries, delayMs, fn, ...args)

Calls a function up to maxTries times, waiting delayMs between attempts. If all attempts fail, the last error is thrown.

const data = yield* retry(3, 2000, fetchData, '/api/users')
// Tries up to 3 times, waiting 2 seconds between each attempt

race(effects)

Runs multiple effects in parallel and resolves with the winner. Losing branches are automatically cancelled.

const result = yield* race({
  data: call(fetchData),
  timeout: delay(5000),
})

if (result.timeout !== undefined) {
  console.log('Request timed out')
} else {
  console.log('Got data:', result.data)
}

The result object has all the same keys as the input, but only the winner's key has a value; the rest are undefined.

all(effects)

Runs multiple effects in parallel and waits for all of them to complete. If any effect fails, the remaining are cancelled and the error is thrown.

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

allSettled(effects)

Like all, but waits for all effects to settle (either fulfill or reject), never throwing.

const results = yield* allSettled([
  call(fetchUsers),
  call(fetchPosts),
])

for (const r of results) {
  if (r.status === 'fulfilled') {
    console.log('Success:', r.value)
  } else {
    console.log('Failed:', r.reason)
  }
}

Each result is either { status: 'fulfilled', value: T } or { status: 'rejected', reason: unknown }.

until(predicate, timeout?)

Blocks until a state predicate becomes truthy. Requires a store subscription in the runner environment.

// Wait for a state key to become truthy
yield* api.until('isReady')

// Wait with a function predicate
yield* api.until((state) => state.count > 10)

// Wait with timeout (returns END if timeout expires)
const result = yield* api.until('isReady', 5000)
if (result === END) {
  console.log('Timed out waiting for ready state')
}

actionChannel(pattern, buffer?)

Creates a buffered channel that collects actions matching pattern. Use with take(channel) to process actions sequentially.

const chan = yield* api.actionChannel('addItem')

while (true) {
  const action = yield* api.take(chan)
  // Process one at a time, even if multiple arrive at once
  yield* call(processItem, action.payload)
}

An optional buffer argument controls buffering behavior (see Buffers).

flush(channel)

Returns all buffered items from a channel, emptying the buffer.

const chan = yield* api.actionChannel('logEvent')
// ... some time later ...
const events = yield* api.flush(chan)
console.log('Buffered events:', events)

callWorker(fn, ...args)

Executes a function in a Web Worker (browser) or worker thread (Node.js) and blocks until the result is returned. The function is serialized and run in an isolated context.

function fibonacci(n: number): number {
  if (n <= 1) return n
  return fibonacci(n - 1) + fibonacci(n - 2)
}

const result = yield* callWorker(fibonacci, 40)

The function must be self-contained (no closures over external variables).

forkWorker(fn, ...args)

Like callWorker, but returns a Task immediately without blocking. The task runs in a worker thread.

const task = yield* forkWorker(heavyComputation, data)
// ... continue with other work ...
const result = yield* join(task)

spawnWorker(fn, ...args)

Like forkWorker, but spawns a detached task. Errors do not propagate to the parent.

yield* spawnWorker(backgroundCrunch, largeDataset)

forkWorkerChannel(fn, ...args)

Runs a function in a worker that can emit multiple values over a channel. The worker function receives an emit callback as its first argument.

function streamData(emit: (value: unknown) => void, url: string) {
  // In the worker thread
  const data = fetchChunked(url)
  for (const chunk of data) {
    emit(chunk)
  }
  return 'done'
}

const { channel: chan, task } = yield* forkWorkerChannel(streamData, '/api/stream')

while (true) {
  const chunk = yield* take(chan)
  if (chunk === END) break
  processChunk(chunk)
}

Returns an object with { channel, task }.

callWorkerGen(fn, handler, ...args)

Runs a function in a worker that can send values back to the main thread for processing by a saga handler, then receive the handler's result.

function processInWorker(send: (value: unknown) => Promise<unknown>, items: number[]) {
  const results = []
  for (const item of items) {
    // Send to main thread, get response from handler saga
    const processed = await send(item)
    results.push(processed)
  }
  return results
}

const result = yield* callWorkerGen(
  processInWorker,
  function* (value) {
    // Handler runs on main thread with full saga capabilities
    const enriched = yield* call(enrichItem, value)
    return enriched
  },
  [1, 2, 3],
)

configureWorkers(config)

Configures worker code generation. Must be called before any worker effects are used.

import { configureWorkers } from '@vue-sagas/vue'

configureWorkers({
  nodeWorkerMode: 'esm', // Use ES module workers in Node.js
})

Options:

| Option | Type | Default | Description | |---|---|---|---| | nodeWorkerMode | 'cjs' \| 'esm' | 'cjs' | Module format for Node.js worker threads |


Vue Reactive Effects

These effects are available in @vue-sagas/vue and on the SagaApi object. They bridge Vue's reactivity system with the saga runtime.

reactiveTake(getter, options?)

Blocks until the next reactive change of the given source. This is the saga equivalent of watch(..., { once: true }).

// Wait for the next change to count
const newCount = yield* api.reactiveTake(() => store.count)
console.log('Count changed to:', newCount)

Internally, this creates an eventChannel backed by a Vue watch that emits once and then closes.

watchEvery(getter, worker, options?)

Forks a worker saga on every change of a reactive source. The Vue-native equivalent of takeEvery for reactive state.

yield* api.watchEvery(() => store.count, function* (newVal, oldVal) {
  console.log(`Count changed from ${oldVal} to ${newVal}`)
  yield* call(analytics.track, 'count_changed', newVal)
})

Every change forks a new instance of the worker. Previous workers are not cancelled.

watchLatest(getter, worker, options?)

Forks a worker saga on reactive change, cancelling any previously running instance. The Vue-native equivalent of takeLatest for reactive state.

yield* api.watchLatest(() => store.searchQuery, function* (query) {
  yield* delay(300)
  const results = yield* call(api.search, query)
  store.results = results
})

watchLeading(getter, worker, options?)

Forks a worker saga on reactive change, but ignores subsequent changes while the worker is still running. The Vue-native equivalent of takeLeading for reactive state.

yield* api.watchLeading(() => store.formData, function* (data) {
  yield* call(api.submitForm, data)
  store.submitStatus = 'success'
}, { deep: true })

Watch Options

All watch effects accept an optional WatchOptions parameter:

| Option | Type | Default | Description | |---|---|---|---| | deep? | boolean | false | Enable deep watching for nested objects/arrays |

// Deep-watch a nested object
yield* api.watchEvery(() => store.config, function* (config) {
  yield* call(saveConfig, config)
}, { deep: true })

Channels

Channels are communication primitives that allow sagas to send and receive messages.

channel(buffer?)

Creates a point-to-point channel. Only one taker receives each message. Defaults to an expanding buffer.

import { channel, fork, take } from '@vue-sagas/vue'

function* producer(chan) {
  for (let i = 0; i < 5; i++) {
    chan.put(i)
    yield* delay(100)
  }
  chan.close()
}

function* consumer(chan) {
  while (true) {
    const value = yield* take(chan)
    console.log('Received:', value)
  }
}

function* rootSaga(api, store) {
  const chan = channel<number>()
  yield* fork(producer, chan)
  yield* fork(consumer, chan)
}

multicastChannel()

Creates a multicast channel where all takers receive each message (broadcast).

import { multicastChannel, fork, take } from '@vue-sagas/vue'

const chan = multicastChannel<string>()

// Both consumers receive every message
yield* fork(function* () {
  while (true) {
    const msg = yield* take(chan)
    console.log('Consumer A:', msg)
  }
})

yield* fork(function* () {
  while (true) {
    const msg = yield* take(chan)
    console.log('Consumer B:', msg)
  }
})

eventChannel(subscribe, buffer?)

Creates a channel backed by an external event source. The subscribe function receives an emitter and must return an unsubscribe function. The channel auto-cleans up when closed.

import { eventChannel, END, take } from '@vue-sagas/vue'

function createWebSocketChannel(url: string) {
  return eventChannel<MessageEvent>((emit) => {
    const ws = new WebSocket(url)
    ws.onmessage = (event) => emit(event)
    ws.onclose = () => emit(END)
    return () => ws.close()
  })
}

function* watchWebSocket(api, store) {
  const chan = createWebSocketChannel('wss://example.com/feed')
  try {
    while (true) {
      const event = yield* take(chan)
      store.messages.push(JSON.parse(event.data))
    }
  } finally {
    chan.close()
  }
}

END

A special symbol that signals channel termination. When END is put onto a channel, all current and future takers receive it, and take causes the saga to return.

import { END } from '@vue-sagas/vue'

// Close a channel
chan.put(END)

// Use takeMaybe to handle END explicitly instead of auto-terminating
const msg = yield* takeMaybe(chan)
if (msg === END) {
  console.log('Channel closed')
}

isChannel(value)

Type guard that checks whether a value is a Channel instance.

import { isChannel, channel } from '@vue-sagas/vue'

const chan = channel()
isChannel(chan) // true
isChannel({})  // false

Buffers

Buffers control how channels store messages when no taker is ready.

import { buffers, channel } from '@vue-sagas/vue'

| Factory | Description | |---|---| | buffers.none() | Zero capacity. Messages are dropped if no taker is waiting. | | buffers.fixed(limit?) | Fixed capacity (default 10). Throws on overflow. | | buffers.dropping(limit) | Fixed capacity. Silently drops new messages when full. | | buffers.sliding(limit) | Fixed capacity. Drops the oldest message when full. | | buffers.expanding() | Unbounded. Grows as needed. This is the default for channels. |

// Use a sliding buffer that keeps only the last 5 items
const chan = channel<number>(buffers.sliding(5))

// Use a dropping buffer for rate-limited events
const eventChan = eventChannel<MouseEvent>(
  (emit) => {
    const handler = (e: MouseEvent) => emit(e)
    window.addEventListener('mousemove', handler)
    return () => window.removeEventListener('mousemove', handler)
  },
  buffers.dropping(1),
)

Helpers

Helpers are higher-level patterns built on top of take, fork, and cancel. They are available on the SagaApi/PiniaSagaApi objects and as standalone imports.

takeEvery(pattern, worker)

Forks a worker saga for every action matching pattern. Does not cancel previous workers.

yield* api.takeEvery('addTodo', function* (action) {
  yield* call(saveTodo, action.payload)
})

takeLatest(pattern, worker)

Forks a worker saga for the latest action, cancelling any previously running worker.

yield* api.takeLatest('search', function* (action) {
  const results = yield* call(searchApi, action.payload)
  store.results = results
})

takeLeading(pattern, worker)

Forks a worker saga for the first action, then blocks (ignoring subsequent actions) until the worker completes. The next action after completion triggers a new worker.

yield* api.takeLeading('submit', function* (action) {
  yield* call(submitForm, action.payload)
})

debounce(ms, pattern, worker)

Waits for ms milliseconds of inactivity after the last action before running the worker. If a new action arrives during the wait, the timer resets.

yield* api.debounce(300, 'search', function* (action) {
  const results = yield* call(searchApi, action.payload)
  store.results = results
})

throttle(ms, pattern, worker)

Runs the worker immediately on the first action, then ignores subsequent actions for ms milliseconds.

yield* api.throttle(1000, 'resize', function* () {
  yield* call(recalculateLayout)
})

useSaga Composable

useSaga(saga, sagaStore)

Runs a saga scoped to the current Vue component or effect scope. The saga is automatically cancelled when the component unmounts (or the effect scope is disposed).

import { useSaga, createSagaStore, delay } from '@vue-sagas/vue'

// In a component's setup()
export default defineComponent({
  setup() {
    const sagaStore = inject('sagaStore')

    const task = useSaga(function* (api) {
      // This saga runs only while the component is mounted
      yield* api.watchLatest(() => sagaStore.store.query, function* (query) {
        yield* delay(300)
        sagaStore.store.results = yield* call(search, query)
      })
    }, sagaStore)

    return { task }
  },
})

useStandaloneSaga(saga)

Runs a standalone saga (no store needed) scoped to the current Vue effect scope. Useful for timers, WebSocket listeners, and other side effects that do not need store integration.

import { useStandaloneSaga, eventChannel, take, END } from '@vue-sagas/vue'

// In a component's setup()
const task = useStandaloneSaga(function* () {
  const chan = eventChannel<number>((emit) => {
    const id = setInterval(() => emit(Date.now()), 1000)
    return () => clearInterval(id)
  })

  try {
    while (true) {
      const timestamp = yield* take(chan)
      console.log('Tick:', timestamp)
    }
  } finally {
    chan.close()
  }
})

createAsyncRef

Creates a reactive async resource with automatic saga integration. Combines refs for data, loading, and error state with a saga that handles the async operation.

import { createAsyncRef, createSagaStore, call } from '@vue-sagas/vue'
import { ref } from 'vue'

const users = createAsyncRef('users', async (page: number) => {
  const response = await fetch(`/api/users?page=${page}`)
  return response.json()
})

const { store } = createSagaStore(
  () => ({
    page: ref(1),
    setPage: (p: number) => { /* noop, triggers saga */ },
    ...users, // Spreads data, loading, error, isSuccess, isError, fetch, reset
  }),
  function* (api) {
    yield* users.saga(api) // Start the async ref watcher
  },
)

// Trigger a fetch
store.fetch(1)
// store.loading === true
// ... later ...
// store.data === [{ id: 1, name: 'Alice' }, ...]
// store.loading === false

createAsyncRef(name, fetchFn, options?)

| Parameter | Type | Description | |---|---|---| | name | string | Identifier for the async ref (used for action naming) | | fetchFn | (...args) => Promise<T> | Async function to execute | | options? | AsyncRefOptions<T> | Configuration |

Options:

| Option | Type | Default | Description | |---|---|---|---| | strategy? | AsyncSagaStrategy | 'takeLatest' | Watcher strategy: 'takeEvery', 'takeLatest', 'takeLeading', 'debounce', 'throttle' | | debounceMs? | number | -- | Required for 'debounce' and 'throttle' strategies | | retries? | number | 0 | Number of retry attempts on failure | | retryDelay? | number | 1000 | Delay between retries in ms | | transform? | (raw: unknown) => T | -- | Transform the fetch result before setting data |

Returns: AsyncRef<T, Args>

| Property | Type | Description | |---|---|---| | data | Ref<T \| null> | The resolved data | | loading | Ref<boolean> | Whether the operation is in progress | | error | Ref<string \| null> | Error message if the operation failed | | isSuccess | ComputedRef<boolean> | Whether the operation completed successfully | | isError | ComputedRef<boolean> | Whether the operation failed | | fetch | (...args: Args) => void | Trigger function to start/restart the operation | | reset | () => void | Reset all state to initial values | | saga | (api: SagaApi) => ForkEffect | Saga generator to include in your root saga |


Patterns

Async Counter with Side Effects

const { store } = createSagaStore(
  () => {
    const count = ref(0)
    const increment = () => { count.value++ }
    const decrement = () => { count.value-- }
    return { count, increment, decrement }
  },
  function* (api, store) {
    yield* api.takeEvery('increment', function* () {
      yield* call(analytics.track, 'increment', store.count)
    })

    yield* api.takeEvery('decrement', function* () {
      if (store.count < 0) {
        store.count = 0 // Direct mutation via reactive proxy
      }
    })
  },
)

Fetch with Timeout

function* fetchWithTimeout(api, store) {
  yield* api.takeLatest('fetchUsers', function* () {
    store.loading = true
    store.error = null

    const result = yield* race({
      data: call(fetchUsers),
      timeout: delay(5000),
    })

    if (result.timeout !== undefined) {
      store.error = 'Request timed out'
    } else {
      store.users = result.data
    }
    store.loading = false
  })
}

Sequential Processing

Process items one at a time using an action channel:

function* processQueue(api) {
  const chan = yield* api.actionChannel('enqueueJob')

  while (true) {
    const action = yield* api.take(chan)
    yield* call(processJob, action.payload)
    yield* delay(100) // Throttle between jobs
  }
}

Saga-to-Saga Communication

Use channels to communicate between independent sagas:

function* rootSaga(api, store) {
  const notifyChan = channel<string>()

  yield* fork(function* () {
    yield* api.takeEvery('login', function* (action) {
      notifyChan.put(`Welcome, ${action.payload}!`)
    })
  })

  yield* fork(function* () {
    while (true) {
      const message = yield* take(notifyChan)
      store.notification = message
      yield* delay(3000)
      store.notification = ''
    }
  })
}

Error Handling

function* rootSaga(api, store) {
  yield* api.takeEvery('fetchData', function* () {
    try {
      store.loading = true
      const data = yield* call(fetchData)
      store.data = data
    } catch (error) {
      store.error = error instanceof Error ? error.message : 'Unknown error'
    } finally {
      store.loading = false
    }
  })
}

Retry with Exponential Backoff

function* fetchWithBackoff(url: string) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return yield* call(fetch, url)
    } catch (error) {
      if (attempt < 4) {
        const backoff = Math.min(1000 * Math.pow(2, attempt), 30000)
        yield* delay(backoff)
      } else {
        throw error
      }
    }
  }
}

Or use the built-in retry effect:

const data = yield* retry(5, 2000, fetchData, '/api/users')

Cancellable Background Task

function* backgroundPolling(api, store) {
  yield* api.takeLeading('startPolling', function* () {
    const pollTask = yield* fork(function* () {
      while (true) {
        const data = yield* call(fetchLatest)
        store.latestData = data
        yield* delay(10000)
      }
    })

    yield* api.take('stopPolling')
    yield* cancel(pollTask)
  })
}

Worker Offload

Offload expensive computation to a Web Worker:

function* rootSaga(api, store) {
  yield* api.takeLatest('processImage', function* (action) {
    store.processing = true

    // Runs in a separate thread
    const result = yield* callWorker(
      function (imageData: ArrayBuffer) {
        // Heavy computation in worker thread
        return applyFilters(imageData)
      },
      action.payload,
    )

    store.processedImage = result
    store.processing = false
  })
}

Reactive Watchers

Use Vue reactive effects to respond to state changes without explicit actions:

function* rootSaga(api, store) {
  // Debounce search as the user types
  yield* api.watchLatest(() => store.searchQuery, function* (query) {
    if (query.length < 3) {
      store.suggestions = []
      return
    }
    yield* delay(300)
    store.suggestions = yield* call(fetchSuggestions, query)
  })

  // Track every count change
  yield* api.watchEvery(() => store.count, function* (newVal, oldVal) {
    yield* call(analytics.track, 'count_changed', { from: oldVal, to: newVal })
  })

  // Only process form on first change while submitting
  yield* api.watchLeading(() => store.formData, function* (data) {
    yield* call(autoSave, data)
  }, { deep: true })
}

Recipes

For more detailed patterns and real-world examples, see:


Saga Monitor

The saga monitor lets you observe every effect, task start/completion, and error in real time. Useful for debugging and development tooling.

import { createSagaMonitor, createSagaStore } from '@vue-sagas/vue'

const monitor = createSagaMonitor({
  verbose: true,        // Include effect results in output
  filter: ['TAKE', 'CALL', 'FORK'],  // Only log these effect types
  log: console.log,     // Custom log function (default: console.log)
})

const { store } = createSagaStore(setup, rootSaga, { monitor })

createSagaMonitor(options?)

| Option | Type | Default | Description | |---|---|---|---| | log? | (...args: unknown[]) => void | console.log | Custom log function | | verbose? | boolean | false | Include effect results and task return values | | filter? | string[] | all | Filter which effect types to log (e.g. ['TAKE', 'CALL', 'FORK']) |

Output format:

[task:0] started  rootSaga
[task:0] >> FORK(watchIncrement)
[task:1] started  watchIncrement
[task:1] >> TAKE('increment')
[task:1] << TAKE('increment') (24.3ms)
[task:1] >> CALL(saveToServer)
[task:1] << CALL(saveToServer) (102.1ms)
[task:1] >> TAKE('increment')

The monitor implements the SagaMonitor interface and can be used with both createSagaStore and attachSaga:

const task = attachSaga(store, rootSaga, { monitor })

Custom monitor:

You can also implement SagaMonitor directly for custom instrumentation:

const customMonitor: SagaMonitor = {
  onTaskStart(task, saga, args) { /* ... */ },
  onTaskResult(task, result) { /* ... */ },
  onTaskError(task, error) { /* ... */ },
  onTaskCancel(task) { /* ... */ },
  onEffectStart(task, effect) { /* ... */ },
  onEffectResult(task, effect, result) { /* ... */ },
  onEffectError(task, effect, error) { /* ... */ },
}

Testing Utilities

cloneableGenerator(fn)

Wraps a generator function so that the returned generator supports .clone(). This allows you to test branching paths in a saga without re-running the entire saga from the start.

import { cloneableGenerator, take, call } from '@vue-sagas/core'

function* fetchSaga() {
  const action = yield take('fetch')
  try {
    const data = yield call(fetchData, action.payload)
    yield call(onSuccess, data)
  } catch (error) {
    yield call(onError, error)
  }
}

const gen = cloneableGenerator(fetchSaga)()

// Advance to the try/catch branch point
gen.next()                        // yield take('fetch')
gen.next({ type: 'fetch', payload: 42 })  // yield call(fetchData, 42)

// Clone here to test both branches
const successBranch = gen.clone()
const errorBranch = gen.clone()

// Test success path
successBranch.next({ id: 1, name: 'Alice' })  // yield call(onSuccess, ...)

// Test error path
errorBranch.throw(new Error('Network error'))  // yield call(onError, ...)

Warning: Cloning works by replaying the full call history on a fresh generator instance. If the generator performs side effects before or between yield points, those side effects will re-execute on every clone.

createMockTask()

Creates a mock Task object for testing sagas that use fork, cancel, or join.

import { createMockTask, cancel, join } from '@vue-sagas/core'

function* saga() {
  const task = yield fork(backgroundWork)
  yield join(task)
  yield cancel(task)
}

const gen = saga()

// Step through the saga
const forkResult = gen.next()

// Create a mock task to pass back
const mockTask = createMockTask()
const joinResult = gen.next(mockTask)

// Verify effects
expect(joinResult.value).toEqual(join(mockTask))

// Simulate task completion
mockTask.setResult('done')
mockTask.setRunning(false)

const cancelResult = gen.next()
expect(cancelResult.value).toEqual(cancel(mockTask))

MockTask API:

| Method | Description | |---|---| | setRunning(running) | Set the task's running state | | setResult(result) | Set the task's result and mark as not running | | setError(error) | Set an error and mark as not running | | cancel() | Mark the task as cancelled and not running | | isRunning() | Check if the task is running | | isCancelled() | Check if the task was cancelled | | result() | Get the task's result | | toPromise() | Get the task's promise |

runSaga(saga, env, ...args)

Run a saga outside of a store context, useful for integration tests.

import { runSaga, ActionChannel } from '@vue-sagas/core'

const channel = new ActionChannel()
const state = { count: 0 }

const task = runSaga(
  function* () {
    const action = yield take('increment')
    state.count++
  },
  {
    channel,
    getState: () => state,
  },
)

// Simulate an action
channel.emit({ type: 'increment' })

await task.toPromise()
expect(state.count).toBe(1)

RunnerEnv:

| Property | Type | Description | |---|---|---| | channel | ActionChannel | The action channel for dispatching and receiving actions | | getState | () => unknown | Function that returns the current state | | subscribe? | (listener) => () => void | State subscription (required for until effect) | | monitor? | SagaMonitor | Optional saga monitor |


Type Safety

Using yield* for Typed Results

Always use yield* instead of yield to get fully typed results from effects:

function* rootSaga(api: SagaApi<MyStore>, store: MyStore) {
  // Typed: action is TypedActionEvent<MyStore, 'increment'>
  const action = yield* api.take('increment')
  //    ^? { type: 'increment'; payload: undefined }

  // Typed: data is the return type of fetchUsers
  const data = yield* call(fetchUsers)

  // Typed: count is number
  const count = yield* api.select((s) => s.count)

  // Typed: task is Task<void>
  const task = yield* fork(backgroundSaga)
}

SagaApi Typing

The SagaApi<State> type automatically infers action names and payload types from your store state type.

interface MyStore {
  count: number
  users: User[]
  increment: () => void
  setUser: (name: string) => void
  setRange: (min: number, max: number) => void
}

// api.take('increment') -> TypedActionEvent with payload: undefined
// api.take('setUser')   -> TypedActionEvent with payload: string
// api.take('setRange')  -> TypedActionEvent with payload: [number, number]
// api.take('count')     -> Type error: 'count' is not an action name

PiniaSagaApi

The PiniaSagaApi<Store> type works the same way for Pinia stores:

const task = attachSaga(store, function* (api) {
  // api is PiniaSagaApi<typeof store>
  yield* api.takeEvery('increment', function* (action) {
    // action is fully typed
  })
})

Types

All types are exported from @vue-sagas/vue, @vue-sagas/pinia, and @vue-sagas/core.

Core Types

| Type | Description | |---|---| | Effect | Union of all effect types | | Task<Result> | A running saga task handle | | Saga<Result> | Generator type alias for user-facing sagas | | SagaFn | Internal saga function type | | ActionEvent | { type: string; payload?: unknown } | | ActionPattern | string \| string[] \| ((action: ActionEvent) => boolean) | | ActionNames<State> | Extracts function-property keys from a store type | | ActionArgs<State, Key> | Parameter tuple for a store action | | ActionPayload<State, Key> | Derived payload type for a store action | | TypedActionEvent<State, Key> | Typed action event for a specific store action | | EffectDescriptor<Result> | Marker interface enabling yield* type inference | | EffectResult<E> | Extracts the result type from an effect descriptor | | SagaMonitor | Interface for monitoring saga execution |

Effect Types

| Type | Description | |---|---| | TakeEffect<Value> | Effect descriptor for take | | TakeMaybeEffect<Value> | Effect descriptor for takeMaybe | | CallEffect<Fn> | Effect descriptor for call | | SelectEffect<Result> | Effect descriptor for select | | ForkEffect<Saga> | Effect descriptor for fork | | SpawnEffect<Saga> | Effect descriptor for spawn | | PutEffect<Action> | Effect descriptor for put | | JoinEffect<Result> | Effect descriptor for join | | CancelEffect<Result> | Effect descriptor for cancel | | CpsCallback<Result> | Callback type for cps | | CpsEffect<Fn> | Effect descriptor for cps | | DelayEffect | Effect descriptor for delay | | RaceEffect<Effects> | Effect descriptor for race | | AllEffect<Effects> | Effect descriptor for all | | AllSettledEffect<Effects> | Effect descriptor for allSettled | | RetryEffect<Fn> | Effect descriptor for retry | | UntilEffect | Effect descriptor for until | | ActionChannelEffect<Value> | Effect descriptor for actionChannel | | FlushEffect<Value> | Effect descriptor for flush | | CallWorkerEffect<Fn> | Effect descriptor for callWorker | | ForkWorkerEffect<Fn> | Effect descriptor for forkWorker | | SpawnWorkerEffect<Fn> | Effect descriptor for spawnWorker | | ForkWorkerChannelEffect<Fn> | Effect descriptor for forkWorkerChannel | | CallWorkerGenEffect<Fn> | Effect descriptor for callWorkerGen |

Settled Result Types

| Type | Description | |---|---| | SettledResult<T> | SettledFulfilled<T> \| SettledRejected | | SettledFulfilled<T> | { status: 'fulfilled'; value: T } | | SettledRejected | { status: 'rejected'; reason: unknown } |

Channel & Buffer Types

| Type | Description | |---|---| | Channel<Item> | Channel interface with take, put, close, flush | | Buffer<Item> | Buffer interface with isEmpty, put, take, flush |

Worker Types

| Type | Description | |---|---| | WorkerFn | ((...args: any[]) => any) \| string \| URL | | WorkerHandle | Interface for worker communication | | WorkerPlatform | Interface for creating workers | | WorkerConfig | Configuration for worker code generation |

Vue-Specific Types

| Type | Description | |---|---| | SagaStore<T> | Return type of createSagaStore | | RootSagaFn<T> | Root saga function signature for Vue stores | | CreateSagaStoreOptions | Options for createSagaStore | | SagaApi<State> | Typed saga API for Vue stores | | WatchOptions | Options for reactive effects ({ deep?: boolean }) | | AsyncRef<T, Args> | Return type of createAsyncRef | | AsyncRefOptions<T> | Options for createAsyncRef | | AsyncSagaStrategy | 'takeLatest' \| 'takeEvery' \| 'takeLeading' \| 'debounce' \| 'throttle' |

Pinia-Specific Types

| Type | Description | |---|---| | PiniaSagaApi<State> | Typed saga API for Pinia stores | | PiniaRootSagaFn<StoreState> | Root saga function signature for Pinia stores | | AttachSagaOptions | Options for attachSaga |

Testing Types

| Type | Description | |---|---| | MockTask<Result> | Mock task with setRunning, setResult, setError | | CloneableGenerator<Result> | Generator with .clone() support | | RunnerEnv | Environment configuration for runSaga | | SagaMonitorOptions | Options for createSagaMonitor |


License

MIT