5 min read

Data fetching with useEffect - why you should go straight to react-query, even for simple apps

Data fetching with useEffect is easy to use and great for quick one-off or practice apps.
I've used it myself for a long time - but recently, I've come to the conclusion it's best to just use react-query, even for the simplest cases.
Why? Because to use it well it takes so much boilerplate, it's not really worth.
Here's what I mean.

A basic example

Let's take a super simple example - fetching a list of pokemons from the Poke API.
The user can input how many pokemon to load and the value is passed as the limit when fetching the data:

The basic code for this using useEffect is as follows:

import { useState, useEffect } from "react";

export default function App() {
  const [pokemon, setPokemon] = useState([]);
  const [limit, setLimit] = useState(2);
  useEffect(() => {
    async function fetchPokemon() {
      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon?limit=${limit}`
      );
      const data = await response.json();
      setPokemon(data.results);
    }

    fetchPokemon();
  }, [limit]);

  return (
    <div className="App">
      <h1>Hello Poke API!</h1>
      <div>
        <p>Please enter how many pokemon to load</p>
        <input
          type="number"
          name="limit"
          value={limit}
          onChange={(e) => setLimit(e.target.value)}
        />
      </div>
      <ul>
        {pokemon.map((p) => (
          <li key={p.name}>{p.name}</li>
        ))}
      </ul>
    </div>
  );
}

It's quite straightforward, right? But it's actually buggy!
The docs point this out - "avoid: fetching without cleanup logic":

The problem with the basic case

With effects, you still need to respect the rule of not breaking the app if you run the effect twice (which is checked by default in development mode with StrictMode).

So, even if it's ok to use useEffect for data fetching, you should make sure that the request that "is not relevant anymore does not keep affecting your application". You can do this with an ignore flag:


Why is this a problem? Because if you don't ignore previous requests, you have no control over which requests finishes first - and your risk having the app show the results of the first request instead of the second, even if the state changed! This is called a race condition and the official docs have a great example over here.

Here's how our updated example would look like:

import { useState, useEffect } from "react";

export default function App() {
  const [pokemon, setPokemon] = useState([]);
  const [limit, setLimit] = useState(2);
  useEffect(() => {
    let ignore = false;

    async function fetchPokemon() {
      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon?limit=${limit}`
      );
      const data = await response.json();
      if (!ignore) {
        setPokemon(data.results);
      }
    }

    fetchPokemon();

    return () => {
      ignore = true;
    };
  }, [limit]);

  return // same as before ....

This works, but you can see how the code is getting a bit harder to read.

Handling loading and error states

With the basic flow in place, we can now add logic to handle loading and error states.
For testing this, I recommend simulating a slower Internet connection, so you can actually see the loading indicator! You can do this by setting the Chrome DevTools "Throttling" setting to "3G".

import { useState, useEffect } from "react";

export default function App() {
  const [pokemon, setPokemon] = useState([]);
  const [limit, setLimit] = useState(2);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;

    async function fetchPokemon() {
      setIsLoading(true);
      try {
        const response = await fetch(
          `https://pokeapi.co/api/v2/pokemon?limit=${limit}`
        );
        const data = await response.json();
        if (!ignore) {
          setPokemon(data.results);
          setIsLoading(false);
        }
      } catch (e) {
        if (!ignore) {
          setError(e.message);
          setPokemon([]);
          setIsLoading(false);
        }
      }
    }

    fetchPokemon();

    return () => {
      ignore = true;
    };
  }, [limit]);

  return (
    <div className="App">
      <h1>Hello Poke API!</h1>
      <div>
        <p>Please enter how many pokemon to load</p>
        <input
          type="number"
          name="limit"
          value={limit}
          onChange={(e) => setLimit(e.target.value)}
        />
      </div>
      {isLoading && <p style={{ color: "blue" }}>Loading ...</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
      <ul>
        {pokemon.map((p) => (
          <li key={p.name}>{p.name}</li>
        ))}
      </ul>
    </div>
  );
}

You can see how we already have more than 50 lines a code for a simple fetch!

We could extract this into a custom hook to keep things simple.

The useFetch hook

The official docs also suggest using a custom hook if you really want to just fetch data by yourself:

With a custom hook, this is how our code would look:

import { useState, useEffect } from "react";

const useFetch = (url) => {
  const [data, setData] = useState();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;

    async function fetchData() {
      setIsLoading(true);
      try {
        const response = await fetch(url);
        const data = await response.json();
        if (!ignore) {
          setData(data);
          setIsLoading(false);
        }
      } catch (e) {
        if (!ignore) {
          setError(e.message);
          setData([]);
          setIsLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      ignore = true;
    };
  }, [url]);

  return { data, isLoading, error };
};

export default function App() {
  const [limit, setLimit] = useState(2);

  const { data, isLoading, error } = useFetch(
    `https://pokeapi.co/api/v2/pokemon?limit=${limit}`
  );
  const pokemon = data?.results || [];

  return (
    <div className="App">
      <h1>Hello Poke API!</h1>
      <div>
        <p>Please enter how many pokemon to load</p>
        <input
          type="number"
          name="limit"
          value={limit}
          onChange={(e) => setLimit(e.target.value)}
        />
      </div>
      {isLoading && <p style={{ color: "blue" }}>Loading ...</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
      <ul>
        {pokemon.map((p) => (
          <li key={p.name}>{p.name}</li>
        ))}
      </ul>
    </div>
  );
}

Much nicer, and it's now also reusable!

But was it really worth it? A lot of boilerplate and we don't even have caching!
If we use this hook in two different components, the data will be fetched twice, instead of read from a cache.

This is why I suggest using react-query by default.

With react-query

The same example, with react-query, would be as simple as:

import { useState } from "react";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const PokemonList = () => {
  const [limit, setLimit] = useState(2);

  const { isLoading, error, data } = useQuery({
    queryKey: ["fetchPokemon", limit],
    queryFn: () =>
      fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}`).then((res) =>
        res.json()
      ),
  });
  const pokemon = data?.results || [];

  return (
    <div className="App">
      <h1>Hello Poke API!</h1>
      <div>
        <p>Please enter how many pokemon to load</p>
        <input
          type="number"
          name="limit"
          value={limit}
          onChange={(e) => setLimit(e.target.value)}
        />
      </div>
      {isLoading && <p style={{ color: "blue" }}>Loading ...</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
      <ul>
        {pokemon.map((p) => (
          <li key={p.name}>{p.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <PokemonList />
    </QueryClientProvider>
  );
}

Notice how we have built in loading and error states, caching and more!


I hope this article highlighted why I think it's no longer worth it to fetch data with useEffect, even for simple apps.

You can try out all the examples in this article in this code sandbox: https://codesandbox.io/p/sandbox/6vr69l.

Get the React Practice Calendar!

28 days of focused practice of increasing difficulty, going through everything from Fundamentals, Data fetching, Forms and using Intervals in React.

You will also get notified whenever a new challenge is published.