Skip to main content

Welcome to a Grasp lesson

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

Lesson 43

Managing Async Data States

Recommended resources

Hello! Welcome to our next lesson on handling side effects in React.

In our last session, you mastered the useEffect dependency array, gaining precise control over when your API calls run. We can now fetch data when a component mounts and re-fetch it whenever a user's input changes.

However, our component is still not very user-friendly. While the fetch request is in progress, the user sees a blank screen. If the request fails, the app silently breaks without any explanation. Today, we'll fix that. Your goal is to implement loading and error states for asynchronous data fetching operations. By adding this logic, you'll provide clear feedback to the user, dramatically improving the application's perceived performance and robustness.

1. The Three States of an API Request

From a user's perspective, any component that fetches data can be in one of three states:

  1. Loading: The request has been sent, and we are waiting for a response. The UI should indicate that something is happening (e.g., show a spinner or a "Loading..." message).
  2. Error: The request failed, either due to a network issue or an error from the server (like a 404 "Not Found"). The UI should inform the user that something went wrong.
  3. Success: The request completed successfully, and we have the data. The UI can now render the data as intended.

To manage these states in React, we'll use the useState hook. We'll need three pieces of state: one for the data itself, one for the loading status, and one for any potential errors.

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); // Start in a loading state
const [error, setError] = useState(null);

Our component's rendering logic will then check these state variables in a specific order to decide what to show the user.


2. Handling Errors

Network requests are inherently unreliable. The API server might be down, the user's internet could disconnect, or the requested resource might not exist. A robust application must anticipate and handle these failures gracefully.

We can capture errors using the .catch() block of a Promise chain. It's also crucial to handle non-network errors, such as when the server responds with an HTTP status code like 404 (Not Found) or 500 (Internal Server Error). The fetch API doesn't treat these as network errors, so the promise doesn't automatically reject. We must check the response status manually and throw an error to trigger the .catch() block.

Let's see how to implement this.

Fetching Data In React

The following article from The Odin Project provides a clear, practical example of adding an error state to a data-fetching component. It shows how to use .catch() and how to check the response status.

Please read the section titled 'Handling errors'. It walks through adding an error state variable and using the .catch() block to update it. Pay close attention to the part about checking response.status inside the .then() block.

As you saw in the resource, the pattern involves two key steps:

  1. Capturing the error:
    .catch((error) => setError(error));
    
  2. Conditionally rendering an error message:
    if (error) {
      return <p>A network error was encountered</p>;
    }
    

By setting the error state, you trigger a re-render, and your component can display a helpful message instead of crashing or showing a blank page.


3. Indicating a Loading State

Just as important as handling errors is letting the user know when the application is busy. Without a loading indicator, a slow network can make your app feel frozen or broken.

The process is similar to handling errors: we use a state variable, loading, to track whether a request is in flight.

The best place to manage the loading state is by setting it to true just before you initiate the fetch, and setting it back to false once the fetch is complete, regardless of whether it succeeded or failed. The .finally() block of a Promise is perfect for this, as it executes after the promise is settled (either fulfilled or rejected).

Fetching Data In React

The same article also has an excellent section on implementing a loading state. It introduces the .finally() method, which is the ideal tool for this job.

Now, read the section 'Loading state'. It demonstrates how to add a loading state variable and use .finally() to ensure it's always set to false after the request finishes.


4. Putting It All Together with async/await

Now let's combine these concepts into a complete component. We'll use async/await syntax, which you're aiming to master. The try...catch...finally block provides a very readable equivalent to the .then().catch().finally() chain.

The order of your conditional rendering logic is important for a correct user experience:

  1. Check for loading.
  2. Check for error.
  3. If neither of the above, render the data.

Here is a complete example of a component that fetches data from an API. It's set to loading initially, handles potential errors, and finally displays the data.

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

function PokemonViewer() {
  const [pokemon, setPokemon] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchPokemon = async () => {
      try {
        // We don't need to reset loading/error here since it's only called once.
        const response = await fetch("https://pokeapi.co/api/v2/pokemon/ditto");

        if (!response.ok) {
          // This will be caught by the catch block
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const data = await response.json();
        setPokemon(data);

      } catch (e) {
        setError(e);
      } finally {
        // This runs whether the request succeeds or fails
        setLoading(false);
      }
    };

    fetchPokemon();
  }, []); // Empty dependency array means this runs once on mount

  // 1. Render loading state
  if (loading) {
    return <div>Loading Pokémon...</div>;
  }

  // 2. Render error state
  if (error) {
    return <div>Error fetching Pokémon: {error.message}</div>;
  }
  
  // 3. Render success state
  return (
    <div>
      <h1>{pokemon.name}</h1>
      <img src={pokemon.sprites.front_default} alt={`Sprite of ${pokemon.name}`} />
    </div>
  );
}

This component provides a complete and professional user experience for a single data-fetching operation. The user is always informed about the current status of the application.


Conclusion

You've now learned how to create a robust data-fetching experience in React. By managing loading and error states, you make your application more communicative, reliable, and user-friendly.

Key Takeaways:

  • A data-fetching component exists in one of three states: loading, error, or success.
  • Use useState to create boolean loading and object error state variables.
  • The try...catch...finally structure in an async/await function is ideal for managing these states:
    • try: The "happy path" where you await your fetch and set data.
    • catch: Handles any network or thrown errors by updating the error state.
    • finally: Cleans up by setting loading to false, regardless of the outcome.
  • The order of conditional rendering is crucial: first check if (loading), then if (error), and finally render the successful result.

Preview of the Next Lesson:
Our data-fetching logic—including the three state variables and the useEffect hook—is quite effective, but it's also a lot of boilerplate code to write in every component that needs data. In our next lesson, we will learn how to create a reusable custom hook to encapsulate this logic. This will allow us to fetch data in any component with a single line of code, making our codebase much cleaner and more maintainable.