Overview

asyncAction takes an async function and returns an enhanced callable that automatically manages a reactive state machine. Instead of manually setting isLoading, error, and data atoms around every await, you get them as reactive atoms you can read directly in templates.

The returned object is directly callable — fetchUser('u_1'). Reactive atoms update automatically: isLoading flips to true when the call starts, and status transitions through 'loading''success' (or 'error') when it settles.

asyncAction vs asyncAtom vs fetchAction

API
Invocation
Prefer when
asyncAction Call with args Any Promise workflow; cache/polling factories; custom fetch logic.
fetchAction / getAction Call with params Declarative HTTP config on top of asyncAction. See fetch-actions.
asyncAtom Read like an atom Keyed auto-fetch from atom deps; refetch/cancel. See State → asyncAtom.

Full API quick reference

import { asyncAction } from 'mates';

const fetchUser = asyncAction(async (id: string) => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
    signal: fetchUser.abortController.signal,
  });
  if (!res.ok) throw new Error('Not found');
  return res.json() as Promise<{ id: number; name: string }>;
}, { cacheLimit: 5, pollInterval: 4000 });

fetchUser.isLoading()
fetchUser.status()     // "init" | "loading" | "success" | "error"
fetchUser.data()
fetchUser.error()

fetchUser('1');
fetchUser.cancel();            // abort() the current AbortController
fetchUser.cache('1');          // skip network on cache hit
fetchUser.clearCache('1');
fetchUser.startPolling('1');
fetchUser.stopPolling();
fetchUser.subscribe(() => console.log(fetchUser.status()), true); // success only
fetchUser.interceptBefore((next, id) => next(String(id).trim()));
fetchUser.interceptAfter((user) => ({ ...user, loadedAt: Date.now() }));

data / isLoading / error / status + interceptAfter

Real GET against jsonplaceholder. interceptAfter stamps loadedAt. status walks init → loading → success | error. User 999 is a 404.

import { html, asyncAction, nothing, renderApp } from 'mates';

const fetchUser = asyncAction(async (id) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${id}`,
{ signal: fetchUser.abortController.signal },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});

fetchUser.interceptAfter((user) => ({
...user,
loadedAt: new Date().toLocaleTimeString(),
}));

const App = () => {
return () => html`
<div class="m-col m-gap">
<div class="m-flex m-gap-sm m-b-20">
<button
class="btn-primary"
@click=${() => fetchUser(1)}
?disabled=${fetchUser.isLoading()}
>
fetchUser(1)
</button>
<button
@click=${() => fetchUser(2)}
?disabled=${fetchUser.isLoading()}
>
fetchUser(2)
</button>
<button
class="btn-danger"
@click=${() => fetchUser(999)}

cancel() aborts abortController

The fn listens on fetchUser.abortController.signal. cancel() aborts that controller, drops the result, and sets status back to init.

import { html, asyncAction, nothing, renderApp } from 'mates';

const fetchUser = asyncAction(async () => {
const signal = fetchUser.abortController.signal;
// Pause so cancel() is clickable; abortController.abort() unwinds both
await new Promise((resolve, reject) => {
const t = setTimeout(resolve, 2500);
signal.addEventListener('abort', () => {
clearTimeout(t);
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
}, { once: true });
});
const res = await fetch('https://jsonplaceholder.typicode.com/users/1', { signal });
if (!res.ok) throw new Error('Not found');
return res.json();
});

const App = () => {
return () => html`
<div class="m-col m-gap">
<div class="m-flex m-gap-sm m-b-20">
<button
class="btn-primary"
@click=${() => { void fetchUser(); }}
?disabled=${fetchUser.isLoading()}
>
Start Request
</button>
${fetchUser.isLoading()
? html`
<button class="btn-danger" @click=${() => fetchUser.cancel()}>
cancel()
</button>
`
: nothing}
</div>