Handling Asynchronous Data Fetching in React Beyond the Basics

react js react-query

Handling asynchronous data fetching in React


If you've ever fetched data in a React app, you've likely encountered a basic pattern like this:

const [id, setId] = useState(1);
 
useEffect(() => {
  fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
    .then((response) => response.json())
    .then((data) => setPokemon(data))
    .catch((error) => setError(error));
}, [id]);

This code fetches data from the PokemonAPI and displays it. While it's great for tutorials, you definitely wouldn’t want to use it in production unless you enjoy debugging at 3 AM. Let’s dive into some common pitfalls and how to avoid them.

1. Handling Loading and Error States

The initial example doesn’t handle loading and error states, leading to two major UX issues: cumulative layout shift and an infinite spinner. To address this, you should manage these states explicitly:

const [id, setId] = useState(1);
const [pokemon, setPokemon] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
 
useEffect(() => {
  setLoading(true);
  fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
    .then((response) => response.json())
    .then((data) => {
      setPokemon(data);
      setLoading(false);
    })
    .catch((error) => {
      setError(error);
      setLoading(false);
    });
}, [id]);

This approach ensures you can display appropriate feedback to the user while data is being fetched.

2. Avoiding Race Conditions

A tricky issue can occur when multiple asynchronous requests are made. For example, if you click a button rapidly twice to change the Pokémon ID, a race condition might happen. This can cause the UI to display incorrect data because your state with the id will be out of sync with the fetch call if the fetch for ID 1 comes second and the fetch for ID 2 comes first by happenstance.

Here how you can fix that:

useEffect(() => {
  let isMounted = true;
 
  setLoading(true);
  fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
    .then((response) => response.json())
    .then((data) => {
      if (isMounted) {
        setPokemon(data);
      }
      setLoading(false);
    })
    .catch((error) => {
      if (isMounted) {
        setError(error);
      }
      setLoading(false);
    });
 
  return () => {
    isMounted = false;
  };
}, [id]);

I've written a full blog explaining race conditions, and I highly recommend checking it out: Race Condition.

3. Abstracting Data Fetching Logic

If you try to push that code to production, someone will definitely ask, 'Can you just hook it up as a custom hook?' 🪝 and you'll finish with code like this:

const usePokemon = (id) => {
  const [id, setId] = useState(1);
  const [pokemon, setPokemon] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    let isMounted = true;
 
    setLoading(true);
    fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
      .then((response) => response.json())
      .then((data) => {
        if (isMounted) {
          setPokemon(data);
        }
        setLoading(false);
      })
      .catch((error) => {
        if (isMounted) {
          setError(error);
        }
        setLoading(false);
      });
 
    return () => {
      isMounted = false;
    };
  }, [id]);
 
  return { pokemon, loading, error };
};

This hook encapsulates the data fetching logic, making it reusable across different components.

4. Managing Data Duplication and State

Okay, after addressing all the previous issues, you might realize that another component also needs access to the same endpoint data. It wouldn't make sense to send another request to the backend just to get the same results. Remember, one of your key responsibilities as a software developer is to reduce unnecessary costs and inefficiencies in your code.

When multiple components fetch the same data, it not only creates redundant requests but can also lead to inconsistencies. To prevent this, you should create a context to share the data across multiple components. Alternatively, you could lift the state up to a common parent component or use context to efficiently manage and distribute the data.

import * as React from "react";
 
const queryContext = React.createContext([{}, () => {}]);
 
export function QueryProvider({ children }) {
  const tuple = React.useState({});
 
  return (
    <queryContext.Provider value={tuple}>{children}</queryContext.Provider>
  );
}
 
export default function useQuery(url) {
  const [state, setState] = React.useContext(queryContext);
 
  React.useEffect(() => {
    const update = (newState) =>
      setState((prevState) => ({
        ...prevState,
        [url]: { ...prevState[url], ...newState },
      }));
    let ignore = false;
 
    const handleFetch = async () => {
      update({ data: null, isLoading: true, error: null });
 
      try {
        const response = await fetch(url);
 
        if (ignore) {
          return;
        }
        if (res.ok === false) {
          throw new Error("Network response was not ok");
        }
 
        const data = await response.json();
 
        update({ data, isLoading: false, error: null });
      } catch (error) {
        update({ data: null, isLoading: false, error });
      }
    };
  }, [url]);
 
  return state[url] || { data: null, isLoading: true, error: null };
}

Are you still considering handling asynchronous data by yourself? I don't think so.

Before trying to solve a problem by myself, I always check if others have already solved it for us, and in most cases, they have. .

5. Leveraging React Query

To simplify data fetching and state management, consider using React Query. It efficiently handles caching, synchronization, and state updates. Look at how easy it is to fetch data without worrying about all the previous issues.

import { useQuery } from "@tanstack/react-query";
 
const fetchPokemon = async (id) => {
  const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
  if (!res.ok) throw new Error("Network response was not ok");
  return res.json();
};
 
const Pokemon = () => {
  const [id, setId] = useState(1);
 
  const { data, isLoading, error } = useQuery({
    queryKey: ["pokemon", id],
    queryFn: () => fetchPokemon(id),
  });
 
  if (isLoading) return "Loading...";
  if (error) return "An error has occurred: " + error.message;
 
  return <div>....</div>;
};
 
export default Pokemon;

React Query abstracts away much of the complexity involved in handling asynchronous data and state, allowing you to focus on building features.

Conclusion

Asynchronous data fetching in React requires careful consideration beyond just making API calls. To build reliable and efficient applications, it’s crucial to:

By addressing these areas, you'll not only improve the robustness of your applications but also enhance the overall user experience, making your React apps more performant and user-friendly.