Skip to content

Promise Machines

A promise machine wraps an async function and tracks its lifecycle as four discrete states. You get loading / success / error states for free — no flags to juggle, no stale data on the next request, full type inference on the result and error.

Every promise machine starts in Idle and moves through this graph:

  • Idle — before you call execute
  • Pending — call in flight; state carries the promise and the params you called it with
  • Resolved — call succeeded; state carries result
  • Rejected — call failed; state carries error

Calling execute again returns the machine to Pending and the cycle repeats.

Pass any async function. The return type and parameter types propagate everywhere — state.data on Resolved is typed, params on Pending is typed.

import { createPromiseMachine } from "matchina";
const fetcher = createPromiseMachine(async (url: string) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});
// Kick off the call
await fetcher.execute("/api/user");
// Or fire-and-forget — read state later
fetcher.execute("/api/user");
// Pattern-match the current state
const view = fetcher.getState().match({
Idle: () => "Click load to fetch",
Pending: ({ params }) => `Loading ${params[0]}…`,
Resolved: (data) => `Got: ${JSON.stringify(data)}`,
Rejected: (error) => `Failed: ${error.message}`,
});

match is exhaustive — TypeScript will complain if you miss a state, and the value in each branch is narrowed to that state’s data.

A simple fetcher hitting a public API. Click Fetch to walk it through Idle → Pending → Resolved. The graph on the left highlights the current state in real time.

executingresolverejectIdlePendingResolvedRejected
Ready to fetch
Idle
import { createPromiseMachine } from "matchina";
// Function to create a promise machine for fetching data
export function createPromiseFetcherMachine() {
return createPromiseMachine(async (url: string, options?: RequestInit) => {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
});
}

The basic shape covers most cases. For production data fetching you usually want cancellation, retries, and timeouts on top. The advanced fetcher shows one way to layer those without losing type safety:

fetchfetchedrejectaborttimeoutnetworkErrorrefetchresolveerrorrefetchrefetchrefetchrefetchrefetchIdleFetchingProcessingResponseResolvedErrorNetworkErrorAbortedTimedOutRefetching
State:Idle

Actions

No response yet
import {
createMachine,
defineStates,
delay,
effect,
enter,
guard,
whenEventType,
whenState,
assignEventApi,
setup,
} from "matchina";
import { autotransition } from "../lib/autotransition";
type Options = RequestInit & {
timeout?: number;
maxTries?: number;
autoretry?: boolean;
getData?: (response: Response) => any;
};
export function createFetcher(
defaultUrl: string,
defaultOptions: Options = {}
) {
const states = defineStates({
Idle: undefined,
Fetching: (url = defaultUrl, options = defaultOptions) => ({
url,
options,
abort: new AbortController(),
}),
ProcessingResponse: (response: Response) => response,
Resolved: (data: any) => data,
Error: (error: Error) => error,
NetworkError: (error: Error) => error,
Aborted: undefined,
TimedOut: undefined,
Refetching: undefined,
});
const canRefetch = { refetch: "Refetching" } as const;
const machine = createMachine(
states,
{
Idle: {
fetch: "Fetching",
},
Fetching: {
fetched: "ProcessingResponse",
reject: "Error",
abort: "Aborted",
timeout: "TimedOut",
networkError: "NetworkError",
},
ProcessingResponse: {
...canRefetch,
resolve: "Resolved",
error: "Error",
},
Resolved: canRefetch,
Error: canRefetch,
NetworkError: canRefetch,
Aborted: canRefetch,
TimedOut: canRefetch,
Refetching: { "": "Fetching" },
},
"Idle"
);
const fetcher = Object.assign(assignEventApi(machine), {
fetch: (url?: string, options?: RequestInit) => {
fetcher.fetch(url, options);
return fetcher.promise!;
},
promise: undefined as undefined | Promise<Response>,
done: undefined as undefined | Promise<void>,
tries: 0,
});
const maxTries = defaultOptions.maxTries ?? Number.POSITIVE_INFINITY;
const runFetch = async (url: string, options: RequestInit) => {
fetcher.tries = fetcher.tries + 1;
const promise = (fetcher.promise = fetch(url, options));
try {
const res = await promise;
if (fetcher.promise === promise) {
fetcher.fetched(res.clone());
}
} catch (error) {
const { name } = error as Error;
if (name === "AbortError") {
fetcher.tries--;
} else if (name === "TypeError") {
fetcher.networkError(error as Error);
} else {
fetcher.reject(error as Error);
}
} finally {
delete fetcher.promise;
}
};
const resolveResponseData = (response: Response) => response.json();
setup(fetcher)(
enter(
whenState("Fetching", (ev) => {
(ev as any).promise = runFetch(ev.to.data.url, {
...ev.to.data.options,
signal: ev.to.data.abort.signal,
});
const { timeout } = ev.to.as("Fetching").data.options;
if (timeout) {
const timer = setTimeout(fetcher.timeout, timeout);
return () => clearTimeout(timer);
}
})
),
effect(
whenState("ProcessingResponse", (ev) => {
fetcher.tries = 0;
delay(1000).then(() => {
resolveResponseData(ev.to.data).then(fetcher.resolve);
});
})
),
autotransition(),
effect(
whenEventType("abort", (ev) => {
ev.from.data.abort.abort();
})
),
guard((ev) => (ev.type === "refetch" ? fetcher.tries < maxTries : true))
);
if (defaultOptions.autoretry) {
const autoRetryStates = ["NetworkError", "TimedOut", "Error"] as const;
setup(fetcher)(
...autoRetryStates.map((stateName) =>
effect(
whenState(stateName, () => {
if (fetcher.tries < maxTries) {
const backoff = 1000 * fetcher.tries;
const timer = setTimeout(() => {
fetcher.refetch();
}, backoff);
return () => {
clearTimeout(timer);
};
}
})
)
)
);
}
return fetcher;
}
// Removed unused type
export type FetcherMachine = ReturnType<typeof createFetcher>;

It uses the same createPromiseMachine underneath plus lifecycle hooks to manage AbortControllers and a retry counter. Read the source to see the composition pattern — it’s not magic, just a few well-placed effect calls.