@cplieger/actions
v2.0.13
Published
Declarative async UI-action framework for TypeScript
Readme
actions
Declarative UI-actions framework with lifecycle management, retry, debounce, and polling.
A standalone TypeScript library for defining and dispatching UI actions with full lifecycle support: optimistic updates, automatic retry with backoff, scope serialization, dedupe collapsing, notification wiring, polling, button-feedback helpers, and a registry for observability. Built on @cplieger/reactive — action pending-state is signal-backed, so isPending/pendingCount are reactive and bindLoadingState is a plain effect over them. HTTP-backed apiActions route their requests through @cplieger/fetch, the toolkit's shared request core (base URL, credentials, header prep, and a custom fetchFn are injected via configureApi). Notification display and streaming transport are injected by the consumer via small interfaces.
Install
npx jsr add @cplieger/actions
# or
npm i @cplieger/actionsRequires TypeScript ≥ 5.0 and a bundler that supports ESM.
Usage
import { configure, defineAction, apiAction, retryNetwork } from "@cplieger/actions";
// Wire up your notification adapter at boot
configure({
success: (msg) => showToast("success", msg),
error: (msg, retry) => showToast("error", msg, retry?.onClick),
});
// Define an action backed by HTTP
const deleteItem = apiAction<string>({
name: "items.delete",
request: (id) => ({ method: "DELETE", path: `/api/items/${id}` }),
error: "Couldn't delete item",
retryable: retryNetwork,
retry: { count: 2, delay: 300 },
});
// Dispatch it
await deleteItem.dispatch(itemId);Injection Points
The framework provides three adapter injection points:
Notifier (
configure()): Providessuccess(msg)anderror(msg, retry?)methods for displaying notifications. Without configuration, notifications are silently dropped.API (
configureApi()): Configures the HTTP layer used by allapiActioninstances — base URL, auth/CSRF headers, credentials mode, or a custom fetch implementation. Without configuration,apiActionuses the globalfetchwith relative paths.Transport (
configureTransport()): Provides asend(cmd, opts)function for SSE/streaming actions. Only needed if usingtransportAction.
HTTP Customization (configureApi)
import { configureApi } from "@cplieger/actions";
configureApi({
baseUrl: "https://api.example.com/v1",
credentials: "include",
prepareHeaders: (headers, { spec }) => {
headers.set("Authorization", `Bearer ${getToken()}`);
headers.set("X-CSRF-Token", getCsrfToken());
},
});Options (mirrors RTK fetchBaseQuery):
baseUrl— prepended to everyRequestSpec.pathprepareHeaders(headers, { spec })— inject headers per-request (may be async)credentials—RequestInit.credentialsmode (e.g."include"for cookies)fetchFn— custom fetch implementation (SSR, testing)
Path contract:
RequestSpec.pathis expected to be a relative path. WithbaseUrlset, the configured scheme+host always precede it, so an absolute (https://…) or protocol-relative (//host) path is neutralised (kept as a path segment) and cannot override the origin. WithbaseUrlunset,pathis passed tofetch()verbatim — the caller owns the full URL and must never pass untrusted input (e.g. a server-supplied string) as the whole path.
Per-request headers can also be set directly on RequestSpec:
const action = apiAction({
name: "items.create",
request: (item) => ({
method: "POST",
path: "/items",
body: item,
headers: { "X-Request-Id": crypto.randomUUID() },
}),
});API
configure(notifier)— inject the notification adapterconfigureApi(opts)— configure the HTTP layer (baseUrl, headers, credentials, fetchFn)configureTransport(fn)— inject the streaming transport adapterdefineAction(def)— create an action from a declarative definitionapiAction(def)— create an HTTP-backed action (usesfetch)transportAction(def)— create a transport/SSE-backed actiondebouncedDispatch(action, opts)— debounce wrapperpollAction(action, args, opts)— interval polling with pause/backoffbindLoadingState(name, el, opts?)— bind an element's disabled/aria-busy state to action pending; a reactive effect over the pending signalspollUntil(step, opts)— poll until a terminal condition (wait-then-poll,untilpredicate,maxAttempts/timeoutMsbudgets, backoff-on-transient); returns{status:'done'|'timeout'|'aborted'}. A standalone sibling topollActionfor one-shot terminal-state waits.withAsyncFeedback(btn, fn, opts?)— per-button async feedback (spinner → ✓/✗ → restore) with a re-entry guard + sr-only announce + injectable glyphs.target?: HTMLElementruns the cycle on a child slot via in-place element replacement (siblings/label untouched);resetMs: 0persists the outcome glyph (no auto-revert).subscribeToActions(fn)— subscribe to all lifecycle events (discrete event stream)subscribeByName(name, fn)— subscribe to lifecycle events for a single action name (discrete event stream)getActionLog()— read the recent action log (for devtools/debugging)pendingCount(names?)— pending action count; reactive (tracks inside an effect)isPending(name)— check if a named action is in-flight; reactive (tracks inside an effect)registerCleanup(fn)— register teardown hooks for page unloadActionError— structured error class with status/coderetryNetwork— preset retry classifier for transient failuresclassifyFetchError(err)— classify fetch errors (network vs timeout vs HTTP)hasErrorString(err)— type guard for objects with a.messagestringwithTimeout(signal, ms)— compose an AbortSignal with a timeoutAPI_TIMEOUT_MS— default API request timeout (30 000 ms)RETRY_STANDARD— standard retry config (2 retries, 300ms)
Test utilities (@cplieger/actions/testing)
The ./testing subpath exports test-only helpers. Import only from test code:
import { resetActionFramework } from "@cplieger/actions/testing";
beforeEach(() => {
resetActionFramework();
});resetActionFramework()— clear every framework state slot (define, registry, cleanup, api, transport, notifier). Call frombeforeEach()to isolate tests.
Breaking change in v2.0: the
./src/*deep-import escape hatch was removed frompackage.jsonexports. Migrate any deep/src/*import to@cplieger/actions/testing(resetActionFramework()), or to the public API for everything else. The release notes list the removed subpaths and the old_reset*ForTesthelpers thatresetActionFramework()supersedes.
Definition-level callbacks (TanStack Query pattern)
ActionDefinition supports onSuccess, onError, and onSettled callbacks that fire on every dispatch without the caller needing to pass them each time:
const save = defineAction({
name: "doc.save",
run: async (id: string) => api.save(id),
onSuccess: (result, id) => invalidateCache(id),
onError: (err, id) => trackError("save", id, err),
onSettled: (id) => console.log("save settled for", id),
});Per-dispatch abort handle (RTK pattern)
dispatch() returns a DispatchHandle — a Promise augmented with an abort() method for per-dispatch cancellation:
const handle = action.dispatch(args);
// Cancel just this dispatch (others unaffected):
handle.abort();
// Still awaitable:
const result = await handle;Timeout option
ActionDefinition accepts a timeout (ms) that aborts run() via AbortSignal.timeout():
const slow = defineAction({
name: "slow.op",
timeout: 5000, // abort after 5s
run: async (args, signal) => fetch(url, { signal }),
});Unsupported by Design (SKIP list)
The following features are intentionally not implemented:
| Feature | Reason |
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Query caching / stale-while-revalidate | Out of paradigm — this is an action runner, not a data cache. Use TanStack Query alongside. |
| Cache invalidation / revalidation | Data-cache concern, out of scope. |
| Framework adapters (React/Vue/Svelte) | Vanilla TS by design. Framework bindings belong in separate packages. |
| Visual DevTools panel | Separate package concern. The registry API (getActionLog, subscribeByName) provides the data. |
| SSR / hydration | Actions are imperative mutations — nothing to serialize across server→client. |
| Debounce maxWait | Deliberate simplification. Use flush() for guaranteed-fire semantics. |
| Throttle helper | Not action-specific. Consumers can throttle before calling dispatch(). |
| condition / pre-execution guard | Trivially implemented by callers with if. dedupe covers the primary use case. |
| Typed discriminated-union result | The callback model (onSuccess/onError/onSettled) is the chosen API shape. |
| onProgress callback | Transport-specific. Consumers wire progress in their run() implementation. |
| Batch dispatch | Store-level concern. This library doesn't own a store. |
| dispose() / action deregistration | Actions are lightweight when idle. Not a leak concern for realistic app sizes. |
Disclaimer
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
GPL-3.0 — see LICENSE.
