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
- Why Centralized Error Handling?
- Basic Implementation
- Setting Up the Axios Instance with Interceptors
- Creating the Custom
useApi
Hook - Understanding Promises and Promise Rejection
- Organizing Service Modules
- Example: User Service
- Advanced Enhancements (Optional)
- Connecting Dots
- Visual Summary
- Putting It All Together: A Real-World Example
- Best Practices
- Further Reading
- Troubleshooting
- Environment Configuration
- Conclusion
- 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';
Step 2: Create the Axios Instance
const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
headers: {
'Content-Type': 'application/json',
},
});
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);
}
);
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;
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;
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;
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
: TheuseApi
hook's request function usestry-catch
to handle these rejections. WhenapiFunc(...args)
rejects, thecatch
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;
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,
};
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,
};
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;
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;
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;
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;
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:
-
Component
- The component initiates API requests using the
useApi
hook, which abstracts away the complexities of managing API calls, error handling, and loading states.
- The component initiates API requests using the
-
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.
-
-
Service Module
- The service module defines specific functions for each API endpoint (e.g.,
getProducts
,createProduct
) and uses the centralizedaxiosInstance
for all requests, ensuring consistency across the application.
- The service module defines specific functions for each API endpoint (e.g.,
-
Axios Instance
- The
axiosInstance
manages HTTP requests and responses, applying any custom configurations like base URLs and headers.
- The
-
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.
-
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.
- Interceptors display error messages to the user via
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;
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 thegetProducts
function fromproductService
. Thefalse
parameter indicates that the API call shouldn't happen immediately upon hook initialization.
- Initializes the
-
API Call Trigger:
- Uses
useEffect
to call therequest
function when the component mounts, fetching the first page of products.
- Uses
-
Error Handling:
- Another
useEffect
listens for changes in theerror
state. If an error occurs, it triggers a toast notification to inform the user.
- Another
-
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:
- Axios Interceptors Documentation: Learn how to use Axios interceptors to globally handle requests and responses.
- React Hooks Documentation: Understand the fundamentals of React Hooks for managing state and side effects.
- Redux Toolkit Introduction: Get started with Redux Toolkit for efficient state management in React applications.
- React-Toastify Documentation: Discover how to implement toast notifications for better user feedback.
Troubleshooting
1. Toast Notifications Not Appearing
-
Cause: The
<ToastContainer />
component fromreact-toastify
might be missing in your application. -
Solution: Ensure that
<ToastContainer />
is included in your main application component, typically inpages/_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;
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';
3. Redirects Not Working on Specific Errors
-
Cause: The
Router
fromnext/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 theuseApi
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
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',
},
});
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)
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!
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.
useEffect(() => {
request({ page: 1, limit: 10 });
}, [request]);
Could this provoke a loop?
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 theuseApi
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 theapiFunc
passed to theuseApi
hook changes. This ensures predictable behavior when used in auseEffect
dependency array.Key Reasons:
Stable
request
Reference:useApi
hook ensures that therequest
function does not change between renders unless theapiFunc
passed to it changes.Dependency Handling:
request
function is created once when the hook is initialized and persists across renders unlessapiFunc
changes.request
in theuseEffect
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:
Further Enhancement: Memoizing
request
To make the implementation even more robust and explicit, you can memoize the
request
function usinguseCallback
. While it doesn’t affect the current behavior, this adjustment ensures React optimization by explicitly stabilizing therequest
function.Here’s the updated
useApi
hook:Why Use
useCallback
?Although the current behavior works fine without memoization, using
useCallback
makes it explicit that therequest
function will not change unlessapiFunc
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!
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 addedapiFunc
as dependency. This is my version of const request: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:
Wouldn't be the param
apiFunc
a dependency sent to useApi by ProductList component?Thanks!
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! IncludingapiFunc
in the dependency array is absolutely correct becauseapiFunc
is indeed passed as a parameter to theuseApi
hook and can potentially change. This ensures React re-creates therequest
function only whenapiFunc
changes, keeping the behavior predictable.Here’s your code for reference:
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 theuseApi
hook by the parent component (ProductList
in this case). When I mentioned the sentence:What I meant was that
request
doesn’t directly depend on dynamic state or props of theProductList
component (beyondapiFunc
). The stability ofrequest
relies only on the stability ofapiFunc
. To clarify further:apiFunc
as a Dependency:apiFunc
is passed from the parent (ProductList
), sorequest
is indirectly dependent onProductList
's state/props ifapiFunc
changes.apiFunc
needs to be included in the dependency array ofuseCallback
to ensure the latest version ofapiFunc
is always used byrequest
.The Stability of
request
:request
itself doesn’t re-create unnecessarily as long asapiFunc
remains stable (e.g., no inline functions or re-definitions in the parent component).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:
request
function depends on theapiFunc
provided by the parent component. IfapiFunc
changes (e.g., when passed as an inline or dynamically defined function),request
is updated accordingly.ProductList
example),apiFunc
is defined as a stable reference within a service module. This ensuresapiFunc
doesn’t change unnecessarily, keepingrequest
stable and avoiding re-renders or loops.Key Takeaways:
apiFunc
in the dependency array, is correct and aligns with React's best practices.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. 😊
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.
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. 😊
So detailed, thanks