React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

fetchdata-fetchingloadingerrorabortcustom-hook

useFetch — Simple data fetching with loading, error, and refetch

Recipe

import { useState, useEffect, useCallback, useRef } from "react";
 
interface UseFetchOptions<T> {
  /** Skip the initial fetch. Default: false */
  skip?: boolean;
  /** Transform the response before setting data */
  transform?: (data: unknown) => T;
  /** Custom fetch init options (headers, method, etc.) */
  init?: RequestInit;
  /** Dependencies that trigger a refetch when changed */
  deps?: unknown[];
}
 
interface UseFetchReturn<T> {
  data: T | null;
  error: Error | null;
  isLoading: boolean;
  /** Manually trigger a refetch */
  refetch: () => void;
  /** Abort the current request */
  abort: () => void;
}
 
function useFetch<T = unknown>(
  url: string | null,
  options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
  const { skip = false, transform, init, deps = [] } = options;
 
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(!skip && url !== null);
  const abortControllerRef = useRef<AbortController | null>(null);
  const mountedRef = useRef(true);
 
  const fetchData = useCallback(async () => {
    if (!url || skip) return;
 
    // Abort any in-flight request
    abortControllerRef.current?.abort();
    const controller = new AbortController();
    abortControllerRef.current = controller;
 
    setIsLoading(true);
    setError(null);
 
    try {
      const response = await fetch(url, {
        ...init,
        signal: controller.signal,
      });
 
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
 
      const json = await response.json();
      const result = transform ? transform(json) : (json as T);
 
      if (mountedRef.current && !controller.signal.aborted) {
        setData(result);
        setIsLoading(false);
      }
    } catch (err) {
      if (err instanceof DOMException && err.name === "AbortError") {
        // Request was aborted, do not update state
        return;
      }
      if (mountedRef.current) {
        setError(err instanceof Error ? err : new Error(String(err)));
        setIsLoading(false);
      }
    }
  }, [url, skip, transform, init, ...deps]);
 
  // Fetch on mount and when dependencies change
  useEffect(() => {
    mountedRef.current = true;
    fetchData();
 
    return () => {
      mountedRef.current = false;
      abortControllerRef.current?.abort();
    };
  }, [fetchData]);
 
  const abort = useCallback(() => {
    abortControllerRef.current?.abort();
    setIsLoading(false);
  }, []);
 
  return { data, error, isLoading, refetch: fetchData, abort };
}

When to reach for this: You need a lightweight data-fetching hook for simple use cases, prototypes, or when SWR/TanStack Query is too heavy. For production apps with caching, deduplication, and revalidation, prefer a dedicated library.

Working Example

"use client";
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
function UserProfile({ userId }: { userId: number }) {
  const { data, error, isLoading, refetch } = useFetch<User>(
    `https://jsonplaceholder.typicode.com/users/${userId}`,
    { deps: [userId] }
  );
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!data) return null;
 
  return (
    <div>
      <h2>{data.name}</h2>
      <p>{data.email}</p>
      <button onClick={refetch}>Refresh</button>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = useFetch<
    Array<{ id: number; title: string }>
  >("https://jsonplaceholder.typicode.com/posts", {
    transform: (raw) =>
      (raw as Array<{ id: number; title: string }>).slice(0, 10),
  });
 
  if (isLoading) return <p>Loading posts...</p>;
 
  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
 
// Skip fetch until user acts
function SearchResults() {
  const [query, setQuery] = useState("");
  const [searchTerm, setSearchTerm] = useState<string | null>(null);
 
  const { data, isLoading } = useFetch<{ results: string[] }>(
    searchTerm
      ? `https://api.example.com/search?q=${encodeURIComponent(searchTerm)}`
      : null
  );
 
  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <button onClick={() => setSearchTerm(query)}>Search</button>
      {isLoading && <p>Searching...</p>}
      {data?.results.map((r, i) => <p key={i}>{r}</p>)}
    </div>
  );
}

What this demonstrates:

  • UserProfile: Fetches user data by ID, refetches when userId changes, manual refetch button
  • PostList: Transforms the response to take only the first 10 items
  • SearchResults: Passes null as URL to skip fetching until the user submits a search

Deep Dive

How It Works

  • AbortController: Every fetch creates a new AbortController. If the URL or dependencies change before the request completes, the previous request is aborted to prevent race conditions.
  • Null URL pattern: Passing null as the URL skips the fetch entirely. This is a common pattern for conditional fetching (similar to SWR's conditional fetching).
  • Transform function: Runs before setData, letting you reshape API responses (filter, map, pick fields) without extra state.
  • Mount guard: mountedRef prevents state updates after unmount, avoiding React warnings.
  • AbortError handling: Aborted requests throw a DOMException with name: "AbortError". The hook silently ignores these to avoid false error states.

Parameters & Return Values

ParameterTypeDefaultDescription
urlstring or nullFetch URL, or null to skip
options.skipbooleanfalseSkip the fetch
options.transform(data: unknown) => TTransform response data
options.initRequestInitFetch options (headers, method, body)
options.depsunknown[][]Extra deps that trigger refetch
ReturnTypeDescription
dataT or nullResponse data, or null
errorError or nullError, or null
isLoadingbooleanWhether a request is in flight
refetch() => voidManually trigger a new fetch
abort() => voidCancel the current request

Variations

POST/PUT requests: Pass method and body through init:

const { data, isLoading } = useFetch("/api/users", {
  init: {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: "Alice" }),
  },
});

With retry: Add automatic retry on failure:

// Inside fetchData, wrap in a retry loop:
for (let attempt = 0; attempt < maxRetries; attempt++) {
  try {
    const response = await fetch(url, { signal: controller.signal });
    // ...success handling
    break;
  } catch (err) {
    if (attempt === maxRetries - 1) throw err;
    await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
  }
}

When to Use This vs Libraries

FeatureuseFetch (this hook)SWRTanStack Query
Bundle size0 KB~4 KB~13 KB
CachingNoYesYes
DeduplicationNoYesYes
RevalidationManual onlyAutomaticAutomatic
Optimistic updatesNoYesYes
DevToolsNoNoYes
Best forPrototypes, simple casesMedium complexityComplex apps

TypeScript Notes

  • Generic T defaults to unknown and is inferred from the transform function if provided.
  • data is typed as T | null (null before the first successful fetch).
  • error is always Error | null, wrapping non-Error throws in new Error(String(err)).

Gotchas

  • Race conditions without abort — If the URL changes rapidly, multiple requests may resolve out of order. Fix: The AbortController cancels stale requests; always use it.
  • No caching — Every mount triggers a new network request. Fix: For apps that need caching, use SWR or TanStack Query.
  • init reference instability — Passing a new init object on every render triggers infinite refetches. Fix: Memoize init with useMemo or define it outside the component.
  • deps spread in useCallback — The ...deps spread in the dependency array can cause unexpected refetches if deps contain unstable references. Fix: Ensure deps contain only primitives or stable references.
  • No SSR data — This hook only fetches on the client. Fix: For SSR/RSC, fetch data in a server component and pass it as props.

Alternatives

PackageHook NameNotes
swruseSWRStale-while-revalidate, most popular
@tanstack/react-queryuseQueryFull-featured, devtools, mutations
reactuse()React 19 built-in for suspense-based fetching
usehooks-tsuseFetchSimilar simple implementation
axios + custom hookUse axios for interceptors, transform with a wrapper hook

FAQs

How does useFetch prevent race conditions when the URL changes rapidly?
  • Each call to fetchData creates a new AbortController and aborts the previous in-flight request.
  • The controller.signal.aborted check prevents stale responses from updating state.
How do you skip the initial fetch and trigger it manually?

Either pass skip: true or pass null as the URL:

// Option 1: skip option
const { refetch } = useFetch("/api/data", { skip: true });
 
// Option 2: null URL
const { data } = useFetch(query ? `/api/search?q=${query}` : null);
What does the transform option do and when would you use it?
  • transform runs before setData, letting you reshape the API response (filter, map, pick fields) without extra state.
  • Example: slicing a large array to only the first 10 items.
How do you make a POST request with useFetch?

Pass method and body through the init option:

const { data } = useFetch("/api/users", {
  init: {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: "Alice" }),
  },
});
Gotcha: What happens if you pass a new init object on every render?
  • A new object reference on each render changes the useCallback dependency, triggering infinite refetches.
  • Fix: memoize init with useMemo or define it as a constant outside the component.
Gotcha: Why does spreading deps in the useCallback dependency array sometimes cause unexpected refetches?
  • If deps contains objects or arrays that are recreated each render, the reference changes trigger a new fetchData function, which triggers the fetch effect.
  • Fix: ensure deps contain only primitives or stable references.
Why does useFetch not provide caching, and when should you switch to a library?
  • Every mount triggers a new network request with no deduplication or stale-while-revalidate logic.
  • Switch to SWR or TanStack Query when you need caching, automatic revalidation, optimistic updates, or devtools.
How does the hook handle AbortError differently from other errors?
  • Aborted requests throw a DOMException with name: "AbortError".
  • The hook detects this and returns early without updating error state, since the abort was intentional.
Does useFetch work with server-side rendering?
  • No. This hook only fetches on the client after mount.
  • For SSR or React Server Components, fetch data in a server component and pass it as props.
How is the generic type T inferred in TypeScript?
  • T defaults to unknown and can be explicitly provided: useFetch<User>(url).
  • If a transform function is provided, T is inferred from its return type.
  • data is typed as T | null, where null represents the pre-fetch state.
What is the type of the error returned by useFetch?
  • error is typed as Error | null.
  • Non-Error throws are wrapped in new Error(String(err)) to normalize the type.
How do you abort a request manually from the consuming component?

Use the abort function from the return value:

const { data, isLoading, abort } = useFetch("/api/slow-endpoint");
return <button onClick={abort}>Cancel</button>;
  • useDebounce — debounce search queries before fetching
  • useInterval — poll an endpoint periodically
  • SWR — production-grade data fetching