Code With Joseph
Posted on June 11, 2024
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;
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;
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;
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]);
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]);
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]);
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;
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
Posted on June 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.