Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 83

Handling Errors and Loading States

Recommended resources

Hello! Congratulations on building a complete, functional full-stack task manager. You've successfully implemented everything from the database schema and REST API to a multi-page React frontend with secure user authentication. This is a massive achievement!

However, so far, we've mostly focused on the "happy path"—what happens when everything works as expected. A professional, production-ready application must also gracefully handle the "unhappy paths"—when networks fail, servers return errors, or data takes a moment to load.

In this lesson, we will focus on that final layer of polish to ensure a robust and professional user experience. We'll implement comprehensive error handling on both our Express backend and React frontend, and add loading indicators to give users clear feedback while your application is working.

By the end of this lesson, you will be able to ensure robust error handling and loading states for a professional user experience by:

  • Implementing a centralized, global error handler in your Express API.
  • Adding loading and error states to your React components that fetch data.
  • Connecting the backend error responses to the frontend UI to display meaningful messages to the user.
  • Using conditional rendering to show loading indicators, error messages, or the final data.

Part 1: Robust Backend Error Handling

Currently, if an error occurs in one of your Express route handlers (e.g., a database error or an invalid request), it might crash your server or return an ugly, unhelpful HTML error page. We can fix this by creating a single, centralized "global error handling" middleware.

The Concept: Centralized Error Handling

The idea is simple: instead of putting try...catch blocks in every single route handler, we'll create a special middleware that Express will automatically jump to whenever an error occurs anywhere in our application. This keeps our code clean and gives us one place to define how all errors are logged and formatted for the client.

Global Error Handling in Express.js: A Complete Guide

This article, "Global Error Handling in Express.js: A Complete Guide", provides an excellent overview of why this pattern is so powerful. It perfectly captures the shift from scattered try/catch blocks to a clean, centralized solution.

Read the first two sections, "The Pitfalls of ‘Try/Catch Everything’" and "Understanding next(err) in Express.js". Focus on understanding the core concept: calling next() with an argument tells Express to skip all other middleware and go straight to the error handler.

Implementation: Creating the Global Error Handler

Now let's see how to build this middleware. An Express error-handling middleware is unique because it has four parameters: (err, req, res, next).

#91 Global Error Handling Middleware | Error Handling in Express | A Complete NODE JS Course

This video from procademy provides a clear, step-by-step guide to creating and using a global error-handling middleware.

Watch the following segments to implement the middleware: Creating the Middleware (03:32 - 07:37): Pay close attention to the function signature (err, req, res, next). The video explains how this signature is what tells Express it's an error handler. Follow along to create a middleware that sends a structured JSON response, using the error's status code and message if they exist, or defaulting to a 500 Internal Server Error. Triggering the Middleware (07:37 - 11:53): This part is crucial. It shows how to create a custom error object and pass it to next(error). This is the mechanism we'll use to send errors from our route handlers to the global handler.

Based on the video, let's create a new file middleware/errorHandler.js in your backend project:

// middleware/errorHandler.js

const errorHandler = (err, req, res, next) => {
  // If the error has a statusCode, use it, otherwise default to 500 (Internal Server Error)
  const statusCode = err.statusCode || 500;

  // If the error has a message, use it, otherwise provide a generic message
  const message = err.message || 'Internal Server Error';

  // In development, it's useful to log the full error
  if (process.env.NODE_ENV === 'development') {
    console.error(err.stack);
  }

  res.status(statusCode).json({
    success: false,
    message: message,
  });
};

export default errorHandler;

Now, you must register this as the very last middleware in your main server file (index.js or app.js). This placement is critical.

// In your main server file (e.g., index.js)
import express from 'express';
import taskRoutes from './routes/taskRoutes.js';
import authRoutes from './routes/authRoutes.js';
import errorHandler from './middleware/errorHandler.js'; // Import the handler

const app = express();

// ... other middleware like express.json(), cors(), etc.

// ... your routes
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);

// Global Error Handler - MUST BE LAST
app.use(errorHandler);

// ... app.listen(...)

Simplifying Async Routes with catchAsync

To avoid writing try...catch in every async controller, we can use a simple utility function. This is a great example of the 80/20 principle—a small piece of code that provides huge value.

The article "Global Error Handling in Express.js" provides a perfect implementation of this.

Global Error Handling in Express.js: A Complete Guide

Let's return to the article and grab the catchAsync utility. This will make our route handlers much cleaner.

Read the section "Example Flow: Putting It All Together" and copy the catchAsync function into a new utility file in your backend, for example, utils/catchAsync.js.

Your utils/catchAsync.js should look like this:

// utils/catchAsync.js
export default function catchAsync(fn) {
  return function (req, res, next) {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

Now, you can wrap your async route handlers with catchAsync. It automatically catches any promise rejections and passes the error to next(), which triggers our global handler.

Before:

// controllers/taskController.js
export const getTasks = async (req, res, next) => {
  try {
    const tasks = await Task.findAll({ where: { userId: req.user.id } });
    res.status(200).json(tasks);
  } catch (error) {
    next(error); // Manually passing the error
  }
};

After:

// routes/taskRoutes.js
import { getTasks } from '../controllers/taskController.js';
import catchAsync from '../utils/catchAsync.js';

router.get('/', protectRoute, catchAsync(getTasks)); // Wrap the controller here

// controllers/taskController.js
export const getTasks = async (req, res, next) => {
  // No more try/catch needed!
  const tasks = await Task.findAll({ where: { userId: req.user.id } });
  res.status(200).json(tasks);
};

Go ahead and apply the catchAsync wrapper to all your async route handlers. Your code is now much cleaner and more robust!


Part 2: Frontend Loading and Error States

On the frontend, user experience is paramount. Users should never see a blank or broken page. They need feedback: "Is the app working?" and "Did something go wrong?". We'll achieve this by managing loading and error states in our React components.

Managing Component State for API Calls

The core idea is to use useState to track the lifecycle of an API request.

  1. isLoading: Is the request in progress?
  2. data: If successful, what's the response?
  3. error: If it failed, what was the error?

This video provides a perfect, practical demonstration.

Full React Tutorial #19 - Handling Fetch Errors

The Net Ninja's tutorial on handling fetch errors is a concise masterclass in frontend state management for API calls. We'll follow its logic closely.

Follow along with the entire video. It covers all the essential steps: Adding State (05:17): See how isPending (we'll call it isLoading) and error states are added to the component. Handling Network Errors (00:02): The video shows how .catch() (or a try...catch block with async/await) can capture network failures when the server is unreachable. Handling Server Errors (01:56): This is a key point! The fetch API doesn't automatically throw an error for HTTP statuses like 404 or 500. You must check the response.ok property and manually throw an error to trigger your catch block. Updating the UI (05:17 & 06:30): Observe how the isLoading and error states are used with conditional rendering to show a loading message, an error message, or the data.

Let's apply this to a component in your task manager, like the one that fetches and displays the list of tasks.

import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';

const TaskList = () => {
  const [tasks, setTasks] = useState([]);
  const [isLoading, setIsLoading] = useState(true); // Start as true on initial load
  const [error, setError] = useState(null);
  const { token } = useAuth(); // Assuming your auth context provides the token

  useEffect(() => {
    const fetchTasks = async () => {
      // Reset states for subsequent fetches
      setError(null);
      setIsLoading(true);

      try {
        const response = await fetch('/api/tasks', {
          headers: {
            'Authorization': `Bearer ${token}`
          }
        });

        // This is the crucial check for server-side errors
        if (!response.ok) {
          const errorData = await response.json(); // Get error message from our backend
          throw new Error(errorData.message || 'Failed to fetch tasks');
        }

        const data = await response.json();
        setTasks(data);

      } catch (err) {
        // This catches both network errors and the thrown error above
        setError(err.message);
      } finally {
        // This runs whether the try or catch block completes
        setIsLoading(false);
      }
    };

    if (token) {
      fetchTasks();
    }
  }, [token]);

  // Conditional Rendering based on state
  if (isLoading) {
    return <p>Loading tasks...</p>;
  }

  if (error) {
    return <p style={{ color: 'red' }}>Error: {error}</p>;
  }

  return (
    <div>
      {tasks.length > 0 ? (
        <ul>
          {tasks.map(task => (
            <li key={task.id}>{task.title}</li>
          ))}
        </ul>
      ) : (
        <p>No tasks found. Create one!</p>
      )}
    </div>
  );
};

export default TaskList;
Test your understanding!

Why is the finally block a good place to put setIsLoading(false)?

Show answer

The finally block executes regardless of whether the try block completed successfully or the catch block was triggered. This guarantees that the loading state is always turned off once the asynchronous operation is finished, preventing the UI from getting stuck in a permanent "Loading..." state if an error occurs.

Polishing the UX: Skeleton Loaders

A simple "Loading..." message is good, but for a truly professional feel, you can use skeleton loaders. These are animated placeholders that mimic the shape of the content that is about to appear. They make the application feel faster and more responsive.

Skeleton Loader with React and Material UI
This image shows the concept of a skeleton loader. On the left is the placeholder UI shown during loading, and on the right is the actual content once it has loaded. This provides a much better user experience than a blank screen or a simple text message.

Implementing a basic skeleton loader can be done with simple HTML and CSS. For your task list, you could create a TaskSkeleton component:

// components/TaskSkeleton.js
const TaskSkeleton = () => (
  <div style={{
    backgroundColor: '#e0e0e0',
    borderRadius: '4px',
    height: '40px',
    marginBottom: '10px',
    // You can add a subtle animation here with CSS
  }}></div>
);

// In your TaskList component, when loading:
if (isLoading) {
  return (
    <div>
      <TaskSkeleton />
      <TaskSkeleton />
      <TaskSkeleton />
    </div>
  );
}

This gives the user a visual cue of the structure of the content they are waiting for.


Conclusion

In this crucial lesson, you've added a layer of professionalism and robustness to your capstone project that separates a student project from a production-ready application.

Key Takeaways:

  • Backend: A centralized global error-handling middleware in Express keeps your code clean and provides consistent, secure error responses.
  • Frontend: Managing isLoading and error states is essential for building a responsive user interface that provides clear feedback.
  • Connection: The frontend's error state is powered by the structured JSON errors sent from the backend, creating a seamless error-handling experience.
  • UX Polish: Simple additions like skeleton loaders can significantly improve the perceived performance and professionalism of your application.

Preview of the Next Module:
Your full-stack application is now feature-complete, robust, and user-friendly. There's only one step left: sharing it with the world! In the next and final module, Deployment and Production Best Practices, you will learn how to prepare your application for a production environment, deploy it to a cloud platform, and manage critical aspects like environment variables and CORS.