Actions
An action is a function in an ES module. CycleWire imports the module the first time the action is needed and calls the function with one context object.
export async function add({ element, props, signal }) { const response = await fetch('/cart', { method: 'POST', body: JSON.stringify(props), signal }); element.querySelector('.count').textContent = (await response.json()).count;}
export default function run(ctx) { /* used for cw-action="cart" */ }cw-action="cart#add"calls theaddexport.cw-action="cart"callsrun, falling back to the default export.
The context
Section titled “The context”| Field | |
|---|---|
event |
The event that triggered the run; null for triggers and run() |
target |
The innermost event target, captured while the event was dispatched. event.target is null for shadow DOM events by the time your code runs |
element |
The element carrying the binding |
signal |
An AbortSignal that fires when a newer run supersedes this one, when the element leaves the page, or on stop() |
props |
Parsed cw-props, or null |
action |
The name that ran, e.g. "cart#add" |
wire |
The CycleWire API (run, preload, observe, …) |
state, store |
Added by the signals plugin |
fetch |
Added by the prefetch plugin: fetch() that takes the data prefetched on intent |
Your code runs after CycleWire’s listener
Section titled “Your code runs after CycleWire’s listener”The first run of an action waits for its module to download. Once the module is in memory, a quick handler runs right after CycleWire’s own listener returns, so its result lands in the next frame; a handler that held the main thread for more than 10 ms last time waits for the browser to paint the pressed state first. Either way, your handler runs after CycleWire has handled the event, and possibly after it has finished dispatching, which means:
- Do not rely on
event.preventDefault()in a handler. Usecw-prevent, which is applied synchronously. - APIs that need a user gesture, such as
navigator.clipboard.writeText,window.openandnavigator.share, may be refused after a slow first import, and Safari is the strictest. Preload the module (cw-preload="load") or use the upgrade pattern below. event.currentTargetis not your element. Useelement.
The upgrade pattern
Section titled “The upgrade pattern”For things that must happen synchronously on every interaction, attach a direct listener the first time the action runs and tie it to the signal:
export function run({ element, signal }) { const copy = () => navigator.clipboard.writeText(element.dataset.text); copy(); // this first click: works in Chromium and Firefox, may be refused in Safari element.addEventListener('click', copy, { signal }); // later clicks: synchronous}Pair it with cw-once so CycleWire stops handling the element after the first
successful run:
<button cw-action="copy" cw-once cw-preload="load" data-text="npm i cyclewire">Copy</button>Concurrency
Section titled “Concurrency”What happens when an event arrives while a run for the same element and action is still going depends on the concurrency mode:
| Mode | Default for | What happens | Typical use |
|---|---|---|---|
drop |
click, submit, command, triggers |
The new event is ignored | Buttons, forms: no double submits |
restart |
input |
The running call’s signal aborts, the new call starts |
Search-as-you-type |
latest |
change, toggle |
The newest event waits and runs once the current run ends | Checkboxes that save: the server ends up with the last value |
parallel |
other events | Every event starts its own run | Independent, idempotent work |
<input type="search" cw-action="search" cw-debounce="200"><input type="checkbox" cw-action="settings#save"><button cw-action="toast" cw-concurrency="parallel">Notify</button>Pass signal to everything that supports it, so superseded work actually stops:
export async function run({ element, signal }) { const response = await fetch(`/search?q=${encodeURIComponent(element.value)}`, { signal }); render(await response.json());}Every new run for a binding also aborts the previous run’s signal, even one that has
finished. Listeners you registered with { signal } in the last run are released that
way.
Once, debounce
Section titled “Once, debounce”cw-oncekeeps the element working until one run succeeds. After that, events are consumed and still prevented.cw-debounce="ms"waits for a pause. Underrestart, a new event aborts the in-flight run immediately rather than when the pause ends.
Pending state
Section titled “Pending state”While a run is in flight, the element has cw-pending. drop runs also set
aria-busy="true". Style them and let assistive technology know:
[cw-pending] { opacity: .6; pointer-events: none; }Results and errors
Section titled “Results and errors”- A handler’s return value (or its promise’s value) is dispatched as
cw:done, withdetail.result. - A thrown error is dispatched as
cw:error, and passed toonError(or logged). - Aborts triggered by CycleWire itself are not errors: an
AbortErrorfrom your ownsignalis swallowed.
start({ actions, onError(error, { action, element }) { reportError(error, { action, element: element.id }); element.setAttribute('data-failed', ''); },});Styles for the UI an action creates
Section titled “Styles for the UI an action creates”If an action builds UI that needs its own stylesheet (a date picker, an editor, a map),
load it with cyclewire/css. Either register the CSS with the action,
{ module: '/js/actions/datepicker.js', css: '/css/flatpickr.css' } with the styles()
plugin, so it is fetched along with the module and applied before your handler runs; or
await css(href, element) at the top of the handler. Either way the UI never appears
unstyled.
Rules of thumb
Section titled “Rules of thumb”- No top-level side effects in action modules. Preloading may import a module before anyone clicks.
- Keep exports intentional.
module#exportmakes every function a module exports callable from markup. - Make it work without JavaScript first. Links link and forms submit. An action that fails to load leaves the browser’s default in place, because CycleWire only prevents once it knows the action exists.
- Use real buttons. Clickable
<div>s cannot be focused or used from a keyboard; the development build warns about them. On iOS Safari, delegated clicks on non-interactive elements also needcursor: pointer.