Overview

Actions replace the repetitive boilerplate of manually wiring isLoading, error, and data atoms around every async operation. They give you a centralized, reusable execution point that is reactive, subscribable, and testable.

Choose the primitive that matches your use case: action for synchronous logic with decoupled subscribers, asyncAction for async state machines, fetchAction/getAction for HTTP, paginatedAsyncAction for paginated data, and taskAction for queued workflows.

Prefer asyncAtom when a resource should auto-refetch from atom keys and be read like an atom. Prefer asyncAction / fetch helpers when call sites pass arguments.

Action types at a glance

Type
Best for
Built-in reactive atoms?
action Sync Synchronous domain operations. Use .__subscribe() for side-effects — no loading atoms.
asyncAction Async Any Promise workflow. Gives .data(), .isLoading(), .error(), .status().
fetchAction family HTTP asyncAction + Fetch URL/method config. See fetch-actions.
paginatedAsyncAction Async + pages Adds .page(), .totalPages(), and .next() on top of asyncAction.
taskAction Queued Queue-oriented progress with concurrency and per-task status.
asyncAtom Resource Keyed/manual fetch read like an atom — not an action. See State.

action — synchronous, trackable operations

action wraps a synchronous function and adds observability: subscribers, before/after interceptors, and a consistent callable interface. The result is still returned synchronously on every call.

Reference

import { action } from 'mates';

const addToCart = action((item: string, qty = 1) => {
  cart[item] = (cart[item] ?? 0) + qty;
  return { item, qty, total: cart[item] };
});

// Call like a normal function — result returned synchronously
addToCart('widget', 2);

// Subscribe to every call's result (__subscribe, not subscribe)
const unsub = addToCart.__subscribe((result) => {
  console.log('Added to cart:', result);
});

// Intercept before execution (validation, transformation)
addToCart.interceptBefore((next, item, qty) => {
  if (!item) throw new Error('Item is required');
  return next(item.trim(), qty);
});

// Intercept after execution (enrich the result)
addToCart.interceptAfter((result) => ({ ...result, at: Date.now() }));

// Unsubscribe when done
unsub();

paginatedAsyncAction — paginated data fetching

paginatedAsyncAction extends asyncAction with a reactive page atom, totalPages, and next(). Each call fetches the current page — it does not accumulate previous pages.

import { paginatedAsyncAction } from 'mates';

const listUsers = paginatedAsyncAction(async () => {
  const page = listUsers.page.val;
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/users?_page=${page}&_limit=5`,
    { signal: listUsers.abortController.signal },
  );
  const total = Number(res.headers.get('X-Total-Count') || 10);
  listUsers.totalPages.set(Math.ceil(total / 5));
  return res.json();
});

listUsers();
listUsers.next();
listUsers.page.set(1);
listUsers();

taskAction — queued concurrent workflows

taskAction queues invocations and runs them with configurable concurrency. Ideal for file uploads, background jobs, or any workflow where per-task progress and cancellation matter.

import { taskAction } from 'mates';

const uploadFile = taskAction(async (file: File, signal: AbortSignal) => {
  const form = new FormData();
  form.append('file', file);
  const res = await fetch('/api/upload', { method: 'POST', body: form, signal });
  return res.json();
});

uploadFile.clearAndStart([file1, file2, file3], 2);
uploadFile.isRunning();
uploadFile.progress();
uploadFile.tasks();
uploadFile.successTasks();
uploadFile.stop();

action — live demo

interceptBefore / interceptAfter run on every call; __subscribe logs the enriched result — no hand-rolled event bus.

import { html, atom, renderApp, action } from 'mates';

const App = () => {
const log = atom([]);

const addToCart = action((item, qty = 1) => {
return { item, qty, at: new Date().toLocaleTimeString() };
});

addToCart.interceptBefore((next, item, qty) => next(item, qty));
addToCart.interceptAfter((r) => ({ ...r, line: `${r.qty}× ${r.item}` }));
addToCart.__subscribe((r) => {
if (!r) return;
log.set((prev) => [`[${r.at}] ${r.line}`, ...prev].slice(0, 6));
});

return () => html`
<div class="m-col m-gap">
<h3>Shopping Cart — action demo</h3>
<div class="m-flex m-flex-wrap m-gap-sm m-b-10">
<button @click=${() => addToCart('Widget', 1)}>Add Widget</button>
<button @click=${() => addToCart('Gadget', 2)}>Add 2× Gadget</button>
<button @click=${() => addToCart('Doohickey', 3)}>Add 3× Doohickey</button>
<button @click=${() => log.set([])}>Clear log</button>
</div>
${log().length === 0
? html`<p class="hint">Click an item — interceptAfter + __subscribe run on every call.</p>`
: html`<ul class="muted">${log().map(entry => html`<li>${entry}</li>`)}</ul>`}
</div>
`;
};

renderApp(App, document.getElementById('app'));