export type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
export type FetchEvent<T> =
| { type: 'FETCH' }
| { type: 'RESOLVE'; data: T }
| { type: 'REJECT'; error: Error }
| { type: 'RESET' };
export function fetchReducer<T>(
state: FetchState<T>,
event: FetchEvent<T>
): FetchState<T> {
switch (event.type) {
case 'FETCH':
return { status: 'loading' };
case 'RESOLVE':
return { status: 'success', data: event.data };
case 'REJECT':
return { status: 'error', error: event.error };
case 'RESET':
return { status: 'idle' };
default:
return state;
}
}
export const initialState: FetchState<never> = { status: 'idle' };
import { useEffect, useReducer } from 'react';
import { FetchState, fetchReducer, initialState } from './fetchMachine';
type Fetcher<T> = (signal: AbortSignal) => Promise<T>;
export function useFetch<T>(
fetcher: Fetcher<T>,
deps: React.DependencyList
): FetchState<T> {
const [state, dispatch] = useReducer(
fetchReducer as React.Reducer<FetchState<T>, any>,
initialState as FetchState<T>
);
useEffect(() => {
const controller = new AbortController();
dispatch({ type: 'FETCH' });
fetcher(controller.signal)
.then((data) => dispatch({ type: 'RESOLVE', data }))
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return; // deliberate cancellation, not a real failure
}
const err = error instanceof Error ? error : new Error(String(error));
dispatch({ type: 'REJECT', error: err });
});
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
import { useFetch } from './useFetch';
interface User {
id: string;
name: string;
email: string;
}
async function loadUser(id: string, signal: AbortSignal): Promise<User> {
const res = await fetch(`/api/users/${id}`, { signal });
if (!res.ok) throw new Error(`Failed to load user (${res.status})`);
return res.json();
}
export function UserCard({ userId }: { userId: string }) {
const state = useFetch<User>((signal) => loadUser(userId, signal), [userId]);
switch (state.status) {
case 'idle':
case 'loading':
return <div className="skeleton">Loading…</div>;
case 'error':
return <div role="alert">Could not load: {state.error.message}</div>;
case 'success':
return (
<article className="user-card">
<h2>{state.data.name}</h2>
<a href={`mailto:${state.data.email}`}>{state.data.email}</a>
</article>
);
default: {
const _exhaustive: never = state;
return _exhaustive;
}
}
}
This snippet models the lifecycle of an async fetch as an explicit state machine whose states are encoded as a TypeScript discriminated union, rather than the common tangle of independent isLoading, error, and data booleans. That loose approach permits impossible combinations — loading and error at once, or data present while still loading a fresh request — and forces consumers to guard against states that should never occur.
In fetchMachine.ts, the FetchState<T> union has four members (idle, loading, success, error), each carrying exactly the fields that are valid in that state: only success has data, only error has error. The shared string literal status is the discriminant, so narrowing on state.status lets the compiler know which fields exist. The transitions live in a pure fetchReducer, which pattern-matches on the incoming FetchEvent and returns the next state. Because it is a plain function of (state, event), it is trivial to unit test and reason about, and illegal transitions simply have no case.
The useFetch.ts tab wires that reducer into React via useReducer and drives it from an effect. Each run creates an AbortController so an in-flight request is cancelled when dependencies change or the component unmounts; this prevents the classic race where a slow earlier response overwrites a newer one. The effect dispatches FETCH before awaiting, then RESOLVE or REJECT, while swallowing AbortError so a deliberate cancellation never flips the machine into the error state. useFetch returns the union directly, preserving the discriminant for callers.
In UserCard.tsx, the consumer switches over state.status, and inside each branch TypeScript narrows the type: state.data is only reachable under success, state.error only under error. The default branch with never gives an exhaustiveness check, so adding a new state to the union becomes a compile error until every consumer handles it.
This pattern is worth reaching for whenever a component juggles several mutually exclusive async phases; it trades a little upfront type ceremony for unrepresentable illegal states and self-documenting consumers. The main trade-off is verbosity, which is usually a fair price for eliminating an entire class of runtime bugs.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
Share this code
Here's the card — post it anywhere.