Async Actions
Every action in QuantaJS carries a small reactive lifecycle surface. You do not opt in and you do not write it yourself:
cart.checkout.pending // boolean, reactive
cart.checkout.error // Error | null
cart.checkout.abort() // aborts every in-flight call of this action
this.$signal // AbortSignal, inside the action
The problem it removes
Almost every store ends up growing the same three fields by hand:
// Before — hand-rolled, and easy to get subtly wrong
actions: {
async checkout() {
this.isLoading = true;
this.error = null;
try {
await api.checkout(this.items);
this.items = [];
} catch (e) {
this.error = e.message; // now a string, so the stack is gone
} finally {
this.isLoading = false; // forget this once and the button stays disabled
}
},
}
That is boilerplate in every store, it multiplies once you have more than one
async action (isLoadingCheckout, isLoadingRefresh, …), and the finally is
exactly the line people forget.
// After — the lifecycle is already there
actions: {
async checkout() {
await api.checkout(this.items);
this.items = [];
},
}
<button onClick={() => cart.checkout()} disabled={cart.checkout.pending}>
{cart.checkout.pending ? 'Checking out…' : 'Checkout'}
</button>
{cart.checkout.error && <p role="alert">{cart.checkout.error.message}</p>}
pending
true while at least one call of that action is in flight. It is backed by a
counter, so overlapping calls behave sensibly: two concurrent refresh() calls
leave pending true until both settle.
It is attached to synchronous actions too, where it is simply never observably
true — which is the point. Making an action async later is not a breaking
change for any caller reading pending.
error
The rejection reason from the most recent call, as an Error (a thrown
non-Error is wrapped). It is cleared at the start of every call, so a
retry that succeeds clears the previous failure without you doing anything.
cart.checkout.error; // Error: payment declined
await cart.checkout(); // succeeds
cart.checkout.error; // null
Errors still propagate:
error is a record, not a swallow. The action still rejects, so a caller
that wants to react to one specific call can try/catch it — and one that
does not care must not leave the rejection unhandled:
// This component reads `checkout.error` instead, so it silences the rejection
// to avoid an unhandled-rejection warning in the console.
cart.checkout().catch(() => {});
abort() and $signal
Inside an action, this.$signal is an AbortSignal scoped to that specific
call. Pass it to fetch and cancellation works end to end:
export const useSearchStore = defineStore('search', {
state: () => ({ results: [] as Result[] }),
actions: {
async search(query: string) {
const res = await fetch(`/search?q=${query}`, { signal: this.$signal });
this.results = await res.json();
},
},
});
store.search.abort(reason?) aborts every in-flight call of search. A
typical use is cancelling a stale request when the component unmounts or the
query changes:
function Search() {
const search = useQuantaActions(useSearchStore);
const pending = useQuantaValue(useSearchStore, (s) => s.search.pending);
useEffect(() => () => search.search.abort(), [search]);
return pending ? <button onClick={() => search.search.abort()}>Cancel</button> : null;
}
$signal only exists for the duration of the call — it is swapped in around
the invocation rather than living on the store, so reading store.$signal from
outside an action gives you nothing useful.
Reactivity
pending and error live on a reactive object separate from state, and the
store's change notifier depends on both. That means all of these work:
store.subscribe()fires when a lifecycle flag flips.useQuanta(definition)re-renders on it.useQuantaValue(definition, s => s.save.pending)re-renders on it, and on nothing else — a component watching one action'spendingis not woken by another action's.- A raw
effect(() => …)readingstore.save.pendingre-runs.
One call notifies subscribers once, not once per field written: the lifecycle bookkeeping and the action body share a single batch.
Reading the flags in React
Two equivalent shapes, with different subscription costs:
// Whole-store subscription — re-renders on any cart change.
const cart = useQuanta(useCartStore);
cart.checkout.pending;
// Narrow subscription — re-renders only when this flag changes.
const pending = useQuantaValue(useCartStore, (s) => s.checkout.pending);
const error = useQuantaValue(useCartStore, (s) => s.checkout.error);
const cart = useQuantaActions(useCartStore); // resolve without subscribing
Prefer the second in a view that renders a lot.
What this deliberately is not
There is no caching, no request deduplication, no retries, no stale-while- revalidate and no query invalidation. Those belong to a server-state library — TanStack Query, SWR — and a half-built version of them here would be worse than none. QuantaJS gives you loading, error and cancellation, and stays out of the way of a real server-cache next to it.