Skip to content

Async Data Fetching

Statecharts excel at managing async data fetching with clear states for loading, success, and error conditions.

import { chart } from "statecharts.sh";
interface FetchContext<T> {
data: T | null;
error: string | null;
}
const fetchMachine = chart<FetchContext<User[]>>({
context: {
data: null,
error: null,
},
initial: "idle",
states: {
idle: {
on: { FETCH: "loading" },
},
loading: {
invoke: async () => {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
},
onDone: {
target: "success",
action: (ctx, event) => ({
data: event.data,
error: null,
}),
},
onError: {
target: "error",
action: (ctx, event) => ({
error: event.error.message,
}),
},
},
success: {
on: {
REFRESH: "loading",
RESET: {
target: "idle",
action: () => ({ data: null, error: null }),
},
},
},
error: {
on: {
RETRY: "loading",
RESET: {
target: "idle",
action: () => ({ data: null, error: null }),
},
},
},
},
});
interface PaginatedContext<T> {
items: T[];
page: number;
hasMore: boolean;
loading: boolean;
error: string | null;
}
const paginatedFetch = chart<PaginatedContext<Item>>({
context: {
items: [],
page: 1,
hasMore: true,
loading: false,
error: null,
},
initial: "idle",
states: {
idle: {
on: {
LOAD_MORE: {
target: "loading",
guard: (ctx) => ctx.hasMore,
},
},
},
loading: {
entry: () => ({ loading: true }),
exit: () => ({ loading: false }),
invoke: async (ctx) => {
const response = await fetch(`/api/items?page=${ctx.page}`);
return response.json();
},
onDone: {
target: "idle",
action: (ctx, event) => ({
items: [...ctx.items, ...event.data.items],
page: ctx.page + 1,
hasMore: event.data.hasMore,
}),
},
onError: {
target: "error",
action: (ctx, event) => ({
error: event.error.message,
}),
},
},
error: {
on: {
RETRY: "loading",
},
},
},
});
interface CachedContext<T> {
cache: Map<string, { data: T; timestamp: number }>;
currentKey: string | null;
data: T | null;
error: string | null;
}
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const cachedFetch = chart<CachedContext<any>>({
context: {
cache: new Map(),
currentKey: null,
data: null,
error: null,
},
initial: "idle",
states: {
idle: {
on: {
FETCH: [
{
target: "cached",
guard: (ctx, event) => {
const cached = ctx.cache.get(event.key);
return cached && Date.now() - cached.timestamp < CACHE_TTL;
},
action: (ctx, event) => ({
currentKey: event.key,
data: ctx.cache.get(event.key)?.data,
}),
},
{
target: "loading",
action: (ctx, event) => ({ currentKey: event.key }),
},
],
},
},
loading: {
invoke: async (ctx) => {
const response = await fetch(`/api/data/${ctx.currentKey}`);
return response.json();
},
onDone: {
target: "success",
action: (ctx, event) => {
const newCache = new Map(ctx.cache);
newCache.set(ctx.currentKey!, {
data: event.data,
timestamp: Date.now(),
});
return {
cache: newCache,
data: event.data,
error: null,
};
},
},
onError: {
target: "error",
action: (ctx, event) => ({
error: event.error.message,
}),
},
},
cached: {
on: {
FETCH: "idle", // Re-evaluate cache
INVALIDATE: {
target: "loading",
action: (ctx) => {
const newCache = new Map(ctx.cache);
newCache.delete(ctx.currentKey!);
return { cache: newCache };
},
},
},
},
success: {
on: {
FETCH: "idle",
REFRESH: "loading",
},
},
error: {
on: {
RETRY: "loading",
FETCH: "idle",
},
},
},
});

Use the useStateChart hook from statecharts.sh/react which uses useSyncExternalStore internally for proper React 18+ concurrent rendering support:

import { useStateChart } from "statecharts.sh/react";
import { useEffect } from "react";
function useFetch<T>(url: string) {
const fetchMachine = chart<FetchContext<T>>({
context: { data: null, error: null },
initial: "idle",
states: {
idle: { on: { FETCH: "loading" } },
loading: {
invoke: async () => {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
onDone: {
target: "success",
action: (_, e) => ({ data: e.data, error: null }),
},
onError: {
target: "error",
action: (_, e) => ({ error: e.error.message }),
},
},
success: { on: { REFRESH: "loading" } },
error: { on: { RETRY: "loading" } },
},
});
const { state, send } = useStateChart(fetchMachine);
useEffect(() => {
send("FETCH");
}, [send]);
return {
data: state.context.data,
error: state.context.error,
isLoading: state.matches("loading"),
isError: state.matches("error"),
refetch: () => send("REFRESH"),
retry: () => send("RETRY"),
};
}
function UserList() {
const { data, error, isLoading, isError, refetch, retry } =
useFetch<User[]>("/api/users");
if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage error={error} onRetry={retry} />;
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}