@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
116 lines (86 loc) • 5.69 kB
Markdown
---
title: "Async Tasks and Effects"
description: "See how Cause & Effect models async derivation, pending state, cancellation, and effect branching."
---
Async work in Cause & Effect is not bolted onto a synchronous system. `createTask()` creates a dedicated async node with cancellation and a reactive pending flag, while `createEffect()` and `match()` give you the side-effect layer that consumes sync and async signals. The relevant source files are `src/nodes/task.ts`, `src/nodes/effect.ts`, and the `recomputeTask()` / `runEffect()` paths in `src/graph.ts`.
## What This Concept Is
A Task is an asynchronous derived signal. It tracks dependencies from the synchronous part of its callback, aborts in-flight work when dependencies change, and retains the previous resolved value while a new run is pending. An Effect is a terminal sink that reacts to changes and optionally registers cleanup work.
## Why It Exists
Most state systems force you to hand-roll loading flags, race cancellation, and stale data handling. This library makes async a first-class graph concept. Because `Task` has its own `AbortController` and `pendingNode`, `match()` can route between `nil`, `err`, `stale`, and `ok` states without extra bookkeeping in user code.
## Internal Mechanics
`createTask()` builds a `TaskNode<T>` plus an internal `StateNode<boolean>` called `pendingNode`. The public `isPending()` method subscribes to that state, which is why stale UI can reactively re-run. In `recomputeTask()` inside `src/graph.ts`, the old controller is aborted, a new controller is created, dependencies are tracked while the callback runs synchronously, and `setState(node.pendingNode, true)` marks the task as pending before the promise resolves.
Effects are queued, not executed inline during propagation. `propagate()` pushes dirty effect nodes into `queuedEffects`, and `flush()` later calls `refresh(effect)` once batching completes. That is how the library avoids repeated effect runs inside a larger transaction.
```mermaid
stateDiagram-v2
[*] --> nil
nil --> ok: first resolution
ok --> stale: dependency changes
stale --> ok: resolution succeeds
stale --> err: resolution rejects
nil --> err: first resolution rejects
err --> stale: dependency changes with retained value
```
## Basic Usage
```ts
import { createState, createTask, createEffect, match } from '@zeix/cause-effect'
const userId = createState(1)
const user = createTask(async (_prev, abort) => {
const response = await fetch(`/api/users/${userId.get()}`, { signal: abort })
if (!response.ok) throw new Error('Request failed')
return response.json() as Promise<{ name: string }>
})
createEffect(() => {
match(user, {
nil: () => console.log('Loading...'),
stale: () => console.log('Refreshing...'),
ok: value => console.log(value.name),
err: error => console.error(error.message),
})
})
```
## Advanced Usage
Seed a task with an initial value and expose a stale indicator without clearing the previous value:
```ts
import {
createState,
createTask,
createMemo,
createEffect,
match,
} from '@zeix/cause-effect'
const query = createState('books')
const results = createTask(
async (_prev, abort) => {
const response = await fetch(`/search?q=${query.get()}`, { signal: abort })
return response.json() as Promise<{ items: string[] }>
},
{ value: { items: [] } },
)
const itemCount = createMemo(() => results.get().items.length)
createEffect(() => {
match(results, {
stale: () => console.log(`Refreshing ${query.get()} (${itemCount.get()} cached)`),
ok: value => console.log(value.items),
})
})
```
You can still use a plain effect for cleanup-based imperative work:
```ts
import { createState, createEffect } from '@zeix/cause-effect'
const visible = createState(true)
createEffect(() => {
if (!visible.get()) return
const id = setInterval(() => console.log('tick'), 1000)
return () => clearInterval(id)
})
```
<Callout type="warn">Do not call `.set()` on reactive state from an async `match()` handler and expect cancellation semantics. The source code in `src/nodes/effect.ts` explicitly treats async handlers as fire-and-forget side effects. If the async work should drive reactive state, model it as a `Task` so cancellation and stale routing stay correct.</Callout>
<Accordions>
<Accordion title="Why `Task` keeps the previous value during re-fetch">
The task node preserves `node.value` when a new run starts and only replaces it after a successful resolution. That design gives you a real stale state, not just loading-or-data. It makes UI and integration logic smoother because downstream memos can keep working with the last good result while a refresh is in flight. The trade-off is that your code must actively branch on `isPending()` or `match(..., { stale })` if showing stale data would be misleading in a given workflow.
</Accordion>
<Accordion title="Why effects are queued instead of run immediately">
If effects ran inline during every `setState()`, a burst of related updates would produce repeated side effects and inconsistent intermediate observations. The queue in `src/graph.ts` lets `batch()` collapse many writes into one flush and ensures effects see the graph after propagation settles. This improves correctness for derived async workflows and composite mutations. The trade-off is that effects are still synchronous relative to the end of a batch, so you should not treat them as a background scheduler or use them for heavy CPU work.
</Accordion>
</Accordions>
See `/docs/guides/async-data-pipelines` for a complete production pattern and `/docs/api-reference/memo-task-effect` for signatures.