Why is the useEffect hook used in fetching data in React?

joeskills

Code With Joseph

Posted on June 11, 2024

Why is the useEffect hook used in fetching data in React?

To make this as simple as possible, I'll avoid talking about Next.js. So you want to fetch data from a server? What's the first thing that comes to your mind? Create a function to handle the request. That makes sense. What could go wrong here?

import React, { useState } from 'react';

function FetchDataComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // Bad practice: Fetching data directly within the component rendering logic
  const fetchData = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      const data = await response.json();
      setData(data);
    } catch (error) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  };

  // Simulating a bad approach: Directly calling fetchData within the component rendering logic
  fetchData();

  return (
    <div className="fetch-data-container">
      <h1>Fetch Data Example</h1>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error}</p>}
      {data && (
        <div>
          <h2>{data.title}</h2>
          <p>{data.body}</p>
        </div>
      )}
    </div>
  );
}

export default FetchDataComponent;


Enter fullscreen mode Exit fullscreen mode

Back to React basics

I think this is a straightforward answer. Infinite loops! Because when the state of a component changes, the component and its children get re-rendered. Using a useState hook to modify the state without any constraints causes an infinite loop to occur.

Effects! Effects! Effects!

Effects should happen after the render phase of your component. Effects usually happen when the state of a component changes, the props of a component change, DOM manipulation, data fetching, and even user interaction causes effects to happen which causes a component to re-render. By using a useEffect hook, you make sure the effect happens after the initial render and also re-renders depending on what is placed in the dependency array (the second parameter) of the useEffect hook. This means if the dependency array is empty, it only runs once.

Add a useEffect hook, right?

Let's say I add a useEffect hook, we get controlled effects, and we can make sure the effects only occur once after the initial render. Plus, sometimes we only want to fetch data once.

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

function FetchDataComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        setData(data);
      } catch (error) {
        setError(error.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []); // Empty dependency array means this effect runs once after the initial render

  return (
    <div className="fetch-data-container">
      <h1>Fetch Data Example</h1>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error}</p>}
      {data && (
        <div>
          <h2>{data.title}</h2>
          <p>{data.body}</p>
        </div>
      )}
    </div>
  );
}

export default FetchDataComponent;

Enter fullscreen mode Exit fullscreen mode

But is something missing?

It can't be that simple. Can it? What happens when you want to change the data you fetched because of user interaction? A lot of the time, data isn't static in a React application. Let's assume we want to create a product listing app that fetches different items based on different categories.

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

const categories = ['Electronics', 'Clothing', 'Books'];

function ProductListComponent() {
  const [selectedCategory, setSelectedCategory] = useState(null);
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!selectedCategory) return;

    const fetchProducts = async () => {
      setLoading(true);
      setError(null);

      try {
        // Simulate fetching data from an API based on the selected category
        const response = await fetch(`https://api.example.com/products?category=${selectedCategory}`);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        setProducts(data);
      } catch (error) {
        setError(error.message);
      } finally {
        setLoading(false);
      }
    };

    fetchProducts();
  }, [selectedCategory]);

  return (
    <div className="product-list-container">
      <h1>Product List</h1>
      <div>
        {categories.map(category => (
          <button key={category} onClick={() => setSelectedCategory(category)}>
            {category}
          </button>
        ))}
      </div>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error}</p>}
      {products.length > 0 && (
        <ul>
          {products.map(product => (
            <li key={product.id}>
              <h2>{product.name}</h2>
              <p>Price: ${product.price}</p>
              <p>{product.description}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default ProductListComponent;

Enter fullscreen mode Exit fullscreen mode

useEffect can't provide everything

useEffect shines when you're trying to control effects in your app, but for data fetching, the concept doesn't reach far.

Loading & Error States

You must have noticed that I've been manually setting loading and error states. Which creates extra lines of code and complexity.


Race Conditions

A race condition occurs when multiple asynchronous tasks are trying to update the same value at the same time. Since there are three different buttons to fetch different data for each, a request to fetch one category might be slower than the other. Making your state unpredictable and inconsistent.


Example of a race condition

Let's assume a user clicks on the electronics button, but quickly changes it to the clothing button. The user clicked the clothing button last. The component gets re-rendered, but the previous fetch is still happening simultaneously with the new one. So clothing data should show up? If the request for fetching electronics data was slower than clothing, electronics should finish last. And that's the data that'll show up. This makes the state inconsistent, like the user didn't click the clothing button last.


How to fix a race condition

You can fix this issue by using a cleanup function in your useEffect and a boolean flag. This is a solution from the React official docs. The way this works is through closures. When the component is re-rendered, the cleanup function updates the state of the previous effect using the ignore boolean flag. You can also add AbortController and AbortSignal to prevent unnecessary network traffic from the user.

useEffect(() => {
  if (!selectedCategory) return;

  let ignore = true; // Flag to track component mount status

  const fetchProducts = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(`https://api.example.com/products?category=${selectedCategory}`);
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      const data = await response.json();
      if (ignore) {
        setProducts(data);
      }
    } catch (error) {
      if (ignore) {
        setError(error.message);
      }
    } finally {
      if (ignore) {
        setLoading(false);
      }
    }
  };

  fetchProducts();

  // Cleanup function to set the flag to false
  return () => {
    ignore = false;
  };
}, [selectedCategory]);


Enter fullscreen mode Exit fullscreen mode

Lack of caching

You have to manually cache your data for every request when using the useEffect hook to fetch data. Caching helps with a good user experience. Caching is important because it makes your app seem fast, let's assume a user clicks to another page and then goes back, without caching the user will see a loader (if you have one) again because the data was re-fetched again.

Fix for caching

We can use the browser's local storage to save the fetched data and use it when needed.

useEffect(() => {
    if (!selectedCategory) return;

    // Check if data is available in local storage
    const cachedData = localStorage.getItem(`products_${selectedCategory}`);
    if (cachedData) {
      setProducts(JSON.parse(cachedData));
      return;
    }

    let ignore = false; // Flag to track component unmount status

    const fetchProducts = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`https://api.example.com/products?category=${selectedCategory}`);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        if (!ignore) {
          setProducts(data);
          localStorage.setItem(`products_${selectedCategory}`, JSON.stringify(data)); // Save data to local storage
        }
      } catch (error) {
        if (!ignore) {
          setError(error.message);
        }
      } finally {
        if (!ignore) {
          setLoading(false);
        }
      }
    };

    fetchProducts();

    // Cleanup function to set the flag to true
    return () => {
      ignore = true;
    };
  }, [selectedCategory]);
Enter fullscreen mode Exit fullscreen mode

Refetches

In some apps, you might build, the data you fetch has to change constantly. To avoid it from getting stale, you need to perform background updates and re-fetches. The number of available products could change or the number of reviews could also change.

Let's add a quick fix

We can use an interval to re-fetch the data and then clear the interval in the cleanup function.

// UseEffect to fetch data when selectedCategory changes
  useEffect(() => {
    if (!selectedCategory) return;

    // Check if data is available in local storage
    const cachedData = localStorage.getItem(`products_${selectedCategory}`);
    if (cachedData) {
      setProducts(JSON.parse(cachedData));
    } else {
      fetchProducts(selectedCategory);
    }

    let ignore = false; // Flag to track component unmount status

    // Interval for refetching data periodically
    const intervalId = setInterval(() => {
      fetchProducts(selectedCategory, ignore);
    }, 60000); // Refetch every 60 seconds

    // Cleanup function to clear the interval and set the flag to true
    return () => {
      clearInterval(intervalId);
      ignore = true;
    };
  }, [selectedCategory]);
Enter fullscreen mode Exit fullscreen mode

Let's bring in React Query

From all these problems we weren't just talking about data fetching we were also talking about managing the state in the app properly. React Query isn't just for data fetching, it's an async state manager. It fixes all the bugs from the useEffect hook. It comes with caching, error & loading states, automatic query invalidation & re-fetches, and no race conditions. There are other data-fetching libraries like SWR, but React Query has better flexibility with its features, is more performant, and has a larger community.

How does it look like as a solution?

Well now, React Query fixes those bugs better and your code is more concise and easier to read. React Query manages the complexities of managing your state properly.

import React, { useState } from 'react';
import { useQueryClient, useQuery, useMutation } from 'react-query';

const categories = ['Electronics', 'Clothing', 'Books'];

function ProductListComponent() {
  const queryClient = useQueryClient();
  const [selectedCategory, setSelectedCategory] = useState(null);

  // Query to fetch products
  const fetchProducts = async (category) => {
    const response = await fetch(`https://api.example.com/products?category=${category}`);
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  };

  // Mutation to fetch products and invalidate the query
  const mutation = useMutation(fetchProducts, {
    onSuccess: (data, variables) => {
      // Invalidate and refetch
      queryClient.setQueryData(['products', variables], data);
      queryClient.invalidateQueries(['products', variables]);
    },
  });

  // Query to get products
  const { data: products, isLoading, isError } = useQuery(
    ['products', selectedCategory],
    () => fetchProducts(selectedCategory),
    {
      enabled: !!selectedCategory, // Only fetch when selectedCategory is truthy
      staleTime: 5 * 60 * 1000, // Cache data for 5 minutes
    }
  );

  // Handler for category selection
  const handleCategoryClick = (category) => {
    setSelectedCategory(category);
    mutation.mutate(category);
  };

  return (
    <div className="product-list-container">
      <h1>Product List</h1>
      <div>
        {categories.map(category => (
          <button key={category} onClick={() => handleCategoryClick(category)}>
            {category}
          </button>
        ))}
      </div>
      {isLoading && <p>Loading...</p>}
      {isError && <p>Error fetching data</p>}
      {products && (
        <ul>
          {products.map(product => (
            <li key={product.id}>
              <h2>{product.name}</h2>
              <p>Price: ${product.price}</p>
              <p>{product.description}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default ProductListComponent;

Enter fullscreen mode Exit fullscreen mode

At least now, you can see why the useEffect hook isn't the best thing when managing async state in your app. It might be okay for fetching data, but it doesn't have enough features to make state predictable. So you can fetch your data without React Query. But with React Query your code and your state become more maintainable.


Resources

https://dev.to/amrguaily/useeffect-some-issues-with-data-fetching-in-effects-21nn
https://dev.to/sakethkowtha/react-query-vs-useswr-122b
https://tkdodo.eu/blog/why-you-want-react-query
https://medium.com/@omar1.mayallo4/react-hooks-useeffect-problems-in-data-fetching-5e2abc37a1c9
https://www.youtube.com/watch?v=SYs5E4yrtpY


You can hear more from me on:
Twitter (X) | Instagram

💖 💪 🙅 🚩
joeskills
Code With Joseph

Posted on June 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related