hacifootprint
Actions

Going async — the adoption recipe

Four moves, in the order most apps need them. Return the promise, name the fire, say you are working, ask later. Everything here is your app's own control flow — there is no async wire to adopt.

The failure this prevents

Every action worth firing is asynchronous, and the library's honest answer at return time is effectStatus: 'pending' — true, and useless to act on. A reader that cannot see the screen is left holding a receipt for something that has not happened, and it has two moves available, both wrong: fire it again, or tell the person it failed.

A production integration met this and built the missing half by hand — a transition listener and a four-second stopwatch, rewriting results on its relay's send path. None of it was needed. Every signal it was reconstructing is a line in your app's own control flow, and this page is those lines in the order you need them.

Waiting for the app is the full reasoning. This page is the recipe.

The four moves

#the movethe linewhat you get
1Return the promisehandler: (p) => save.mutateAsync(p)the settlement, from your app, on the call path that started it
2Name the fireupdateState(delta, { transitionId })attribution that cannot be mismatched
3Say you are workingsetBusy(…) in a try/finallya mid-flight control that does not read as a broken one
4Ask laterdid_it_work / settleWithinMsan answer whenever you want one, and never a guessed one

Do 1 and 3 and you have the whole story for a single-action-at-a-time app. Add 2 the moment two fires can be in the air at once.

1. Return the promise — it is the settlement

There is no separate async wire, and that is deliberate: your app already has one.

.('compose', {
  : { : () => .() },
});

The one mistake to avoid is handing over the fire-and-forget half. React Query's mutate returns undefined — which tells this library the work is finished — and mutateAsync returns the promise. Everything downstream would otherwise be answering honestly about a call that came back early.

Failure is your app's word too: throw, or return { ok: false }. Both take the same path, the outcome flips to rejected, and the reason becomes the settlement's.

2. Name the fire — one property, on the state rail

The handler rail carries identity for free (the promise is that fire's). The state rail cannot: by the time your store reports a delta the call path is gone, so identity travels with the report.

const  = .('compose.save', { : 'user' });
if (.) {
  const  = ..;
  void ().(() => {
    .({ : . }, { :  }); // exact, always
  });
}

Skip it with one fire outstanding and nothing goes wrong. Skip it with two and the library falls back to oldest-first FIFO — predictable, and predictably capable of being wrong. No clock is consulted, ever: recency would be wrong exactly when the timing is interesting, and silently.

3. Say you are working — the three shapes, pick one

Plain, no framework — two calls around your own await, and the label comes down in finally:

const  = .('compose', { : {  } });

async function () {
  const  = .('Saving your draft…');  // the app is working
  .('save', 'Saving your draft…');           // and this control is the one
  try {
    await ();
    .();
  } catch () {
    .();
    throw ;
  } finally {
    .('save', );                    // `undefined` is the only clear
  }
}

React — the same two edges, taken from the boolean your component already renders from:

import { useWorking } from 'hcifootprint/react';

useWorking({
  busy: save.isPending,          // the flag your spinner already reads
  label: 'Saving your draft…',   // your words
  error: save.error,             // the error you already render
  actions: saveTool,
  session,
});

A live action store — put busy on the row and let the emission your app already sends carry it:

const : LiveAction[] = [
  { : 'compose', : 'save', : 'Save the draft', : 'Saving your draft…' },
];

All three land on the same served key. busy is a label, never a flag — there is no boolean form and no busyWhen, because a condition can prove a state but cannot write prose. And it gates nothing: if you also mean nobody may press it, setEnabled(false) is the wire that says so. The third state, in full.

4. Ask later — a poll, not a wait

// the fire's own result told you the door:
{ "effectStatus": "pending", "transitionId": "compose.save#0", "howToSettle": "did_it_work" }

did_it_work answers immediately, every time, and never blocks: settled: true with the receipt, still-pending while that is the truth (with stillWorking: true beside it when your app has opened a piece of work), or a refusal by name for an id no settlement can exist for. In process the same truths are settlementOf (a promise), settlementIfKnown (now, or nothing) and port.settledAnswer(id).

Over MCP, settleWithinMs folds a settlement that lands quickly into the same result — keep the ceiling well under your host's own timeout, because this server sends no progress notifications, so a long ceiling only means the client gives up first and reports an error about an action that may well have succeeded.

What not to build

Each of these was tried by a real integration, and each has a door already:

the reflexwhy it goes wrongthe door
a stopwatch that calls it failedit has been a while is not evidence of done or faileddid_it_work, for as long as you like
a transition listener that rewrites resultsyou are re-deriving a settlement the session already holdssettlementOf / settledAnswer
a boolean kept only for the librarya second copy of a fact, and the copy nobody looks at is the one that rotspass the flag your screen already depends on
attributing a report to the newest fireright only when handlers finish in order — the case FIFO already handles — and unfalsifiable otherwise{ transitionId }

Honest limits

  • fire() is synchronous and the handler is always deferred, so effectStatus at return time is never 'performed'. The final truth arrives on the settlement.
  • No promise here rejects, and none of them times out. A fire your app never reports on waits forever, on purpose — a timed-out answer could only be a guessed one.
  • A tapless session cannot verify. 'performed' there means our side ran to completion; effectVerified stays 'unobservable' because nothing exists to check against.
  • Nothing in this library expires anything. Not a busy label, not a work row, not a claim. Every ceiling belongs to the caller who set it, and none of them is ever a verdict.

On this page