How to track an async task without loading flags
You will render a first load, a background refresh, a failure, and a retry as four different screens, from one object, without threading isLoading and error through your components.
isLoading plus error is two booleans and four combinations, and none of them remember what happened last time. A refresh looks exactly like a first load, so the spinner covers rows you could have kept on screen; a retry looks exactly like a failure clearing, so the error banner disappears the moment someone clicks it. AsyncOperation from @hyperfrontend/state-machine derives those distinctions from the dispatches it is already making.
1. Install it
npm install @hyperfrontend/state-machine
2. Wrap the work
An AsyncProcess is a function returning a promise. Throw to report failure:
import { AsyncOperation } from '@hyperfrontend/state-machine/async-operation'
const operation = new AsyncOperation(loadOrders)
Call start whenever the work should run: on mount, on a refresh button, on a retry. The same call covers all three, and the state tells them apart. start resolves either way, because a failure arrives as state rather than as a rejection.
3. Keep the result and the error where you can render them
The process returns nothing, so whatever it produces stays in scope beside it. Hold the error there too, and rethrow so the operation records the failure:
let orders: Order[] = []
let failure: Error | null = null
const loadOrders = async () => {
try {
const response = await fetch('/api/orders')
if (!response.ok) throw new Error(`orders request failed: ${response.status}`)
orders = await response.json()
failure = null
} catch (cause) {
failure = <Error>cause
throw cause
}
}
Assigning orders only on success is what makes a refresh non-destructive: while the next attempt runs, the previous rows are still there to show.
4. Render from the state you are handed
on passes your handler the DerivedState as of that transition. Test the specific states before the general ones, because retrying and restarting are both also inProgress:
import type { DerivedState } from '@hyperfrontend/state-machine'
const render = (state: DerivedState) => {
if (state.notStarted) return emptyScreen()
if (state.retrying) return errorBanner(failure, { spinner: true })
if (state.restarting) return table(orders, { refreshing: true })
if (state.inProgress) return skeleton()
if (state.failed) return errorBanner(failure)
return table(orders)
}
for (const event of ['inProgress', 'successful', 'failed'] as const) {
operation.on(event, (_event, current) => paint(render(current)))
}
Those three events cover every screen, because the second start after a success arrives as inProgress with restarting already true, and the second start after a failure arrives as inProgress with retrying already true. Subscribe to restarting or retrying directly when something other than the view needs to know, for example an analytics call that should fire on retries only.
operation is a plain value with nothing to mount, so it holds the same way in a React effect, a Svelte store, a worker, or a module; over work AsyncOperation does not own, Store takes the same dispatches directly.
Check it worked
Load the screen once and watch the skeleton give way to rows. Refresh, and the rows stay up with a refreshing marker instead of collapsing. Make the request fail, and the banner names it. Click retry, and the banner stays up with a spinner on it rather than blinking away. That last one is the difference the two booleans could not express.
Reference and background
Next steps
How to make a service safe to use before it is ready
A shared service with async setup only ever serves one of its two callers: whoever subscribed early waits for a signal, and whoever subscribed late missed the signal that already fired, so I end up guarding every call site with an isReady check that can go stale between the check and the subscribe.
How to run async jobs one at a time, in order
My writes need to happen one at a time and in order, and the array-plus-busy-flag queue I wrote breaks the moment a second caller starts it, a rejected job leaves the flag stuck, or a retry queues the same work twice.