Lightweight React hooks and utilities for fetch requests with cancellation support.
npm install react-fetch-utils- React 16.8+
- Node 18+ (for package development/build tooling)
import {
FetchPromise,
createFetchClient,
useQueries,
useRequest,
STATUS, // Constants for readable status checks
statusEnum, // Legacy reverse lookup (0 -> "idle", etc.)
isUnauthorizedError, // Type guard for 401 errors
isTimeoutError, // Type guard for timeout errors
isUnknownError, // Type guard for other errors
type CancellablePromise,
type FetchClientDefaults,
type FetchPromiseError,
type FetchResponseConfig,
type UnauthorizedError,
type TimeoutError,
type UnknownError,
type FetchPromiseParams,
type FetchRequestConfig,
type Status,
type UseRequestOptions,
type UseRequestResult,
} from "react-fetch-utils";FetchPromise- Create a cancellable fetch requestcreateFetchClient- Create a reusable API client with defaultsuseQueries- Track multiple in-flight requests with status aggregationuseRequest- React hook for fetch with caching & lifecycle managementSTATUS- Status constants (IDLE, LOADING, SUCCESS, ERROR)statusEnum- Reverse lookup map for status codes (legacy)isUnauthorizedError- Type guard for UnauthorizedErrorisTimeoutError- Type guard for TimeoutErrorisUnknownError- Type guard for UnknownError- Types:
CancellablePromiseFetchClientDefaultsFetchPromiseError(discriminated union of error types)UnauthorizedError(401 response)TimeoutError(request timeout)UnknownError(other failures)FetchResponseConfig(passed to onResponse interceptor)FetchPromiseParamsFetchRequestConfigStatusUseRequestOptionsUseRequestResult
FetchPromiseParams(type)FetchRequestConfig(type)Status(type)UseRequestOptions(type)UseRequestResult(type)
import { useEffect } from "react";
import { FetchPromise, useRequest, STATUS } from "react-fetch-utils";
type Todo = { id: number; title: string; completed: boolean };
function getTodo() {
return FetchPromise<Todo>({
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "GET",
});
}
export function TodoCard() {
const { status, response, error } = useRequest(getTodo);
useEffect(() => {
if (status !== STATUS.SUCCESS || !response) return;
console.log("Loaded:", response.title);
}, [status, response]);
if (status === STATUS.IDLE) return <p>Idle</p>;
if (status === STATUS.LOADING) return <p>Loading...</p>;
if (status === STATUS.ERROR) return <p>Error: {String(error)}</p>;
return <p>{response?.title}</p>;
}Creates a cancellable promise around fetch.
Signature:
function FetchPromise<T = unknown>(
params: FetchPromiseParams,
): CancellablePromise<T>;FetchPromiseParams:
type FetchPromiseParams = {
url: string;
method: string;
body?: unknown;
headers?: HeadersInit | Record<string, string>;
timeoutMs?: number;
baseUrl?: string;
includeContentType?: boolean;
parseAs?: "json" | "raw" | "text" | "response";
onRequest?: (
request: FetchRequestConfig,
) => FetchRequestConfig | void | Promise<FetchRequestConfig | void>;
validateStatus?: (status: number, response: Response) => boolean;
getAuthToken?: () => string | null | undefined | Promise<string | null | undefined>;
allowBodyForGetHead?: boolean;
};Behavior:
- Parsing mode via
parseAs:json(default)raw(Blob)text(string)response(Response)
- Supports request timeout with
timeoutMsusingAbortController. - Supports base URL joining with
baseUrl. - Supports request customization with
headers,onRequest,validateStatus, andgetAuthToken. Acceptis set automatically only when caller did not provide it.Content-Type: application/jsonis added only when:- there is a body,
- method is
POST/PUT/PATCH, includeContentType !== false,- caller did not already provide
Content-Type.
- Request body behavior:
GET/HEADbodies are not sent by default.- use
allowBodyForGetHead: trueto allow body onGET/HEAD. - non-JSON bodies (
FormData,Blob,string,URLSearchParams, etc.) are passed through unchanged. - plain objects/values are JSON-stringified when body is allowed.
- Error model:
401rejects with{ reason: "Unauthorized", details: Response }- timeout abort rejects with
{ reason: "Timeout", details: ... } - other failures reject with
{ reason: "Unknown", details: ... }
- Returned promise remains cancellable with
.cancel()and is safe to call repeatedly.
Example (JSON):
import { FetchPromise } from "react-fetch-utils";
type User = { id: number; name: string };
export function getUser() {
return FetchPromise<User>({
url: "/user",
method: "POST",
baseUrl: "https://api.example.com/v1",
body: { includeDetails: true },
timeoutMs: 8000,
getAuthToken: () => localStorage.getItem("token"),
});
}Example (text parse mode + custom status validation):
import { FetchPromise } from "react-fetch-utils";
export function getHealth() {
return FetchPromise<string>({
url: "/health",
method: "GET",
parseAs: "text",
validateStatus: status => status >= 200 && status < 500,
});
}Example (full Response mode):
import { FetchPromise } from "react-fetch-utils";
export function getRawResponse() {
return FetchPromise<Response>({
url: "/diagnostics",
method: "GET",
parseAs: "response",
});
}Example (raw Blob + cancellation):
import { FetchPromise } from "react-fetch-utils";
const download = FetchPromise<Blob>({
url: "/api/report",
method: "GET",
parseAs: "raw",
});
// Cancel if no longer needed
download.cancel();Creates a reusable request function with merged defaults so app code does not need a custom API wrapper utility.
Signature:
function createFetchClient(
defaults?: FetchClientDefaults,
): <T = unknown>(params: FetchPromiseParams) => CancellablePromise<T>;Example (replace a custom APIRequest utility):
import { createFetchClient } from "react-fetch-utils";
export const apiRequest = createFetchClient({
baseUrl: "https://api.example.com/v1",
timeoutMs: 10000,
headers: {
"X-App-Client": "web",
},
getAuthToken: async () => localStorage.getItem("access_token"),
validateStatus: status => status >= 200 && status < 300,
onRequest: request => {
request.headers.set("X-Trace-Id", crypto.randomUUID());
},
});
export const getProfile = () =>
apiRequest<{ id: string; name: string }>({
url: "/profile",
method: "GET",
parseAs: "json",
});
export const downloadReport = () =>
apiRequest<Blob>({
url: "/reports/monthly",
method: "GET",
parseAs: "raw",
});Example (FormData upload without forced JSON content-type):
const formData = new FormData();
formData.append("file", file);
apiRequest({
url: "/upload",
method: "POST",
body: formData,
});Example (response interceptor for data transformation):
import { createFetchClient, type FetchResponseConfig } from "react-fetch-utils";
// Transform all responses to add a timestamp
export const apiRequest = createFetchClient({
baseUrl: "https://api.example.com/v1",
onResponse: async (response: FetchResponseConfig) => {
// Wrap response data with metadata
return {
data: response.data,
fetchedAt: new Date(),
status: response.status,
};
},
});Example (response interceptor for error handling):
// Add request/response logging and error transformation
export const apiRequest = createFetchClient({
baseUrl: "https://api.example.com/v1",
onRequest: (request) => {
console.log("Request:", request.url, request.init.method);
return request;
},
onResponse: async (response: FetchResponseConfig) => {
console.log("Response:", response.status, response.data);
// Custom error handling or transformation
if (response.status >= 400) {
throw new Error(`API Error: ${response.status}`);
}
return response.data;
},
});Tracks active cancellable promises with status aggregation and batch refetch.
Signature:
function useQueries(): {
list: CancellablePromise[];
cancelAll: () => void;
add: (query: CancellablePromise) => void;
remove: (query: CancellablePromise) => void;
getStatus: () => "idle" | "loading" | "success" | "error";
refetchAll: (queries: (() => CancellablePromise)[]) => void;
};Status aggregation:
idle- No active queriesloading- At least one query is in progresserror- At least one query failedsuccess- All queries completed successfully
Example (basic tracking):
import { useEffect } from "react";
import { FetchPromise, useQueries } from "react-fetch-utils";
export function SearchBox({ term }: { term: string }) {
const queries = useQueries();
useEffect(() => {
// Cancel older in-flight requests before starting a new one
queries.cancelAll();
const query = FetchPromise({
url: `/api/search?q=${encodeURIComponent(term)}`,
method: "GET",
});
queries.add(query);
query
.then(result => {
console.log(result);
})
.catch(error => {
// Abort rejections are surfaced in error.details
if (error?.details?.name === "AbortError") return;
console.error(error);
})
.finally(() => {
queries.remove(query);
});
}, [term]);
return <div>Active queries: {queries.list.length}</div>;
}Example (status aggregation and batch refetch):
import { useEffect } from "react";
import { FetchPromise, useQueries } from "react-fetch-utils";
function fetchUser() {
return FetchPromise({ url: "/api/user", method: "GET" });
}
function fetchPosts() {
return FetchPromise({ url: "/api/posts", method: "GET" });
}
function fetchComments() {
return FetchPromise({ url: "/api/comments", method: "GET" });
}
export function Dashboard() {
const queries = useQueries();
const status = queries.getStatus();
const handleLoadAll = () => {
queries.refetchAll([fetchUser, fetchPosts, fetchComments]);
};
useEffect(() => {
handleLoadAll();
return () => queries.cancelAll();
}, []);
if (status === "idle") return <p>Ready</p>;
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p>Failed to load data</p>;
return (
<div>
<p>All data loaded ({queries.list.length} queries)</p>
<button onClick={handleLoadAll}>Refresh All</button>
</div>
);
}Runs a cancellable request factory with error handling, cancellation, and optional dependency-driven reruns.
Signature:
function useRequest<T = unknown>(
fetchPromise: (() => CancellablePromise<T>) | null | undefined,
disableCacheOrOptions?: boolean | UseRequestOptions,
): UseRequestResult<T>;
type UseRequestOptions = {
disableCache?: boolean;
cacheKey?: string;
enabled?: boolean;
deps?: ReadonlyArray<unknown>;
staleTimeMs?: number;
dedupe?: boolean;
};
type UseRequestResult<T> = {
status: Status;
response: T | null;
error: unknown;
refetch: () => void;
cancel: () => void;
reset: () => void;
};Behavior:
- Status transitions:
0(idle) ->1(loading) ->2(success) or3(error) - Handles promise rejections and exposes them through
error. - Uses
cacheKeyoption when provided. Fallback key is derived from factory name (or function source hash for anonymous factories). - Reuses fresh cache across hook instances unless
disableCache: true. staleTimeMscontrols cache freshness (default:Infinity).dedupereuses in-flight requests that share the samecacheKey(default:true).- Supports automatic reruns by passing
depsand can be disabled withenabled: false. - Exposes
refetch(),cancel(), andreset()helpers.
Example (cached by default, with STATUS constants):
import { useEffect } from "react";
import { FetchPromise, useRequest, STATUS } from "react-fetch-utils";
type Profile = { name: string; role: string };
function loadProfile() {
return FetchPromise<Profile>({
url: "/api/profile",
method: "GET",
});
}
export function ProfilePanel() {
const { status, response, error, refetch } = useRequest(loadProfile, {
cacheKey: "profile",
});
useEffect(() => {
if (status !== STATUS.SUCCESS || !response) return;
console.log("Ready:", response.name);
}, [status, response]);
if (status === STATUS.ERROR) {
return (
<div>
<p>Failed to load profile.</p>
<button onClick={refetch}>Retry</button>
<pre>{String(error)}</pre>
</div>
);
}
if (status === STATUS.LOADING) {
return <p>Loading...</p>;
}
return <p>Profile: {response?.name}</p>;
}Example (disable cache):
const { status, response } = useRequest(loadProfile, true);Maps Status values to labels.
const statusEnum = {
0: "idle",
1: "fetching",
2: "done",
3: "error",
} as const;Example:
import { statusEnum, type Status } from "react-fetch-utils";
function labelFor(status: Status) {
return statusEnum[status];
}type CancellablePromise<T = unknown> = Promise<T> & {
cancel: () => void;
};Example:
import { type CancellablePromise, FetchPromise } from "react-fetch-utils";
const req: CancellablePromise<{ ok: boolean }> = FetchPromise({
url: "/api/health",
method: "GET",
});
req.cancel();Example:
import { type FetchPromiseParams, FetchPromise } from "react-fetch-utils";
const request: FetchPromiseParams = {
url: "/api/items",
method: "POST",
body: { page: 1 },
headers: { Authorization: "Bearer token" },
timeoutMs: 5000,
parseAs: "json",
};
FetchPromise(request);Discriminated union of error types. Use type guards to handle errors precisely:
import {
type FetchPromiseError,
isUnauthorizedError,
isTimeoutError,
isUnknownError
} from "react-fetch-utils";
const handleError = (error: FetchPromiseError) => {
if (isUnauthorizedError(error)) {
// 401 Unauthorized - handle auth failure
console.error("Auth failed:", error.status, error.response);
redirectToLogin();
} else if (isTimeoutError(error)) {
// Request timeout
console.error("Timeout after", error.timeoutMs, "ms");
showRetryUI();
} else if (isUnknownError(error)) {
// Network error, parse error, or invalid status
console.error("Request failed:", error.details);
showErrorUI(error);
}
};Error types:
// 401 Unauthorized response
interface UnauthorizedError {
reason: "Unauthorized";
status: 401;
response: Response;
details: unknown;
originalError?: unknown;
}
// Request timeout (exceeded timeoutMs)
interface TimeoutError {
reason: "Timeout";
timeoutMs?: number;
details: unknown;
originalError: unknown;
}
// Other failures (network error, parsing error, failed status validation, etc.)
interface UnknownError {
reason: "Unknown";
status?: number;
response?: Response;
details: unknown;
originalError?: unknown;
}
type FetchPromiseError = UnauthorizedError | TimeoutError | UnknownError;type FetchResponseConfig = {
/** HTTP status code */
status: number;
/** Response headers */
headers: Headers;
/** Parsed/raw response data */
data: unknown;
/** Original fetch Response object */
response: Response;
};type FetchRequestConfig = {
url: string;
init: RequestInit;
headers: Headers;
};type FetchClientDefaults = Omit<FetchPromiseParams, "url" | "method"> & {
method?: string;
};type Status = 0 | 1 | 2 | 3;Example:
import { type Status, statusEnum } from "react-fetch-utils";
const status: Status = 1;
console.log(statusEnum[status]); // "fetching"- Use
cacheKeywithuseRequestwhen a request has dynamic arguments to avoid collisions. - Set
staleTimeMs: 0to force revalidation on each mount while still allowing in-flight dedupe.
Use type guards to handle different error types precisely:
import {
FetchPromise,
isUnauthorizedError,
isTimeoutError,
isUnknownError,
} from "react-fetch-utils";
async function loadData() {
try {
const data = await FetchPromise({ url: "/api/data", method: "GET" });
return data;
} catch (error) {
if (isUnauthorizedError(error)) {
// Handle 401 - redirect to login
window.location.href = "/login";
} else if (isTimeoutError(error)) {
// Handle timeout - show retry button
console.error(`Request timed out after ${error.timeoutMs}ms`);
} else if (isUnknownError(error)) {
// Handle other errors
console.error(`Request failed: ${error.details}`);
}
}
}Or with useRequest:
import {
useRequest,
isUnauthorizedError,
isTimeoutError,
} from "react-fetch-utils";
export function MyComponent() {
const { status, error, response } = useRequest(() =>
FetchPromise({ url: "/api/data", method: "GET" })
);
if (error) {
if (isUnauthorizedError(error)) {
return <p>Please log in again</p>;
}
if (isTimeoutError(error)) {
return <p>Request timed out. Please try again.</p>;
}
return <p>Error: {String(error.details)}</p>;
}
// ... rest of component
}Problem: status === STATUS.LOADING never transitions to SUCCESS or ERROR.
Possible causes:
-
The factory function is not being called. Check that
enabledistrue(default). -
The promise never resolves or rejects. Ensure your fetch returns valid JSON or use
parseAs: "text"/parseAs: "raw". -
Network request is hanging. Add a
timeoutMsto auto-abort long requests:const { status } = useRequest(() => FetchPromise({ url: "/api/data", method: "GET", timeoutMs: 5000 }) );
Problem: Stale data persists after mutation or page change.
Solutions:
-
Explicitly call
refetch()after mutations to bypass cache:const { refetch } = useRequest(fetchData); await submitForm(data); refetch(); // Force fresh data
-
Use different cache keys for different data sets:
const userId = props.userId; const { response } = useRequest(() => fetchUser(userId), { cacheKey: `user-${userId}` // Unique per user });
-
Disable caching for specific requests:
const { response } = useRequest(fetchData, { disableCache: true });
Problem: The same request runs multiple times instead of being deduplicated.
Causes & solutions:
-
Anonymous functions create new references on every render. Use named functions or
useCallback:// ❌ Bad: new function on every render const { response } = useRequest(() => FetchPromise({ url, method })); // ✅ Good: stable function reference const fetchData = useCallback(() => FetchPromise({ url, method }), [url, method] ); const { response } = useRequest(fetchData);
-
Deduplication is enabled by default. Disable it only if you need parallel requests:
const { response } = useRequest(fetchData, { dedupe: false });
Problem: Requests are rejected with reason: "Unauthorized".
Solutions:
-
Provide a
getAuthTokenfunction to auto-inject bearer tokens:FetchPromise({ url: "/api/protected", method: "GET", getAuthToken: () => localStorage.getItem("token") });
-
Handle 401 in
onRequestto refresh tokens:const client = createFetchClient({ onRequest: async (request) => { const token = await refreshTokenIfNeeded(); request.headers.set("Authorization", `Bearer ${token}`); return request; } });
Problem: Request runs past timeoutMs without aborting.
Solution: Ensure timeoutMs is set and is a positive number:
FetchPromise({
url: "/api/slow",
method: "GET",
timeoutMs: 3000 // Abort after 3 seconds
});Check error handling to catch timeout errors:
promise.catch(err => {
if (err.reason === "Timeout") {
console.error("Request timed out after", err.details.timeoutMs, "ms");
}
});Problem: TypeScript complains about response type mismatch.
Solution: Explicitly type the generic parameter:
type ApiResponse = { id: number; name: string };
const { response } = useRequest<ApiResponse>(() =>
FetchPromise<ApiResponse>({
url: "/api/data",
method: "GET"
})
);MIT
See CONTRIBUTING and ISSUES