← All writings ES

Custom Hook to Manage API Requests in React

Stop repeating useEffect + fetch boilerplate in every component. Build a reusable useAPI hook that handles loading, error, and data states in one place.

Every React developer has written this at least a dozen times:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  setLoading(true);
  fetch('/api/users')
    .then(res => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

It works. But it’s duplicated across every component that touches an API. The fix is a custom hook that encapsulates this pattern once and exposes a clean interface everywhere.

The basic useAPI hook

import { useState, useEffect } from 'react';

interface UseAPIOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  url: string;
  body?: unknown;
}

interface UseAPIResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useAPI<T>({ method = 'GET', url, body }: UseAPIOptions): UseAPIResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(url, {
      method,
      body: body ? JSON.stringify(body) : undefined,
      headers: { 'Content-Type': 'application/json' },
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [method, url, JSON.stringify(body)]);

  return { data, loading, error };
}

Using it in a component

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

function UserList() {
  const { data, loading, error } = useAPI<User[]>({ url: '/api/users' });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!data) return null;

  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  );
}

No boilerplate in the component. The three states — loading, error, data — are always available without repetition.

Supporting POST requests

The same hook handles mutations. For a form submission:

function CreatePost() {
  const [submitted, setSubmitted] = useState(false);
  const [formData, setFormData] = useState({ title: '', body: '' });

  const { data, loading, error } = useAPI<{ id: number }>({
    method: 'POST',
    url: submitted ? '/api/posts' : '',
    body: submitted ? formData : undefined,
  });

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setSubmitted(true);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.title}
        onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
        placeholder="Title"
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Saving...' : 'Save'}
      </button>
      {error && <p style={{ color: 'red' }}>{error.message}</p>}
      {data && <p>Created post #{data.id}</p>}
    </form>
  );
}

Adding abort support

The basic version has a bug: if the component unmounts while a request is in flight, the state update fires on an unmounted component. Fix it with AbortController:

useEffect(() => {
  const controller = new AbortController();

  setLoading(true);
  setError(null);

  fetch(url, {
    method,
    body: body ? JSON.stringify(body) : undefined,
    headers: { 'Content-Type': 'application/json' },
    signal: controller.signal,
  })
    .then(res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json() as Promise<T>;
    })
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    })
    .finally(() => setLoading(false));

  return () => controller.abort();
}, [method, url, JSON.stringify(body)]);

The cleanup function cancels the request when the component unmounts or the dependencies change — eliminating the race condition.

When to reach for something more

useAPI is a great fit for simple cases. As requirements grow, consider:

  • TanStack Query — adds caching, background refetching, pagination, and mutations with a consistent API
  • SWR — stale-while-revalidate strategy, minimal API, good for read-heavy apps
  • RTK Query — if you’re already using Redux Toolkit, it’s a natural fit

These libraries solve the problems you’ll hit next: deduplication of concurrent requests, cache invalidation, optimistic updates, and retry logic. The custom hook approach is a good stepping stone to understanding why they exist.

Key takeaways

  • Extract the fetch + state boilerplate into a single useAPI hook and never write it again
  • TypeScript generics (useAPI<User[]>) propagate the return type without extra casting
  • Always add AbortController cleanup to avoid state updates on unmounted components
  • Serialize the body in the useEffect dependency array to avoid infinite loops with object references
  • For production apps with complex data-fetching needs, TanStack Query is worth the dependency