reactjavascripttypescriptfetchhooks

JSON in React — Fetching, Typing, and a Reusable useFetch Hook

·8 min read·Tool Guides

The pattern everyone writes once and then copies forever

Fetching JSON in a React component always needs the same three pieces of state — the data itself, a loading flag, and an error — and almost every codebase ends up with a slightly different hand-rolled version of the same useEffect block. Getting the shared version right once, as a hook, is worth doing before it's copy-pasted into a dozen components with a dozen small inconsistencies.

The naive version, and what's wrong with it

jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [userId]);

  return user ? <div>{user.name}</div> : <p>Loading...</p>;
}

This works for a demo and breaks in a few specific, common ways: it never checks res.ok, so a 404 or 500 response still gets JSON-parsed as if it succeeded (or throws a confusing parse error on a non-JSON error page); there's no error state to render; and if userId changes quickly (fast navigation, a search box), an earlier slow request can resolve *after* a newer one and overwrite fresher data with stale data — a real, hard-to-reproduce bug called a race condition.

A typed, reusable version

tsx
interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((err) => {
        if (err.name === "AbortError") return; // request was cancelled, not a real error
        setState({ data: null, loading: false, error: err.message });
      });

    return () => controller.abort(); // cancels the in-flight request on unmount or re-run
  }, [url]);

  return state;
}

The AbortController is what actually fixes the race condition — when url changes and the effect re-runs, the cleanup function cancels the previous request, so a slow, now-stale response never reaches setState at all instead of needing a manual "is this still the latest request" flag.

tsx
interface User { id: string; name: string; email: string; }

function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Failed to load user: {error}</p>;
  return <div>{user!.name}</div>;
}

Where should the User type come from?

Hand-writing interface User { ... } and hoping it matches the real API response is a common source of drift — the type says one thing, the backend ships another, and nothing catches the mismatch until runtime. Two better sources: generate the interface directly from a real sample response with JSON to TypeScript, or — if the backend publishes an OpenAPI spec — generate types from that spec with a tool like openapi-typescript, which keeps the frontend types tied to the documented contract rather than to whatever sample happened to be pasted in once.

Handling an array response

The same hook works unchanged for a list endpoint — only the generic type parameter and the loading UI differ:

tsx
const { data: users, loading, error } = useFetch<User[]>("/api/users");

if (loading) return <Skeleton count={5} />;
if (error) return <ErrorBanner message={error} />;
return <ul>{users!.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;

When to stop hand-rolling this and use a library

The hook above covers the common case, but a real application usually grows requirements a hand-rolled hook doesn't cleanly cover — caching between components that both fetch the same URL, automatic background refetching, retry-with-backoff on failure, request deduplication. That's the point to reach for TanStack Query (React Query) or SWR rather than continuing to extend a homegrown hook — both solve exactly this set of problems and are the de facto standard for anything beyond a handful of simple fetches.

tsx
// TanStack Query — same shape of result, considerably more capability underneath
const { data: user, isLoading, error } = useQuery({
  queryKey: ["user", userId],
  queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});

Frequently asked questions

A boolean flag (common in older React code) still lets the fetch complete and consume bandwidth/server resources for a response nobody needs anymore — it only prevents the *state update*, not the request itself. AbortController actually cancels the underlying network request, which is strictly better and is now well-supported across browsers.

For a fetch tied to one component's lifecycle (a profile page loading its own data), a local hook is the right scope — no other part of the app needs to know this request happened. Reach for a global store (or React Query's cache) when the same data is needed by multiple, unrelated components and you want to avoid fetching it twice.

Check res.headers.get("content-type") before calling res.json(), or wrap res.json() in a try/catch — a load balancer or auth proxy returning an HTML error page instead of your API's JSON error shape is a common real-world cause of a confusing "Unexpected token <" parse error surfacing in your catch block instead of a clean HTTP status check.

Not quite — Server Components fetch data during server-side rendering, before any client-side JavaScript runs, so there's no loading state to manage the way there is in a useEffect-based client hook; you await fetch(...) directly in the component function. This client-side hook pattern is specifically for Client Components that need to fetch after the initial render (in response to user interaction, a prop change, etc.).

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.