Overview

fetchAction (and the method helpers) create an asyncAction whose body calls Mates Fetch. You get the full reactive state machine — data, isLoading, error, status — plus cache / startPolling from the same options object.

Config fixes host, url, method, headers, and default body/params. Call-time args are a plain params object: keys matching :tokens in the URL fill path segments; the rest become query-string params.

Module-level helpers (getAction, …) use the shared fetchClient. Prefer new FetchClient({ host }) + api.getAction(…) when you need a shared base URL or interceptors.

fetchAction vs asyncAction vs asyncAtom

API
Role
Prefer when
fetchAction / getAction HTTP + asyncAction Declarative URL/method config; call with params. Built on Fetch.
asyncAction Any async fn Custom Promise work, non-HTTP, or when you wrap Post/Fetch yourself.
asyncAtom Resource atom Keyed auto-fetch from atom deps — read like an atom. See asyncAtom.

getAction — real GET with :id params

getAction fills :id from call-time params and hits jsonplaceholder. You get data / isLoading / error / status, plus cancel() and interceptAfter.

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

const loadUser = getAction({
host: 'https://jsonplaceholder.typicode.com',
url: '/users/:id',
cacheLimit: 10,
});

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

const App = () => {
return () => html`
<div class="m-col m-gap">
<div class="m-flex m-flex-wrap m-items-center m-justify-center m-gap-sm">
<button class="btn-primary" ?disabled=${loadUser.isLoading()}
@click=${() => loadUser({ id: 1 })}>Load user 1</button>
<button ?disabled=${loadUser.isLoading()}
@click=${() => loadUser({ id: 2 })}>Load user 2</button>
<button class="btn-danger" ?disabled=${loadUser.isLoading()}
@click=${() => loadUser({ id: 999 })}>Missing id</button>
<button class="btn-ghost"
?disabled=${!loadUser.isLoading()}
@click=${() => loadUser.cancel()}>cancel()</button>
</div>
<p class="muted m-t-5">status=${loadUser.status()}</p>
${loadUser.isLoading()
? html`<p class="muted">Loading…</p>`
: loadUser.error()
? html`<p>${loadUser.error().message}</p>`
: loadUser.data()
? html`<p>
<strong>${loadUser.data().name}</strong>
· ${loadUser.data().email}<br>

getAction.cache() vs a plain call

cache({ id }) skips the network on a hit — loadedAt from interceptAfter stays the same. A plain call() always fetches. clearCache() drops the LRU.

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

const loadPost = getAction({
host: 'https://jsonplaceholder.typicode.com',
url: '/posts/:id',
cacheLimit: 10,
cacheDuration: 30_000,
});

// Runs on network success only — cache hits reuse the stamped object
loadPost.interceptAfter((post) => ({
...post,
loadedAt: new Date().toLocaleTimeString(),
}));

const App = () => {
return () => html`
<div class="m-col m-gap">
<div class="m-flex m-flex-wrap m-items-center m-justify-center m-gap-sm">
<button class="btn-primary" ?disabled=${loadPost.isLoading()}
@click=${() => loadPost.cache({ id: 1 })}>.cache({ id: 1 })</button>
<button ?disabled=${loadPost.isLoading()}
@click=${() => loadPost.cache({ id: 2 })}>.cache({ id: 2 })</button>
<button ?disabled=${loadPost.isLoading()}
@click=${() => loadPost({ id: 1 })}>call({ id: 1 }) — always network</button>
<button class="btn-ghost" @click=${() => loadPost.clearCache()}>clearCache()</button>
</div>
${loadPost.data()
? html`
<p>
<strong>#${loadPost.data().id}</strong> ${loadPost.data().title}
</p>
<p class="hint">loadedAt stays put on a cache hit: ${loadPost.data().loadedAt}</p>
`
: nothing
}

postAction — config body, JSON response

postAction sends the config body. Call-time params would go to the URL, not the JSON body — so the payload lives on the factory config.

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

// Call-time args fill :path / query — body is fixed on the config
const createPost = postAction({
host: 'https://jsonplaceholder.typicode.com',
url: '/posts',
body: { title: 'Hello from postAction', body: 'mates fetchAction family', userId: 1 },
});

const App = () => {
return () => html`
<div class="m-col m-gap">
<div class="m-flex m-items-center m-justify-center m-gap-sm">
<button class="btn-primary"
?disabled=${createPost.isLoading()}
@click=${() => createPost()}>
${createPost.isLoading() ? 'POSTing…' : 'postAction()'}
</button>
</div>
<p class="muted m-t-5">status=${createPost.status()}</p>
${createPost.error()
? html`<p>${createPost.error().message}</p>`
: createPost.data()
? html`<p class="mono">${JSON.stringify(createPost.data())}</p>`
: nothing
}
</div>
`;
};
renderApp(App, document.getElementById('app'));