Overview

action wraps a synchronous function and adds observability: subscribers that fire on every call, before/after interceptors for validation and result enrichment, and a consistent callable interface.

The result is still returned synchronously — wrapping a function in action does not change its calling behavior. What you gain is a centralized execution point with hooks for analytics, logging, and side-channel effects without coupling them at the call site.

Subscribe with .__subscribe(fn) (not .subscribe — that name belongs to asyncAction).

action — intercept, subscribe, and set()

Empty input is blocked by interceptBefore (not a custom guard at the click handler). interceptAfter stamps char count. __subscribe logs every success. save.set() swaps the implementation without dropping interceptors or subscribers.

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

const App = () => {
const input = atom('');
const saved = atom([]);
const callCount = atom(0);
const implName = atom('plain');

const save = action((text) => {
return { text, savedAt: new Date().toLocaleTimeString() };
});

// Validation lives on the action, not at each call site
save.interceptBefore((next, text) => {
const trimmed = String(text).trim();
if (!trimmed) throw new Error('empty');
return next(trimmed);
});

save.interceptAfter((r) => ({ ...r, chars: r.text.length }));

save.__subscribe((result) => {
if (!result) return; // interceptBefore threw
saved.set((prev) => [result, ...prev].slice(0, 5));
callCount.set((n) => n + 1);
});

const onSave = () => {
try {
save(input());
input.set('');
} catch {
/* empty input blocked by interceptBefore */
}
};

interceptBefore / interceptAfter

Before interceptor trims + uppercases; empty input throws. After interceptor tags the result. Subscriber sees the enriched value.

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

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

const save = action((text) => ({ text, at: new Date().toLocaleTimeString() }));

save.interceptBefore((next, text) => {
if (!String(text).trim()) throw new Error('empty');
return next(String(text).trim().toUpperCase());
});
save.interceptAfter((r) => ({ ...r, ok: true }));
save.__subscribe((r) => {
log.set(prev => [`${r.at}${r.text}`, ...prev].slice(0, 4));
});

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" @click=${() => {
try { save(' hello '); } catch (e) { /* empty */ }
}}>Save "hello"</button>
<button class="btn-danger" @click=${() => {
try { save(' '); } catch (e) {
log.set(prev => ['blocked: empty', ...prev].slice(0, 4));
}
}}>Save empty</button>
</div>
<div class="card card-body">
${log().map(e => html`<div class="mono">${e}</div>`)}
</div>
</div>
`;
};
renderApp(App, document.getElementById('app'));