Handling API Calls in React: useState, useEffect, and Custom Hooks

How to fetch data in React with useState and useEffect, the pattern I use for every project, the memory leak that taught me about AbortController, and when to reach for React Query instead.

Handling API Calls in React: useState, useEffect, and Custom Hooks

Every React project I’ve ever worked on has had the same moment: you need data from an API, you wire up a useEffect, and then you spend the next hour figuring out why your component re-renders forty times or why the console says “Can’t perform a React state update on an unmounted component.”

I’ve hit both of those bugs in production. The second one, the memory leak warning, is what finally made me learn AbortController properly. Here’s everything I know about fetching data in React, from the basic pattern to the stuff I wish someone had told me earlier.

The Basic Pattern: useState + useEffect

This is the bread and butter. Three state variables, one effect, one async function:

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div className="user-profile">
      <h2>{data.name}</h2>
      <p>{data.email}</p>
    </div>
  );
}

The important bits:

  • Three states: data, loading, error. You need all three. I’ve tried skipping error state before and it always comes back to bite me.
  • Async function inside the effect. useEffect can’t be async itself: that changes the return value (which it expects to be a cleanup function). So you define an async function and call it.
  • The dependency array. [userId] means “re-run this effect when userId changes.” Leave it out and it runs on every render. Add something that changes every render and you get an infinite loop. Both are bad.
  • The finally block. Ensures loading is reset no matter what. Without it: a failed request leaves the user staring at “Loading…” forever.

Different HTTP Methods

GET Requests

The simplest case. Fetch data on mount:

function ProductList() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    const fetchProducts = async () => {
      const response = await fetch('/api/products');
      const data = await response.json();
      setProducts(data);
    };

    fetchProducts();
  }, []);

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

The empty dependency array [] means “run once on mount.” I use this pattern a lot for initial data loading.

POST Requests

Submitting data is a different pattern, triggered by user action, not component mount:

function CreatePost() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [status, setStatus] = useState('idle');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setStatus('loading');

    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ title, content }),
      });

      if (!response.ok) throw new Error('Failed to create post');

      const data = await response.json();
      console.log('Post created:', data);
      setStatus('success');
      setTitle('');
      setContent('');
    } catch (err) {
      setStatus('error');
      console.error(err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
      />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Content"
      />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Creating...' : 'Create Post'}
      </button>
      {status === 'error' && <p>Failed to create post</p>}
      {status === 'success' && <p>Post created successfully!</p>}
    </form>
  );
}

I like using a status string enum (idle, loading, success, error) for form submissions instead of separate booleans. It’s cleaner and prevents the impossible state of loading and error both being true.

PUT/PATCH Requests

Updating existing data. I handle these similarly to POST but with the resource URL:

function EditUser({ userId }) {
  const [name, setName] = useState('');
  const [isSaving, setIsSaving] = useState(false);

  const handleUpdate = async () => {
    setIsSaving(true);

    try {
      const response = await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });

      const updated = await response.json();
      console.log('User updated:', updated);
    } catch (err) {
      console.error('Update failed:', err);
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleUpdate} disabled={isSaving}>
        {isSaving ? 'Saving...' : 'Save Changes'}
      </button>
    </div>
  );
}

DELETE Requests

The trickiest one, because you need to update the UI after the deletion completes:

function TodoItem({ todo, onDelete }) {
  const [isDeleting, setIsDeleting] = useState(false);

  const handleDelete = async () => {
    setIsDeleting(true);

    try {
      await fetch(`/api/todos/${todo.id}`, {
        method: 'DELETE',
      });
      onDelete(todo.id);
    } catch (err) {
      console.error('Delete failed:', err);
      setIsDeleting(false);
    }
  };

  return (
    <li>
      {todo.text}
      <button onClick={handleDelete} disabled={isDeleting}>
        {isDeleting ? 'Deleting...' : 'Delete'}
      </button>
    </li>
  );
}

The onDelete callback is important, it lets the parent component remove the item from its state. Without it, the UI is out of sync with the server.

Building a Custom Hook for API Calls

Once you’ve written the useState + useEffect + fetch pattern for the fifth time, you start thinking about extraction. A custom useFetch hook is the natural next step.

Basic useFetch Hook

import { useState, useEffect } from 'react';

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    const fetchData = async () => {
      try {
        const response = await fetch(url, {
          ...options,
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const result = await response.json();
        setData(result);
      } catch (err) {
        if (err.name === 'AbortError') {
          console.log('Fetch aborted');
          return;
        }
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // Cleanup function
    return () => {
      controller.abort();
    };
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return <div>{data.name}</div>;
}

The AbortController is doing important work here. Without it, if the component unmounts while a fetch is in-flight, React will try to call setData on an unmounted component. That’s the memory leak warning I mentioned at the start. The cleanup function aborts the pending request, the AbortError catch clause handles it gracefully, and everyone’s happy.

useFetch with Refetch

Sometimes you need to manually re-fetch (after a form submission, or a “Refresh” button):

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const refetch = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`Status: ${response.status}`);
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    refetch();
  }, [url]);

  return { data, loading, error, refetch };
}

// Usage with manual refresh
function Dashboard() {
  const { data, loading, error, refetch } = useFetch('/api/dashboard');

  return (
    <div>
      <button onClick={refetch} disabled={loading}>
        Refresh
      </button>
      {loading ? <div>Loading...</div> : <DataDisplay data={data} />}
    </div>
  );
}

I use this pattern constantly. The refetch function gives you fine-grained control without having to juggle key-based re-mounting tricks.

Handling Authentication

Most real APIs need auth headers. Here’s how I handle that:

function useAuthFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        // Get token from storage or context
        const token = localStorage.getItem('authToken');

        const response = await fetch(url, {
          ...options,
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${token}`,
            ...options.headers,
          },
        });

        if (response.status === 401) {
          // Handle unauthorized - redirect to login
          window.location.href = '/login';
          return;
        }

        if (!response.ok) throw new Error(`Status: ${response.status}`);

        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

The 401 check is important, if the token’s expired, you don’t want to show the user a cryptic error. Redirecting to login is usually the right call. (In a real app, you’d probably want an auth context or interceptor instead of doing this in every hook, but the principle is the same.)

Error Boundaries

For unhandled errors that crash a component, React error boundaries are your safety net:

import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => window.location.reload()}>
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Wrap your app
function App() {
  return (
    <ErrorBoundary>
      <Dashboard />
    </ErrorBoundary>
  );
}

Error boundaries don’t catch errors in event handlers or async code, only in the render lifecycle. Keep that in mind. They’re a last resort, not a substitute for proper try-catch in your effects.

Best Practices (From My Mistakes)

Use AbortController for Cleanup

This is the one I learned the hard way. Always abort pending requests when the component unmounts:

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

  fetch(url, { signal: controller.signal })
    .then(res => res.json())
    .then(setData);

  return () => controller.abort();
}, [url]);

I had a dashboard component that fetched data for different time ranges. Switching ranges fast enough would trigger “Can’t perform a React state update on an unmounted component” warnings in the console. AbortController fixed it.

Handle Loading States Gracefully

Don’t just show “Loading…”, show something useful:

function UserList() {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!data) return <EmptyState />;

  return <UserGrid users={data} />;
}

Implement Retry Logic

For flaky APIs, retry before giving up:

function useFetchWithRetry(url, options = {}, maxRetries = 3) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [retryCount, setRetryCount] = useState(0);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url, options);
        if (!response.ok) throw new Error(`Status: ${response.status}`);
        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        if (retryCount < maxRetries) {
          setRetryCount(prev => prev + 1);
        } else {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url, retryCount]);

  return { data, loading, error, retryCount };
}

Debounce Search Requests

If you’re hitting an API on every keystroke in a search box, you’re going to hammer your backend. Debounce it:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) return;

    const delayDebounce = setTimeout(async () => {
      setLoading(true);
      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();
      setResults(data);
      setLoading(false);
    }, 300);

    return () => clearTimeout(delayDebounce);
  }, [query]);

  return (
    <div>
      {loading ? <Spinner /> : <ResultsList results={results} />}
    </div>
  );
}

The 300ms delay means you only fire the request after the user stops typing for a moment. Without this, typing “react hooks” would fire seven separate API calls.

Consider React Query for Complex Scenarios

I’ve been using React Query (TanStack Query) more and more. For anything beyond simple data fetching, it handles caching, background updates, pagination, and a dozen other things that you’d otherwise build yourself:

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`/api/users/${userId}`);
      return response.json();
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{data.name}</div>;
}

I’ll be honest, for simple projects, the useFetch custom hook above is plenty. React Query earns its weight when you’ve got multiple data sources, caching requirements, or optimistic updates. Don’t add it just because it’s popular.

Common Mistakes

Missing the Dependency Array

// Bad: Runs on every render, infinite loop
useEffect(() => {
  fetchData();
});

// Good: Runs only when userId changes
useEffect(() => {
  fetchData();
}, [userId]);

This is the #1 cause of infinite loops in React. If your effect makes a network call and you forget the dependency array, it’ll fire on every render, which triggers a state update, which triggers a re-render, which fires the effect again. Your browser tab will freeze and your API will send you an angry email.

Silent Failures

// Bad: No error handling, broken UI
useEffect(() => {
  fetch('/api/data').then(res => res.json()).then(setData);
}, []);

// Good: Handle errors properly
useEffect(() => {
  fetch('/api/data')
    .then(res => {
      if (!res.ok) throw new Error('Failed');
      return res.json();
    })
    .then(setData)
    .catch(setError);
}, []);

Setting State on Unmounted Components

// Bad: Memory leak warnings
useEffect(() => {
  fetchData().then(setData);
}, []);

// Good: Check if component is still mounted
useEffect(() => {
  let isMounted = true;
  
  fetchData().then(data => {
    if (isMounted) setData(data);
  });

  return () => { isMounted = false; };
}, []);

(Or just use AbortController like I showed above. Both approaches work; I prefer AbortController because it actually cancels the network request, not just ignores the result.)

What I Took Away

The useState + useEffect + fetch pattern is one of those things that looks simple but has a lot of surface area for bugs. The dependency array, the cleanup function, the error handling, each one is a place where a small mistake causes a confusing production bug.

My advice: start with the basic pattern, get it working, then add the robustness layer by layer. Don’t try to build the perfect useFetch hook on day one. Get the data flowing, handle the errors, add cleanup, then consider extracting.

And once you find yourself writing the same fetch logic for the third time, that’s when you build the custom hook. Not before. Premature abstraction is just as real in React as it is anywhere else.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.