action
The primitive for mutations: imperative async workflows whose writes span
an async gap — optimistic write, server round-trip, reconciling write —
where intermediate state must not leak and failure must revert cleanly
(pair with createOptimistic / createOptimisticStore).
Navigation-shaped updates do not need an action. A plain setter call is
enough: reads pull the async, and downstream async computeds hold their
previous values per-node until the new ones are ready (isPending /
latest expose the in-flight state). Reach for action only when writes
happen after async work, not merely upstream of it.
Framework-level actions (router form actions, server actions) are specializations of this primitive: they are actions in exactly this sense — the same transactional semantics — with form binding, serialization, and submission tracking layered on top. The shared name is deliberate.
Wraps a generator function so each invocation runs as a single transaction that holds every signal/store write between yields. The surrounding UI sees one atomic update when the action completes; nothing is committed until every action in the transaction has returned.
yield is the transaction-safe suspension point: the action waits for a
yielded promise and re-enters the transaction before running the code after
it. A plain await does not — the runtime has no hook into an async
generator's internal await continuations, so code between an await and
the next yield runs OUTSIDE the transaction: writes to fresh signals
commit immediately, and anything that creates a reader there — until(),
latest(), a memo or effect, a mount — is created mainline, where a read of
this action's held state makes it born held (A29): staged with the
transaction and replayed at its commit. await is still the ergonomic
choice for typed results; just put a bare yield before any write or
reader creation that follows it — including the expression of the next
yield, which is evaluated before the step re-enters:
const saved = await api.createTodo(text); // typed resultyield; // re-enter the transaction before writing or readingsetTodos(t => { ... });yield until(() => todos.some(t => t.id === saved.id));(For the same reason, don't call flush() inside an action body — it
cannot reveal the held writes and is refused.)
Each call returns a Promise that resolves with the generator's return
value, or rejects if it throws. Pair with createOptimistic /
createOptimisticStore to apply tentative writes that auto-revert if the
action fails.
Import
import { action } from "solid-js";Type signature
function action<Args extends any[], Y, R>( genFn: (...args: Args) => Generator<Y, R, any> | AsyncGenerator<Y, R, any>): (...args: Args) => Promise<R>;Parameters
genFn
- Type:
(...args: Args) => Generator<Y, R, any> | AsyncGenerator<Y, R, any>
Generator or async generator. Each yield is a suspension point that re-enters the transaction; writes between yields commit together.
Return value
A function with the generator's parameters that returns a promise for its return value. The promise rejects when the generator throws.
Examples
const [todos, setTodos] = createOptimisticStore<Todo[]>([]);
const addTodo = action(async function* (text: string) { const tempId = crypto.randomUUID(); setTodos((t) => { t.push({ id: tempId, text, pending: true }); }); // optimistic const saved = await api.createTodo(text); // network round-trip, typed yield; // re-enter the transaction setTodos((t) => { const i = t.findIndex((x) => x.id === tempId); if (i >= 0) t[i] = saved; }); return saved;});
await addTodo("buy milk");Caveats
- A plain
awaitleaves the transaction. Put a bareyieldbefore any writes that follow anawait, or useyieldin place ofawait. - Do not call
flush()inside the generator; it drains the transaction mid-step. - Navigation-shaped updates (write an input, read the async result) do not need an action. A plain setter call is held automatically.