coderain guide

How to Handle API Requests in Your React Frontend

In modern web development, React frontends rarely exist in isolation. They depend on backend services, third-party APIs, or databases to fetch, send, and manipulate data. Whether you’re building a simple to-do app or a complex enterprise platform, **handling API requests effectively** is critical for performance, user experience, and code maintainability. This guide will walk you through everything you need to know about managing API requests in React—from native tools like the Fetch API to popular libraries like Axios, state management with hooks, advanced caching, and error handling. By the end, you’ll be equipped to build robust, reliable data flows between your React app and backend services.

Table of Contents

  1. Understanding API Requests in React
  2. Using the Native Fetch API
  3. Leveraging Axios for Simplified Requests
  4. Managing API State with React Hooks
  5. Advanced: Using React Query/SWR
  6. Authentication & Headers
  7. Error Handling Best Practices
  8. Testing API Calls
  9. Conclusion
  10. References

1. Understanding API Requests in React

An API (Application Programming Interface) request is how your React frontend communicates with a backend server or external service. Common use cases include fetching user data, submitting forms, updating records, or integrating with third-party tools (e.g., payment gateways, social media APIs).

Why Proper Handling Matters:

  • User Experience: Slow or unhandled requests lead to frustrated users (e.g., infinite loading spinners, broken UIs).
  • Reliability: Unhandled errors can crash your app or leave users with stale data.
  • Performance: Poorly managed requests (e.g., redundant calls, lack of caching) waste bandwidth and slow down your app.
  • Maintainability: Scattered API logic becomes hard to debug or update as your app grows.

2. Using the Native Fetch API

The Fetch API is a built-in browser interface for making HTTP requests. It’s promise-based, meaning you can use async/await for clean, readable code.

Basic GET Request

To fetch data from an API endpoint (e.g., a list of users), use fetch() with the URL, then parse the response as JSON.

import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        // Make GET request
        const response = await fetch('https://jsonplaceholder.typicode.com/users');

        // Fetch doesn't throw errors for 4xx/5xx status codes—check manually!
        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }

        // Parse JSON response
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message); // Handle errors (e.g., network issues, 404)
      } finally {
        setLoading(false); // Hide loading state regardless of success/error
      }
    };

    fetchUsers();
  }, []); // Empty dependency array: run once on component mount

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

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

export default UserList;

Key Notes:

  • fetch() returns a Response object, not the actual data. Use .json() to parse it.
  • response.ok checks if the status code is 200-299 (success). Fetch does not throw errors for 4xx (client errors) or 5xx (server errors)—you must handle these manually.

Handling Different HTTP Methods

Fetch supports POST, PUT, DELETE, etc., by passing a method option in the configuration object.

Example: POST Request (Create Data)

const createUser = async (userData) => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users', {
      method: 'POST', // HTTP method
      headers: {
        'Content-Type': 'application/json', // Send JSON data
      },
      body: JSON.stringify(userData), // Convert JS object to JSON string
    });

    if (!response.ok) throw new Error('Failed to create user');
    const newUser = await response.json();
    console.log('User created:', newUser);
  } catch (err) {
    console.error('Error:', err);
  }
};

// Usage: createUser({ name: 'John Doe', email: '[email protected]' });

Example: PUT Request (Update Data)

const updateUser = async (userId, updatedData) => {
  try {
    const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(updatedData),
    });

    if (!response.ok) throw new Error('Failed to update user');
    const updatedUser = await response.json();
    console.log('User updated:', updatedUser);
  } catch (err) {
    console.error('Error:', err);
  }
};

Cleaning Up Requests (Avoid Memory Leaks)

If a component unmounts (e.g., user navigates away) before a fetch request completes, it can cause memory leaks. Use AbortController to cancel pending requests.

useEffect(() => {
  const controller = new AbortController(); // Create abort controller
  const signal = controller.signal; // Get abort signal

  const fetchUsers = async () => {
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users', { signal }); // Pass signal to fetch
      // ... rest of the logic ...
    } catch (err) {
      if (err.name === 'AbortError') {
        console.log('Request aborted (component unmounted)');
      } else {
        setError(err.message);
      }
    }
  };

  fetchUsers();

  // Cleanup: Abort request if component unmounts
  return () => controller.abort();
}, []);

3. Leveraging Axios for Simplified Requests

Axios is a popular third-party library for making HTTP requests. It simplifies common tasks like JSON parsing, error handling, and request/response interceptors.

Why Axios Over Fetch?

  • Automatic JSON Parsing: Axios parses JSON responses by default (no need for .json()).
  • Error Handling: Axios throws errors for 4xx/5xx status codes, making try/catch more effective.
  • Interceptors: Global request/response modification (e.g., adding auth tokens, handling 401 errors).
  • Request Cancellation: Built-in cancellation with CancelToken (or AbortController in newer versions).
  • Browser Support: Works in older browsers (unlike Fetch, which requires polyfills for IE).

Installing Axios

npm install axios
# or
yarn add axios

Basic Axios GET Request

import { useState, useEffect } from 'react';
import axios from 'axios';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        // Axios automatically parses JSON and throws errors for 4xx/5xx
        const response = await axios.get('https://jsonplaceholder.typicode.com/users');
        setUsers(response.data); // Response data is in response.data
      } catch (err) {
        setError(err.message || 'Failed to fetch users');
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []);

  // ... render logic (same as Fetch example) ...
}

Axios Interceptors

Interceptors let you modify requests/responses globally. For example, add an auth token to every request or handle 401 (unauthorized) errors by redirecting to login.

Example: Add Auth Token to All Requests

import axios from 'axios';

// Request interceptor: Add auth token to headers
axios.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('authToken'); // Get token from storage
    if (token) {
      config.headers.Authorization = `Bearer ${token}`; // Add token to headers
    }
    return config;
  },
  (error) => Promise.reject(error) // Handle request errors
);

// Response interceptor: Handle 401 errors
axios.interceptors.response.use(
  (response) => response, // Return successful response
  (error) => {
    if (error.response?.status === 401) {
      // Redirect to login if token is invalid/expired
      localStorage.removeItem('authToken');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

4. Managing API State with React Hooks

React hooks like useState and useEffect are the foundation for managing API state (data, loading, errors). To avoid repeating code across components, create custom hooks for reusable API logic.

Custom Hook: useFetch

A custom useFetch hook encapsulates fetching logic, making it reusable across components.

import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';

// Custom hook to fetch data from an API
function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Memoize fetch function to prevent infinite re-renders
  const fetchData = useCallback(async () => {
    try {
      setLoading(true);
      const response = await axios(url, options);
      setData(response.data);
      setError(null);
    } catch (err) {
      setError(err.message || 'Something went wrong');
      setData(null);
    } finally {
      setLoading(false);
    }
  }, [url, options]); // Re-run if url/options change

  useEffect(() => {
    fetchData();
  }, [fetchData]); // Run fetchData when it changes

  // Return data, loading, error, and a refetch function
  return { data, loading, error, refetch: fetchData };
}

// Usage in a component
function PostList() {
  const { data: posts, loading, error, refetch } = useFetch(
    'https://jsonplaceholder.typicode.com/posts'
  );

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error} <button onClick={refetch}>Retry</button></div>;

  return (
    <div>
      <h2>Posts ({posts?.length})</h2>
      <button onClick={refetch}>Refresh Posts</button>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

5. Advanced: Using React Query/SWR

For complex apps, libraries like React Query (now TanStack Query) or SWR simplify API state management with built-in caching, background refetching, and automatic retries.

Why React Query?

  • Caching: Stores fetched data to avoid redundant API calls.
  • Background Updates: Refetches data in the background to keep it fresh.
  • Dependent Queries: Fetch data based on previous query results (e.g., fetch user posts after fetching the user).
  • Pagination/Infinite Scrolling: Built-in support for loading more data.

Example: Fetch Data with React Query

  1. Install React Query:
npm install @tanstack/react-query
  1. Wrap your app with QueryClientProvider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient(); // Create a QueryClient

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your app components */}
    </QueryClientProvider>
  );
}
  1. Fetch data with useQuery:
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

// Define a function to fetch data
const fetchUsers = async () => {
  const response = await axios.get('https://jsonplaceholder.typicode.com/users');
  return response.data;
};

function UserList() {
  // Use useQuery to fetch and cache data
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'], // Unique key for caching
    queryFn: fetchUsers, // Function that returns data
  });

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

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

React Query automatically caches data under the queryKey (['users']), so subsequent calls to useQuery with the same key will return cached data instantly.

6. Authentication & Headers

Most APIs require authentication (e.g., JWT tokens). Here’s how to handle auth in API requests:

Adding Auth Tokens

  • Option 1: Include the token in request headers manually (not recommended for scalability).
  • Option 2: Use Axios interceptors (recommended) to add tokens globally (see Axios Interceptors).

Handling Token Expiry

If a token expires, the backend returns a 401 error. Use an Axios response interceptor to:

  1. Detect 401 errors.
  2. Clear the invalid token from storage.
  3. Redirect the user to the login page.

7. Error Handling Best Practices

Effective error handling ensures users are informed and developers can debug issues.

Types of Errors to Handle

  • Network Errors: No internet connection, DNS failure, etc. (e.g., TypeError: Failed to fetch).
  • HTTP Errors: 4xx (client errors like 404 Not Found) or 5xx (server errors like 500 Internal Server Error).
  • Validation Errors: Backend rejects data (e.g., missing fields in a form).

Displaying Errors in the UI

Show user-friendly messages instead of raw error details. For example:

// In a component
if (error) {
  return (
    <div className="error-alert">
      <p>Oops! {error.message === 'Failed to fetch' ? 'Check your internet connection.' : error.message}</p>
      <button onClick={refetch}>Try Again</button>
    </div>
  );
}

Logging Errors

Log errors to a service like Sentry for debugging:

catch (err) {
  setError(err.message);
  // Log to Sentry (or similar)
  if (process.env.NODE_ENV === 'production') {
    Sentry.captureException(err);
  }
}

8. Testing API Calls

To test components that make API requests, mock the API calls to avoid hitting real endpoints. Use tools like Jest and React Testing Library.

Example: Mocking Axios with Jest

import { render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import UserList from './UserList';

// Mock Axios
jest.mock('axios');

describe('UserList', () => {
  it('displays users after fetching', async () => {
    // Mock successful response
    axios.get.mockResolvedValue({
      data: [{ id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Doe' }],
    });

    render(<UserList />);

    // Check loading state
    expect(screen.getByText('Loading...')).toBeInTheDocument();

    // Wait for data to load and check users
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
      expect(screen.getByText('Jane Doe')).toBeInTheDocument();
    });
  });

  it('displays error on failure', async () => {
    // Mock error response
    axios.get.mockRejectedValue(new Error('Network Error'));

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText('Error: Network Error')).toBeInTheDocument();
    });
  });
});

9. Conclusion

Handling API requests in React requires a mix of native tools (Fetch API), libraries (Axios, React Query), and best practices (error handling, caching, cleanup). By following this guide, you’ll build React apps that communicate smoothly with backends, provide great user experiences, and are easy to maintain.

Key takeaways:

  • Use Fetch for simple cases, Axios for advanced features like interceptors.
  • Encapsulate API logic in custom hooks for reusability.
  • For complex apps, adopt React Query/SWR to handle caching and state.
  • Always handle loading states, errors, and request cleanup to avoid memory leaks.

10. References