@zoijs/action

The write-side companion to resource. Wrap a submit, save, or delete and get reactive pending / error / done state.

npm install @zoijs/core @zoijs/action

Example#

import { html, mount } from "@zoijs/core";
import { action } from "@zoijs/action";

const save = action(async (formData) => {
  const res = await fetch("/api/users", { method: "POST", body: formData });
  if (!res.ok) throw new Error("Could not save");
  return res.json();
});

function Form() {
  return html`
    <form onsubmit=${(e) => { e.preventDefault(); save.run(new FormData(e.currentTarget)); }}>
      <input name="name" />
      <button disabled=${() => save.pending()}>
        ${() => (save.pending() ? "Saving…" : "Save")}
      </button>
      ${() => (save.error() ? html`<p role="alert">${save.error().message}</p>` : null)}
    </form>
  `;
}
run() never throws — failures land in error(), so await save.run(...) is safe without try/catch.

API#

MemberWhat it does
action(fn, { exclusive }?)Wrap a write function; nothing runs until run()
run(...args)Call fn; sets pending, then done+result or error
pending()true while running
error()The error, or null
done()true after success
result()The latest successful result
reset()Clear all state

One request at a time: exclusive#

For non-idempotent writes (a payment, a POST that creates a record), pass { exclusive: true }. While a run is pending, run() doesn't call fn again — it returns the pending run's promise — so a double-click can't send the request twice. The lock is taken before fn starts and released when the run succeeds or fails (or on reset()). Joined calls aren't errors and aren't reported to onError.

const pay = action((order) => fetch("/api/pay", { method: "POST", body: JSON.stringify(order) }), { exclusive: true });

It doesn't stop server-side replays — keep the endpoint idempotent and protect it against CSRF (see Production Security).

Error monitoring#

A failure that becomes error() is also reported to the app's configure({ onError }) hook from @zoijs/core, as { kind: "action" } with the original error (superseded or post-unmount results aren't reported). It goes through the core's shared runtime, so a no-build import map needs no extra entry. Reports need @zoijs/core 1.9.0 or later — older cores have no shared runtime, so nothing is reported (and nothing breaks).

import { configure } from "@zoijs/core";
configure({ onError: (error, info) => reportToMonitoring(error, info.kind) });

Known limitations#

  • No cache, query invalidation, retries, or optimistic updates — by design.
  • To refresh data after a write, call your resource's refresh() yourself.