How to Handle API Calls in React: A Complete Guide with JSX and Hooks

Learn how to make and manage API calls in React using JSX and hooks. Master useState, useEffect, error handling, loading states, and best practices for building robust React applications.

How to Handle API Calls in React: A Complete Guide with JSX and Hooks

Making API calls is one of the most common tasks in React development. Whether you’re fetching user data, submitting forms, or loading dynamic content, understanding how to properly handle API calls is essential. In this guide, we’ll explore how to make and manage API calls in React using JSX and React hooks.

Why Handle API Calls Carefully in React?

React’s component-based architecture and declarative nature require a different approach to API calls compared to traditional JavaScript. You need to consider:

  • Component lifecycle - When should the API call be made?
  • State management - How do you store and update the response data?
  • Loading states - How do you show users that data is being fetched?
  • Error handling - What happens when the API call fails?
  • Cleanup - How do you prevent memory leaks and race conditions?

Let’s dive into the practical implementation.

The Basic Pattern: useState + useEffect

The most common pattern for API calls in React combines useState for managing data and useEffect for triggering the call.

Basic Structure

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>
  );
}

Key Points

  1. Three state variables - data, loading, and error cover all possible states
  2. Async function inside useEffect - useEffect itself can’t be async, so wrap your logic
  3. Dependency array - Include all values that should trigger a refetch
  4. Error handling - Always wrap fetch calls in try-catch
  5. Cleanup - The finally block ensures loading state is always reset

Handling Different HTTP Methods

GET Requests

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>
  );
}

POST Requests

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>
  );
}

PUT/PATCH Requests

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

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>
  );
}

Creating a Custom Hook for API Calls

One of React’s greatest strengths is the ability to create custom hooks. Let’s create a reusable hook for API calls.

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>;
}

Advanced useFetch with Refetch

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>
  );
}

Handling Authentication Headers

Most APIs require authentication. Here’s how to include auth headers in your requests.

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 };
}

Error Boundaries and Global Error Handling

For production applications, consider implementing global error handling.

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>
  );
}

Best Practices

1. Use AbortController for Cleanup

Prevent memory leaks and race conditions by aborting pending requests on unmount.

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

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

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

2. Handle Loading States Gracefully

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} />;
}

3. Implement Retry Logic

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 };
}

4. Debounce Search Requests

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>
  );
}

5. Use React Query for Complex Scenarios

For production apps with complex caching, background updates, and pagination, consider libraries like React Query (TanStack Query):

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>;
}

Common Mistakes to Avoid

Missing Dependency Array

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

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

Not Handling Errors

// Bad: Silent failures
useEffect(() => {
  fetch('/api/data').then(res => res.json()).then(setData);
}, []);

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

Setting State on Unmounted Component

// Bad: Can cause 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; };
}, []);

Conclusion

Handling API calls in React requires understanding the component lifecycle, state management, and proper cleanup. By combining useState and useEffect, you can create robust data-fetching logic that handles loading states, errors, and edge cases gracefully.

Key takeaways:

  • Use useState for data, loading, and error states
  • Wrap API calls in useEffect with proper dependency arrays
  • Always handle errors and loading states
  • Use AbortController for cleanup
  • Consider custom hooks for reusability
  • For complex apps, use libraries like React Query

Master these patterns, and you’ll be well-equipped to build production-ready React applications that communicate effectively with APIs.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.