Advanced Fetcher
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>;import { useMemo } from "react";import { useMachine } from "matchina/react";import { createFetcher } from "./machine";
export const useAdvancedFetcher = ( url = "https://httpbin.org/delay/1", options = { method: "GET", maxTries: 5, timeout: 2000, autoretry: true, }) => { const { method, maxTries, timeout, autoretry } = options; const fetcher = useMemo(() => { return createFetcher(url, options); }, [url, method, maxTries, timeout, autoretry]);
useMachine(fetcher);
return fetcher;};import { useMachine } from "matchina/react";import { MachineActions } from "../lib/MachineActions";import type { FetcherMachine } from "./machine";
interface FetcherAppViewProps { machine: FetcherMachine;}
const stateColor: Record<string, string> = { Idle: "text-muted-foreground", Fetching: "text-[oklch(0.60_0.18_240)]", ProcessingResponse: "text-[oklch(0.65_0.14_240)]", Resolved: "text-[oklch(0.55_0.16_142)]", Error: "text-destructive", NetworkError: "text-destructive", Aborted: "text-[oklch(0.65_0.14_85)]", TimedOut: "text-[oklch(0.65_0.15_55)]", Refetching: "text-[oklch(0.60_0.18_290)]",};
export function FetcherAppView({ machine }: FetcherAppViewProps) { useMachine(machine); const state = machine.getState(); const { tries } = machine; const colorClass = stateColor[state.key] ?? "text-muted-foreground";
return ( <div className="p-5 flex flex-col gap-4"> <div className="flex items-center gap-3"> <span className="text-sm text-muted-foreground">State:</span> <span className={`text-sm font-semibold ${colorClass}`}>{state.key}</span> {tries > 0 && ( <span className="badge badge-outline text-[10px] ml-auto"> {tries} {tries === 1 ? "attempt" : "attempts"} </span> )} </div>
<div> <p className="text-xs text-muted-foreground mb-2 font-medium uppercase tracking-wide">Actions</p> <MachineActions transitions={machine.transitions} state={state.key} send={machine.send} children={undefined} /> </div>
<div className="rounded-xl border border-border bg-muted px-4 py-3 min-h-12 flex items-center"> {state.match({ Resolved: () => ( <span className="text-sm font-medium text-[oklch(0.55_0.16_142)]">Success!</span> ), Error: (error: Error) => ( <span className="text-sm font-medium text-destructive">Error: {error?.message}</span> ), NetworkError: (error: Error) => ( <span className="text-sm font-medium text-destructive">Network Error: {error?.message}</span> ), _: () => <span className="text-sm text-muted-foreground">No response yet</span>, })} </div> </div> );}import { useState } from "react";import { useAdvancedFetcher } from "./hooks";import { FetcherAppView } from "./FetcherAppView";import { OptionsForm, defaultOptions, type FetcherOptions,} from "./OptionsForm";
export function FetcherDemo() { const [options, setOptions] = useState<FetcherOptions>(defaultOptions);
const fetcher = useAdvancedFetcher(options.url, { method: "GET", timeout: options.timeout, maxTries: options.maxTries, autoretry: options.autoretry, });
return ( <div> <OptionsForm options={options} onChange={setOptions} /> <FetcherAppView machine={fetcher} /> </div> );}