DEV Community

Cover image for Building a Robust Frontend Error-Handling System with Axios and Custom Hooks
Riyon Sebastian
Riyon Sebastian

Posted on

Building a Robust Frontend Error-Handling System with Axios and Custom Hooks

Introduction

Effective error handling is crucial for providing a seamless user experience and maintaining clean, scalable code. In complex applications, managing errors manually across components often leads to cluttered and inconsistent code. This guide will show you how to build a modular, scalable, and centralized error-handling system in React using Axios, a custom hook (useApi), and modular service layers to create a user-friendly, organized, and efficient structure.

Note: This guide demonstrates a centralized, consistent structure for handling errors. While the approach offers a solid foundation for managing errors across an application, it’s essential to evaluate and adapt it based on specific production needs. Certain cases may benefit from context-specific handling (e.g., inline messages, modals) rather than relying solely on toast notifications. Use this structure as a starting point and customize it to fit the unique requirements of your application.

A Flexible Approach: There are many ways to handle errors in complex applications, and this is one of several effective approaches. I’ve chosen this method for its balance of modularity and scalability, which has proven beneficial across projects. I’ll walk you through the choices I made and why they work well for a variety of setups.

Hook: Why Centralized Error Handling Matters

Imagine you're building an e-commerce platform. Multiple components fetch data from various APIs, and each might fail for different reasons—network issues, server errors, or invalid user input. Without a centralized error-handling system, your code becomes cluttered with repetitive error checks, and users receive inconsistent feedback. How can you streamline this process to ensure reliability and a seamless user experience? This guide will show you how.

By the end, you’ll learn:

  • How to set up centralized error handling with Axios interceptors.
  • The role of a custom useApi hook for managing API request state.
  • The benefits of using service modules to organize API logic.
  • Advanced techniques for user-friendly error handling, including retry options and request cancellation.

Table of Contents

  1. Why Centralized Error Handling?
  2. Basic Implementation
  3. Setting Up the Axios Instance with Interceptors
  4. Creating the Custom useApi Hook
  5. Understanding Promises and Promise Rejection
  6. Organizing Service Modules
  7. Example: User Service
  8. Advanced Enhancements (Optional)
  9. Connecting Dots
  10. Visual Summary
  11. Putting It All Together: A Real-World Example
  12. Best Practices
  13. Further Reading
  14. Troubleshooting
  15. Environment Configuration
  16. Conclusion
  17. Call to Action

Why Centralized Error Handling?

Centralized error handling addresses two common challenges:

Repetitive Error Code

  • Issue: Without a central mechanism, components rely on multiple try-catch blocks.
  • Impact: Leads to inconsistent error handling and redundant code.

Inconsistent User Experience

  • Issue: Error messages may vary across the app without centralization.
  • Impact: Creates a disjointed user experience and can confuse users.

Using a centralized approach with Axios interceptors, a custom hook (useApi), and service modules solves these issues by:

  • Single Location for Error Parsing and Messaging: Provides a unified place to handle all errors, ensuring consistency.
  • Separation of Concerns: Allows components to focus purely on data presentation and user interaction, leaving error handling to centralized modules.

Basic Implementation

Setting Up the Axios Instance with Interceptors

Axios interceptors are functions that Axios calls for every request or response. By setting up response interceptors, you can globally handle errors, parse responses, and perform actions like logging or redirecting users based on specific conditions.

Step 1: Import Necessary Modules

// utils/axiosInstance.js
import axios from 'axios';
import ERROR_MESSAGES from '../config/customErrors';
import { toast } from 'react-toastify';
import Router from 'next/router';
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Axios Instance

const axiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
  headers: {
    'Content-Type': 'application/json',
  },
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Add a Response Interceptor

axiosInstance.interceptors.response.use(
  (response) => response, // Pass through successful responses
  (error) => {
    if (!error.response) {
      toast.error(ERROR_MESSAGES.NETWORK_ERROR);
      return Promise.reject(error);
    }

    const { status, data } = error.response;
    let message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR;

    // Custom logic for specific error types
    if (data?.type === 'validation') {
      message = `Validation Error: ${data.message}`;
    } else if (data?.type === 'authentication') {
      message = `Authentication Error: ${data.message}`;
    }

    // Display error notification
    toast.error(message);

    // Handle unauthorized access by redirecting to login
    if (status === 401) {
      Router.push('/login');
    }

    return Promise.reject(error);
  }
);
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Error Parsing: The interceptor checks if the error has a response. If not, it assumes a network error and displays a corresponding message.
  • Custom Error Messages: It attempts to use custom error messages based on the error type provided by the server. If none are available, it falls back to predefined messages.
  • User Feedback: Utilizes react-toastify to show toast notifications, enhancing user experience by providing immediate feedback.
  • Redirection: Redirects users to the login page if a 401 Unauthorized error occurs, ensuring security by preventing unauthorized access.

Step 4: Export the Axios Instance

export default axiosInstance;
Enter fullscreen mode Exit fullscreen mode

Custom Error Messages

Define your custom error messages in a separate configuration file to maintain consistency and ease of management.

// config/customErrors.js

const ERROR_MESSAGES = {
  NETWORK_ERROR: "Network error. Please check your connection and try again.",
  BAD_REQUEST: "There was an issue with your request. Please check and try again.",
  UNAUTHORIZED: "You are not authorized to perform this action. Please log in.",
  FORBIDDEN: "Access denied. You don't have permission to view this resource.",
  NOT_FOUND: "The requested resource was not found.",
  GENERIC_ERROR: "Something went wrong. Please try again later.",

  // You can add more messages here if you want
};

export default ERROR_MESSAGES;
Enter fullscreen mode Exit fullscreen mode

Quick Summary: Axios Interceptor Configuration

Setting up Axios interceptors provides:

  • Centralized Error Parsing: Manages errors in one place, ensuring consistency across all API requests.
  • User Feedback: Utilizes react-toastify to notify users about issues immediately.
  • Redirection and Security: Redirects unauthorized users to login when needed, keeping the app secure.

This centralized Axios instance is key to building a reliable, user-friendly API communication layer that ensures consistent management of all API requests and error handling across your application.

Creating the Custom useApi Hook

The useApi hook centralizes API request handling, managing loading, data, and error states. By abstracting this process, useApi allows components to avoid repetitive try-catch blocks and focus on data presentation.

Parameters:

  • apiFunc (Function): The API function to execute, typically from service modules.
  • immediate (Boolean, optional): Determines whether the API call should be made immediately upon hook initialization. Defaults to false.

Returned Values:

  • data: The response data from the API call.
  • loading: Indicates whether the API call is in progress.
  • error: Captures any error messages from failed API calls.
  • request: The function to initiate the API call, which can be called with necessary parameters.

Implementation:

// hooks/useApi.js
import { useState } from 'react';

const useApi = (apiFunc, immediate = false) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(immediate);
  const [error, setError] = useState(null);

  const request = async (...args) => {
    setLoading(true);
    setError(null);
    try {
      const result = await apiFunc(...args);
      setData(result.data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

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

export default useApi;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • State Management:
    • data: Stores the response data.
    • loading: Indicates if the API call is in progress.
    • error: Captures any error messages.
  • request Function:
    • Initiates the API call.
    • Manages state updates based on the API call's outcome.

Understanding Promises and Promise Rejection

Before diving deeper, it's essential to understand Promises and Promise Rejection in JavaScript, as they play a pivotal role in handling asynchronous operations like API calls.

  • Promises: A Promise is an object representing the eventual completion or failure of an asynchronous operation. It allows you to attach callbacks to handle the success (resolve) or failure (reject) of the operation.
  • Promise Rejection: When an operation fails, a Promise is "rejected," triggering the .catch method or the second parameter in .then.

Relevance in Axios and useApi:

  • Axios and Promises: Axios uses Promises to handle HTTP requests. When you make a request using Axios, it returns a Promise that resolves with the response data or rejects with an error.
  • Promise Rejection in Axios Interceptors: In the Axios interceptor, when an error occurs, the interceptor rejects the Promise using Promise.reject(error). This rejection propagates to where the API call was made.
  • Catching Rejections in useApi: The useApi hook's request function uses try-catch to handle these rejections. When apiFunc(...args) rejects, the catch block captures the error, updating the error state accordingly.

Importance of Handling Promise Rejections:

  • Prevent Unhandled Rejections: If Promise rejections aren't handled, they can lead to unexpected behaviors and make debugging difficult.
  • Consistent Error Management: By centralizing the handling of Promise rejections, you ensure that all errors are managed uniformly, enhancing code reliability and user experience.

What If useApi Hook Isn't Used?

Without the useApi hook, you would need to implement try-catch blocks in every component that makes an API call. This approach leads to:

  • Repetitive Code: Each component would have similar error-handling logic, increasing code redundancy.
  • Inconsistent Error Handling: Different components might handle errors differently, leading to an inconsistent user experience.
  • Increased Maintenance Effort: Updating error-handling logic would require changes across multiple components, making maintenance cumbersome.

By using the useApi hook, you abstract away the repetitive error-handling logic, promoting cleaner and more maintainable code.

Example Usage:

// src/components/ProductList.js
import React, { useEffect } from 'react';
import productService from '../services/productService';
import useApi from '../hooks/useApi';

const ProductList = () => {
  const { data, loading, error, request } = useApi(productService.getProducts, false);

  useEffect(() => {
    request({ page: 1, limit: 10 });
  }, [request]);

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

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {data?.data.map((product) => (
        <div key={product._id} className="border rounded p-4">
          <img src={product.images[0]} alt={product.name} className="w-full h-48 object-cover mb-4" />
          <h2 className="text-lg font-semibold">{product.name}</h2>
          <p className="text-gray-700">${product.price}</p>
        </div>
      ))}
    </div>
  );
};

export default ProductList;
Enter fullscreen mode Exit fullscreen mode

In this example, the useApi hook manages the API call to fetch products. It handles loading states, captures any errors, and provides the fetched data to the component for rendering.


Organizing Service Modules

Service modules define API endpoint functions, organized by entity (e.g., users, products). This structure keeps API logic separate from component code, ensuring modularity and reuse.

Example: Product Service

// services/productService.js
import axiosInstance from "../utils/axiosInstance";

const getProducts = (params) => axiosInstance.get('/api/products', { params });
const getProductById = (id) => axiosInstance.get(`/api/products/${id}`);
const createProduct = (data) => axiosInstance.post('/api/products', data);

export default {
  getProducts,
  getProductById,
  createProduct,
};
Enter fullscreen mode Exit fullscreen mode

Example: User Service

// services/userService.js
import axiosInstance from "../utils/axiosInstance";

const getUsers = (params) => axiosInstance.get('/api/users', { params });
const getUserById = (id) => axiosInstance.get(`/api/users/${id}`);
const createUser = (data) => axiosInstance.post('/api/users', data);

export default {
  getUsers,
  getUserById,
  createUser,
};
Enter fullscreen mode Exit fullscreen mode

Benefits of Service Modules:

  • Enable Reuse and Modularity: API functions can be reused across multiple components, reducing code duplication.
  • Ensure Separation of Concerns: Keeps components clean by moving API logic into services, improving code organization and maintainability.
  • Easier Testing: Service modules can be independently tested, ensuring that API interactions work as expected before integrating them into components.

Advanced Enhancements (Optional)

For those ready to take their error-handling system further, consider implementing these advanced techniques:

Error Parsing Customization

Categorize errors (e.g., network vs. validation) and provide actionable messages to help users understand issues and possible solutions.

Implementation:

// utils/axiosInstance.js
import axios from 'axios';
import ERROR_MESSAGES from '../config/customErrors';
import { toast } from 'react-toastify';
import Router from 'next/router';

const axiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
  headers: {
    'Content-Type': 'application/json',
  },
});

axiosInstance.interceptors.response.use(
  (response) => response,
  (error) => {
    if (!error.response) {
      toast.error(ERROR_MESSAGES.NETWORK_ERROR);
      return Promise.reject(error);
    }

    const { status, data } = error.response;
    let message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR;

    // Custom logic for specific error types
    if (data?.type === 'validation') {
      message = `Validation Error: ${data.message}`;
    } else if (data?.type === 'authentication') {
      message = `Authentication Error: ${data.message}`;
    }

    toast.error(message);

    if (status === 401) {
      Router.push('/login');
    }

    return Promise.reject(error);
  }
);

export default axiosInstance;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Error Categorization: The interceptor checks the type property in the error response to determine the nature of the error (e.g., validation or authentication).
  • Actionable Messages: Provides users with specific error messages based on the error type, enhancing their understanding and ability to respond appropriately.

Retry Mechanism

Implement retry options for failed requests, such as a retry button in the UI or automatic retries with exponential backoff, to enhance reliability.

Implementation:

// utils/axiosInstance.js
import axios from 'axios';
import axiosRetry from 'axios-retry';
import ERROR_MESSAGES from '../config/customErrors';
import { toast } from 'react-toastify';
import Router from 'next/router';

const axiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
  headers: {
    'Content-Type': 'application/json',
  },
});

// Configure axios-retry
axiosRetry(axiosInstance, {
  retries: 3, // Number of retry attempts
  retryDelay: (retryCount) => retryCount * 1000, // Exponential backoff
  retryCondition: (error) => {
    return axiosRetry.isNetworkOrIdempotentRequestError(error) || error.response.status === 500;
  },
  onRetry: (retryCount, error, requestConfig) => {
    console.log(`Retrying request to ${requestConfig.url} (${retryCount}/3)`);
  },
});

axiosInstance.interceptors.response.use(
  (response) => response,
  (error) => {
    if (!error.response) {
      toast.error(ERROR_MESSAGES.NETWORK_ERROR);
      return Promise.reject(error);
    }

    const { status, data } = error.response;
    let message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR;

    // Custom logic for specific error types
    if (data?.type === 'validation') {
      message = `Validation Error: ${data.message}`;
    } else if (data?.type === 'authentication') {
      message = `Authentication Error: ${data.message}`;
    }

    toast.error(message);

    if (status === 401) {
      Router.push('/login');
    }

    return Promise.reject(error);
  }
);

export default axiosInstance;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Retries: Configures Axios to retry failed requests up to three times with an exponential backoff strategy.
  • Retry Conditions: Retries occur for network errors, idempotent requests, or when the server responds with a 500 Internal Server Error.
  • Logging Retries: Logs each retry attempt, which can be useful for debugging and monitoring.

Detailed Notifications

Differentiate notifications by severity (info, warning, error) to help users understand the importance of the error.

Implementation:

// utils/axiosInstance.js
import axios from 'axios';
import ERROR_MESSAGES from '../config/customErrors';
import { toast } from 'react-toastify';
import Router from 'next/router';

const axiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
  headers: {
    'Content-Type': 'application/json',
  },
});

axiosInstance.interceptors.response.use(
  (response) => response,
  (error) => {
    if (!error.response) {
      toast.error(ERROR_MESSAGES.NETWORK_ERROR);
      return Promise.reject(error);
    }

    const { status, data } = error.response;
    let message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR;
    let notificationType = 'error'; // Default notification type

    // Custom logic for specific error types
    if (data?.type === 'validation') {
      message = `Validation Error: ${data.message}`;
      notificationType = 'warning';
    } else if (data?.type === 'authentication') {
      message = `Authentication Error: ${data.message}`;
      notificationType = 'error';
    } else if (status === 404) {
      message = ERROR_MESSAGES.NOT_FOUND;
      notificationType = 'info';
    }

    // Display error notification with appropriate severity
    toast[notificationType](message);

    if (status === 401) {
      Router.push('/login');
    }

    return Promise.reject(error);
  }
);

export default axiosInstance;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Notification Types: Determines the type of toast notification (info, warning, error) based on the error category.
  • User Feedback: Provides users with context-specific feedback, helping them understand the severity and nature of the issue.

Cancel Requests on Component Unmount

Use Axios cancel tokens to prevent memory leaks by canceling ongoing requests if a component unmounts.

Implementation:

// hooks/useApi.js
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';

const useApi = (apiFunc, immediate = false) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(immediate);
  const [error, setError] = useState(null);
  const cancelTokenSource = useRef(null);

  const request = async (...args) => {
    setLoading(true);
    setError(null);

    // Cancel any previous request
    if (cancelTokenSource.current) {
      cancelTokenSource.current.cancel('Operation canceled due to new request.');
    }

    cancelTokenSource.current = axios.CancelToken.source();

    try {
      const result = await apiFunc(...args, {
        cancelToken: cancelTokenSource.current.token,
      });
      setData(result.data);
    } catch (err) {
      if (axios.isCancel(err)) {
        console.log('Request canceled:', err.message);
      } else {
        setError(err.message);
      }
    } finally {
      setLoading(false);
    }
  };

  // Cleanup on component unmount
  useEffect(() => {
    return () => {
      if (cancelTokenSource.current) {
        cancelTokenSource.current.cancel('Component unmounted.');
      }
    };
  }, []);

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

export default useApi;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Cancel Tokens: Utilizes Axios cancel tokens to cancel ongoing API requests when a new request is made or when the component unmounts.
  • Prevent Memory Leaks: Ensures that no state updates occur on unmounted components, preventing potential memory leaks.
  • Logging Canceled Requests: Logs canceled requests for debugging purposes.

Advanced Enhancements Summary

Implementing these advanced techniques takes your error-handling system to the next level:

  • Error Parsing Customization: Delivers more specific error messages to users, helping them understand and address issues directly.
  • Retry Mechanism: Improves reliability by allowing requests to retry automatically or manually after certain errors.
  • Detailed Notifications: Differentiates between error types, showing notifications based on severity to better inform users.
  • Cancel Requests on Component Unmount: Prevents memory leaks and redundant requests, ensuring a stable and efficient app performance.

These enhancements are optional but highly valuable as they add depth, flexibility, and user-focused improvements to your app’s error-handling approach.


Connecting Dots

When a component initiates an API call through useApi, the following flow is triggered:

Components Use Service Modules:

Each service module (e.g., userService, productService) defines functions for specific API endpoints and uses the configured axiosInstance. Components interact only with these service functions.

useApi Triggers Axios via the Service Module:

Components pass a service function (e.g., productService.getProducts) to useApi. When request is called, useApi forwards the function to the service, which then makes the HTTP request through axiosInstance.

Custom Error Parsing in Axios Interceptors:

The interceptors in axiosInstance handle error logging, parse predefined custom error messages, and centralize error handling.

Structured Responses from useApi:

useApi returns structured states (data, loading, and error) to the component, allowing it to focus solely on presenting data and handling interactions.


Visual Summary

The following outline describes how each component in the error-handling system interacts within the application, from the initial API call to user feedback:

  1. Component

    • The component initiates API requests using the useApi hook, which abstracts away the complexities of managing API calls, error handling, and loading states.
  2. useApi Hook

    • useApi is a custom hook that receives the function for the API request (from the service module). It manages the request’s loading state, handles errors, and returns structured responses (data, loading, error) back to the component.
  3. Service Module

    • The service module defines specific functions for each API endpoint (e.g., getProducts, createProduct) and uses the centralized axiosInstance for all requests, ensuring consistency across the application.
  4. Axios Instance

    • The axiosInstance manages HTTP requests and responses, applying any custom configurations like base URLs and headers.
  5. API Response

    • The response from the API is processed through Axios interceptors, which handle errors globally. This includes parsing custom error messages and triggering user notifications.
  6. Error Handling & User Notifications

    • Interceptors display error messages to the user via react-toastify notifications, and they can manage additional actions, like redirecting users to the login page on authentication errors.

This flow enables centralized error management and consistent user feedback, allowing components to focus solely on presenting data without needing to handle repetitive error-checking logic.


Putting It All Together: A Real-World Example

ProductList Component

This example demonstrates the entire flow, from making an API call to displaying data, with centralized error handling and feedback.

// ProductList component
import React, { useEffect } from 'react';
import { toast } from 'react-toastify';
import productService from '../services/productService';
import useApi from '../hooks/useApi';

const ProductList = () => {
  // Initialize useApi with the getProducts service function
  const { data, loading, error, request } = useApi(productService.getProducts, false);

  // Trigger the API request when the component mounts
  useEffect(() => {
    request({ page: 1, limit: 10 });
  }, [request]);

  // Display error notification if an error occurs
  useEffect(() => {
    if (error) {
      toast.error(error);
    }
  }, [error]);

  // Show loading state
  if (loading) return <p>Loading products...</p>;

  // Render the list of products
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {data?.data.map((product) => (
        <div key={product._id} className="border rounded p-4">
          <img src={product.images[0]} alt={product.name} className="w-full h-48 object-cover mb-4" />
          <h2 className="text-lg font-semibold">{product.name}</h2>
          <p className="text-gray-700">${product.price}</p>
        </div>
      ))}
    </div>
  );
};

export default ProductList;
Enter fullscreen mode Exit fullscreen mode

Component Breakdown:

  • Import Statements:

    • react-toastify: Used for displaying toast notifications.
    • productService: Contains API functions related to products.
    • useApi: Custom hook for managing API calls.
  • Hook Initialization:

    • Initializes the useApi hook with the getProducts function from productService. The false parameter indicates that the API call shouldn't happen immediately upon hook initialization.
  • API Call Trigger:

    • Uses useEffect to call the request function when the component mounts, fetching the first page of products.
  • Error Handling:

    • Another useEffect listens for changes in the error state. If an error occurs, it triggers a toast notification to inform the user.
  • Conditional Rendering:

    • Loading State: Displays a loading message while the API call is in progress.
    • Error State: Shows an error message if the API call fails.
    • Data Rendering: Once data is successfully fetched, it renders a grid of products with their images, names, and prices.

This example demonstrates how centralized error handling simplifies component logic and ensures consistent user feedback.


Best Practices

Adhering to best practices ensures that your error-handling system is efficient, maintainable, and user-friendly.

Separation of Concerns

  • Description: Keep API logic separate from UI components by using service modules. This improves code organization and maintainability.
  • Example: Instead of making API calls directly within components, delegate them to service modules like productService.js.

Consistent Error Messaging

  • Description: Handle all errors uniformly to simplify debugging and provide a seamless user experience.
  • Example: Using predefined error messages in customErrors.js ensures that users receive consistent feedback regardless of where the error originates.

Avoid Repetitive Code

  • Description: Use centralized error handling and custom hooks to eliminate repetitive try-catch blocks across components.
  • Example: The useApi hook manages error states and notifications, allowing components to focus solely on rendering data.

Meaningful Error Messages

  • Description: Provide user-friendly, actionable error messages to improve understanding and reduce frustration.
  • Example: Instead of displaying generic messages like "Error occurred," use specific messages such as "Validation Error: Please enter a valid email address."

Handle Edge Cases

  • Description: Anticipate and manage unexpected scenarios, such as network failures or server errors, to prevent your application from crashing.
  • Example: The Axios interceptor handles network errors by displaying a "Network error" toast and preventing the application from breaking.

Secure Error Handling

  • Description: Avoid exposing sensitive information in error messages. Provide user-friendly messages while logging detailed errors securely on the server.
  • Example: Display generic error messages to users while sending detailed error logs to a logging service like Sentry for developers.

Further Reading

Enhance your understanding of the concepts covered in this guide with the following resources:


Troubleshooting

1. Toast Notifications Not Appearing

  • Cause: The <ToastContainer /> component from react-toastify might be missing in your application.
  • Solution: Ensure that <ToastContainer /> is included in your main application component, typically in pages/_app.js.
// pages/_app.js
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Component {...pageProps} />
      <ToastContainer />
    </>
  );
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

2. API Requests Not Caught by Interceptors

  • Cause: Service modules might be importing the default Axios library instead of the centralized axiosInstance.
  • Solution: Ensure that all service modules import the centralized axiosInstance.
// Correct Import
import axiosInstance from '../utils/axiosInstance';

// Incorrect Import
import axios from 'axios';
Enter fullscreen mode Exit fullscreen mode

3. Redirects Not Working on Specific Errors

  • Cause: The Router from next/router might not be correctly imported or used outside of React components.
  • Solution: Ensure that the Axios interceptor is used in a context where Router can perform redirects. Alternatively, handle redirects within the useApi hook or within components.

Environment Configuration

Managing different environments ensures that your application interacts with the correct API endpoints during development, testing, and production.

Step 1: Define Environment Variables

Create a .env.local file in your project's root directory and define your API base URL:

NEXT_PUBLIC_API_BASE_URL=https://api.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

Step 2: Access Environment Variables in Code

Ensure that your Axios instance uses the environment variable:

const axiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
  headers: {
    'Content-Type': 'application/json',
  },
});
Enter fullscreen mode Exit fullscreen mode

Best Practices:

  • Secure Sensitive Information: Never commit .env.local files or any sensitive environment variables to your version control system. Use .gitignore to exclude these files.
  • Consistent Naming Conventions: Use clear and consistent naming for environment variables, typically prefixed with NEXT_PUBLIC_ to indicate they are exposed to the frontend.
  • Separate Configuration for Each Environment: Maintain separate .env files for different environments (e.g., .env.development, .env.production) to manage configurations effectively.

Conclusion

By building a centralized error-handling system, you’ve set up a clean, modular, and user-friendly structure that improves both developer experience and user satisfaction. Whether you're just starting or looking to enhance your app’s error management, this approach provides a solid foundation that can grow with your application.

Encourage yourself to experiment with the features described here, starting with the basics and adding enhancements as you grow comfortable. A centralized approach to error handling is a valuable skill and practice that will pay off as your application scales.


Call to Action

Ready to enhance your React/Next.js applications with centralized error handling?

Implement the strategies outlined in this guide to experience cleaner code, consistent user notifications, and improved maintainability.

Share Your Feedback

Have questions, suggestions, or experiences to share? Engage with the community by leaving comments or reaching out on GitHub and Twitter.

Stay Tuned for More

I'm working on developing an npm package based on this centralized error-handling system. Stay tuned for updates, or suggest features that you'd find valuable!

Happy Coding! 🚀✨

Top comments (9)

Collapse
 
itsmeseb profile image
sebkolind • Edited

It’s an interesting take on handling errors. However, I don’t see how all errors in a real world app could be handled in a Toast, tbh.

If we, at work, should have all our error states in a Toast, that would quickly become pretty bad UX wise.

You might also have very different API’s, which handle errors differently, since it might be multiple teams working on the endpoints

But, all in all, thanks for this write!

Collapse
 
riyon_sebastian profile image
Riyon Sebastian

Thank you for your feedback! I completely agree that handling every error via toast notifications wouldn’t be ideal in a real-world application.

In production apps, there’s definitely a need to balance error visibility with usability. For example, while toast notifications can be helpful for transient issues (like network errors or minor feedback), more persistent or critical errors might need to be handled differently—such as showing inline messages, redirecting users, or displaying modal dialogs for certain cases.

As you mentioned, different APIs (and even different teams) might have unique error-handling requirements. The structure here is flexible, so it can be adapted based on the error type or API. We could add logic within useApi or the Axios interceptor to route errors to various UI components depending on context or severity, rather than defaulting to toast notifications.

Thank you for highlighting this! I’m glad you found the article useful, and I’ll consider adding a section about tailoring error handling to different scenarios.

Collapse
 
prgbono profile image
Paco Ríos

useEffect(() => {
request({ page: 1, limit: 10 });
}, [request]);

Could this provoke a loop?

Collapse
 
riyon_sebastian profile image
Riyon Sebastian

Good observation! Thankfully, in this implementation, the request function is stable and does not cause a re-render loop.

Why It Won’t Cause a Loop

The request function is defined inside the useApi hook, but it doesn’t depend on any values from the parent component. It is directly returned to the consuming component and remains stable because there are no dependencies or reassignments that could cause it to be re-created during subsequent renders.

How React Treats request

React treats the request function as a stable reference unless the apiFunc passed to the useApi hook changes. This ensures predictable behavior when used in a useEffect dependency array.

Key Reasons:

  1. Stable request Reference:

    • The useApi hook ensures that the request function does not change between renders unless the apiFunc passed to it changes.
    • This means React won’t treat the function as a “new dependency,” avoiding any infinite re-render loops.
  2. Dependency Handling:

    • The request function is created once when the hook is initialized and persists across renders unless apiFunc changes.
    • When you include request in the useEffect dependency array, React checks its reference for changes. Since it is stable, the effect runs only once (on component mount).

Here’s an example from the code:

useEffect(() => {
  // Trigger the API call for the first page of products
  // 'request' is stable because it's returned from the useApi hook.
  // This ensures the effect only runs once when the component mounts.
  request({ page: 1, limit: 10 });
}, [request]);
Enter fullscreen mode Exit fullscreen mode

Further Enhancement: Memoizing request

To make the implementation even more robust and explicit, you can memoize the request function using useCallback. While it doesn’t affect the current behavior, this adjustment ensures React optimization by explicitly stabilizing the request function.

Here’s the updated useApi hook:

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

const useApi = (apiFunc, immediate = false) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(immediate);
  const [error, setError] = useState(null);
  const cancelTokenSource = useRef(null);

  const request = useCallback(async (...args) => {
    setLoading(true);
    setError(null);

    // Cancel any ongoing requests before making a new one
    if (cancelTokenSource.current) {
      cancelTokenSource.current.cancel('Operation canceled due to new request.');
    }

    cancelTokenSource.current = axios.CancelToken.source();

    try {
      const result = await apiFunc(...args, {
        cancelToken: cancelTokenSource.current.token,
      });
      setData(result.data);
    } catch (err) {
      if (axios.isCancel(err)) {
        console.log('Request canceled:', err.message);
      } else {
        setError(err.message);
      }
    } finally {
      setLoading(false);
    }
  }, [apiFunc]);

  useEffect(() => {
    // Cleanup on component unmount
    return () => {
      if (cancelTokenSource.current) {
        cancelTokenSource.current.cancel('Component unmounted.');
      }
    };
  }, []);

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

export default useApi;
Enter fullscreen mode Exit fullscreen mode

Why Use useCallback?

Although the current behavior works fine without memoization, using useCallback makes it explicit that the request function will not change unless apiFunc changes. This extra layer of clarity is beneficial in collaborative projects or codebases with multiple contributors, ensuring predictable behavior.


Let me know if this clears up the concern or if there’s anything more you’d like to discuss! Thanks again for raising such a thoughtful question!

Collapse
 
prgbono profile image
Paco Ríos • Edited

First of all thanks for such a great article @riyon_sebastian. I spent a couple of hours yesterday implementing it and have to say it was quite fun. Actually, I'll convert to typescript shortly.

Thanks also for such a detailed reply. I completely agree with the use of useCallback to avoid infinitive loop. Indeed, that was my solution yesterday though I also added apiFunc as dependency. This is my version of const request:

const request = useCallback(
    async (...args) => {
      setLoading(true)
      setError(null)

      if (cancelTokenSource.current) {
        cancelTokenSource.current.cancel(
          'Operation canceled due to new request.'
        )
      }

      cancelTokenSource.current = axios.CancelToken.source()

      try {
        const result = await apiFunc(...args, {
          cancelToken: cancelTokenSource.current.token
        })
        setData(result.data)
      } catch (err) {
        if (axios.isCancel(err)) {
          console.log('Request canceled:', err.message)
        } else {
          setError(err.message)
        }
      } finally {
        setLoading(false)
      }
    },
    [apiFunc]
  )
Enter fullscreen mode Exit fullscreen mode

Notice the array of dependencies including apiFunc. This approach works properly for me.

Said this, I don't understand when you say in the section Why It Won’t Cause a Loop of you reply, this sentence:

The request function is defined inside the useApi hook, but it doesn’t depend on any values from the parent component...

Wouldn't be the param apiFunc a dependency sent to useApi by ProductList component?

Thanks!

Thread Thread
 
riyon_sebastian profile image
Riyon Sebastian

Thank you so much for your kind words! I'm thrilled that you not only enjoyed the article but also spent time implementing the solution and adapting it to your needs. Transitioning it to TypeScript is a great idea—it’ll provide additional type safety and make the implementation even more robust.

Regarding Your Implementation:

Your version of request is spot-on! Including apiFunc in the dependency array is absolutely correct because apiFunc is indeed passed as a parameter to the useApi hook and can potentially change. This ensures React re-creates the request function only when apiFunc changes, keeping the behavior predictable.

Here’s your code for reference:

const request = useCallback(
  async (...args) => {
    setLoading(true);
    setError(null);

    if (cancelTokenSource.current) {
      cancelTokenSource.current.cancel(
        'Operation canceled due to new request.'
      );
    }

    cancelTokenSource.current = axios.CancelToken.source();

    try {
      const result = await apiFunc(...args, {
        cancelToken: cancelTokenSource.current.token
      });
      setData(result.data);
    } catch (err) {
      if (axios.isCancel(err)) {
        console.log('Request canceled:', err.message);
      } else {
        setError(err.message);
      }
    } finally {
      setLoading(false);
    }
  },
  [apiFunc] // Ensures request is re-created only when apiFunc changes
);
Enter fullscreen mode Exit fullscreen mode

This aligns perfectly with React’s dependency management principles and avoids any unnecessary re-renders or stale references.


Addressing the Confusion in My Explanation:

You’re absolutely correct to point out that apiFunc is passed to the useApi hook by the parent component (ProductList in this case). When I mentioned the sentence:

The request function is defined inside the useApi hook, but it doesn’t depend on any values from the parent component...

What I meant was that request doesn’t directly depend on dynamic state or props of the ProductList component (beyond apiFunc). The stability of request relies only on the stability of apiFunc. To clarify further:

  1. apiFunc as a Dependency:

    • Yes, apiFunc is passed from the parent (ProductList), so request is indirectly dependent on ProductList's state/props if apiFunc changes.
    • This is why apiFunc needs to be included in the dependency array of useCallback to ensure the latest version of apiFunc is always used by request.
  2. The Stability of request:

    • My earlier statement focused on the fact that request itself doesn’t re-create unnecessarily as long as apiFunc remains stable (e.g., no inline functions or re-definitions in the parent component).
    • If apiFunc is stable, request remains stable too, which is why no infinite loop occurs.

Revised Explanation:

To better address this point, here's an updated clarification:

  • The request function depends on the apiFunc provided by the parent component. If apiFunc changes (e.g., when passed as an inline or dynamically defined function), request is updated accordingly.
  • However, in many cases (like the ProductList example), apiFunc is defined as a stable reference within a service module. This ensures apiFunc doesn’t change unnecessarily, keeping request stable and avoiding re-renders or loops.

Key Takeaways:

  1. Your implementation, including apiFunc in the dependency array, is correct and aligns with React's best practices.
  2. The confusion arose from the way I phrased the explanation in my original reply. I’ll revise that part to ensure clarity for other readers.

Thank you again for catching this detail and sharing your insights! It’s comments like these that improve the overall quality of the discussion and the content itself.

Let me know if you have more questions or feedback! I'm happy to discuss this further. 😊

Thread Thread
 
prgbono profile image
Paco Ríos

Thanks again @riyon_sebastian for your detailed reply and also for rising such an interesting topic.
I have learnt a lot from your article and you can count on one plus follower of your future articles in here ;)
Just for your info, here you have your glorious error-handling-system in TypeScript. Please let me know if you don't want to be mentioned.

Thread Thread
 
riyon_sebastian profile image
Riyon Sebastian

Thank you so much for your kind words and support!

I’m absolutely thrilled that you found the article useful and were able to learn something new from it. The fact that you took the time to implement it and even convert it to TypeScript is incredibly motivating.

I truly appreciate you sharing the GitHub link—it’s fantastic to see this concept in TypeScript! I’d love to check it out, and being mentioned is an honor. Your feedback and enthusiasm really inspire me to keep creating and sharing.

Thank you for being a part of this journey, and I look forward to more amazing exchanges like this. Let me know if there’s anything else you’d like to explore or discuss. 😊

Collapse
 
viettl profile image
viettl

So detailed, thanks