The status quo
Lets start with a simple data-fetching use case. We’ll fetch and render a list of products. The most common approach at the moment is to use fetch
inside of a useEffect
hook.
const Products = () => {
const [products, setProducts] = useState([]);
useEffect(() => {
const fetchProducts = async () => {
const responseData = await fetch(getApiURL("products")).then((response) =>
response.json()
);
setProducts(responseData.products);
};
fetchProducts();
}, []);
return (
<div>
<h1>Our Products</h1>
{products.map((product) => {
return <Product key={product.id} product={product} />;
})}
</div>
);
};
This works great for small cases in the happy path. However, requests aren’t guaranteed to succeed so we need to take care of error handling.
Lets set up an error state and render a fallback message when loading products fail.
const Products = () => {
const [products, setProducts] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const fetchProducts = async () => {
try {
const responseData = await fetch(getApiURL("products")).then(
(response) => response.json()
);
setProducts(responseData.products);
} catch {
setError("Could not load products at the moment");
}
};
fetchProducts();
}, []);
return (
<div>
<h1>Our Products</h1>
{error && <ErrorMessage>{error}</ErrorMessage>}
{products.map((product) => {
return (
<ProductLink
key={product.id}
image={product.image}
name={product.name}
price={product.price}
/>
);
})}
</div>
);
};
Great, now that that’s taken care of let’s show a loading skeleton while the products are loading.
const Products = () => {
const [products, setProducts] = useState([]);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchProducts = async () => {
setIsLoading(true);
try {
const responseData = await fetch(getApiURL("products")).then(
(response) => response.json()
);
setProducts(responseData.products);
} catch {
setError("Could not load products at the moment");
}
setIsLoading(false);
};
fetchProducts();
}, []);
return (
<div>
<h1>Our Products</h1>
{isLoading ? <ProductsSkeleton /> : null}
{error && <ErrorMessage>{error}</ErrorMessage>}
{products.map((product) => {
return (
<ProductLink
key={product.id}
image={product.image}
name={product.name}
price={product.price}
/>
);
})}
</div>
);
};
Nice! lets take a look at the product details page. We will get the productId
from the URL and fetch its details.
const ProductDetails = () => {
const { productId } = useParams();
const [productDetails, setProductDetails] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchProductDetails = async () => {
setIsLoading(true);
try {
const responseData = await fetch(
getApiURL(`products/${productId}`)
).then((response) => response.json());
setProductDetails(responseData.productDetails);
} catch {
setError(`Could not load product with id ${productId} at the moment`);
}
setIsLoading(false);
};
}, [productId]);
if (isLoading) {
return <ProductDetailsSkeleton />;
}
if (error) {
return <ErrorMessage>{error}</ErrorMessage>;
}
return (
<div>
<h1>{productDetails.name}</h1>
<img src={productDetails.image} alt={productDetails.description} />
<p>{productDetails.description}</p>
<p>{productDetails.price}</p>
// This component makes a network request to another service for fetching product reviews.
// To keep it short we won't implement it but it should follow the same
// pattern for fetching and handling error and loading states.
<ProductReviews productId={productDetails.id} />
</div>
);
};
As you can see, there is a lot of duplication going on to fetch and handle loading and error states. Every time we need to set up the effect, try/catch block, loading, and error states.
A common approach to solve this duplication is to create reusable hooks which encapsulate the repetitive logic.
const useProducts = () => {
const { productId } = useParams();
const [products, setProducts] = useState([]);
const [productDetails, setProductDetails] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const fetchProducts = useCallback(async () => {
setIsLoading(true);
try {
const responseData = await fetch(getApiURL("products")).then((response) =>
response.json()
);
setProducts(responseData.products);
} catch {
setError("Could not load products at the moment");
}
setIsLoading(false);
}, []);
const fetchProductDetails = useCallback(async () => {
setIsLoading(true);
try {
const responseData = await fetch(getApiURL(`products/${productId}`)).then(
(response) => response.json()
);
setProductDetails(responseData.productDetails);
} catch {
setError(`Could not load product with id ${productId} at the moment`);
}
setIsLoading(false);
}, [productId]);
useEffect(() => {
if (productId) {
fetchProductDetails();
} else {
fetchProducts();
}
}, [productId, fetchProductDetails, fetchProducts]);
return {
products,
productDetails,
isLoading,
error,
};
};
A lot is going on here but in a nutshell, this hook will load either the product details or the products list based on the page being viewed.
const Products = () => {
const { products, isLoading, error } = useProducts();
return (
<div>
<h1>Our Products</h1>
{error && <ErrorMessage>{error}</ErrorMessage>}
{isLoading ? <ProductsSkeleton /> : null}
{products.map((product) => {
return (
<ProductLink
key={product.id}
image={product.image}
name={product.name}
price={product.price}
/>
);
})}
</div>
);
};
const ProductDetails = () => {
const { productDetails, isLoading, error } = useProducts();
if (!productDetails && (!isLoading || !error)) {
return null;
}
return (
<div>
<h1>{productDetails.name}</h1>
<img src={productDetails.image} alt={productDetails.description} />
<p>{productDetails.description}</p>
<p>{productDetails.price}</p>
<ProductReviews productId={productDetails.id} />
</div>
);
};
As you can see, this greatly simplifies our components and removes the need for duplication. However, the hook state is local to each instance, and going back and forth between the product listing and details pages would trigger new requests every time, even though the data hasn’t changed.
This is when usually we reach out for some sort of a global state or store where we can save the data we fetched and access it from different parts of the app. Lets refactor our useProducts
hook to work with a store.
const useProducts = () => {
const { productId } = useParams();
const setProducts = useStore((store) => store.setProducts);
const products = useStore((store) => store.products);
const setProductDetails = useStore((store) => store.productDetails);
const productDetails = useStore((store) =>
store.fetchedProductDetails.find((product) => product.id === productId)
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const fetchProducts = useCallback(async () => {
if (products.length > 0) {
return;
}
setIsLoading(true);
try {
const responseData = await fetch(getApiURL("products")).then((response) =>
response.json()
);
setProducts(responseData.products);
} catch {
setError("Could not load products at the moment");
}
setIsLoading(false);
}, [productDetails]);
const fetchProductDetails = useCallback(async () => {
if (productDetails) {
return;
}
setIsLoading(true);
try {
const responseData = await fetch(getApiURL(`products/${productId}`)).then(
(response) => response.json()
);
setProductDetails(responseData.productDetails);
} catch {
setError(`Could not load product with id ${productId} at the moment`);
}
setIsLoading(false);
}, [productId, productDetails]);
useEffect(() => {
if (productId) {
fetchProductDetails();
} else {
fetchProducts();
}
}, [productId, fetchProductDetails, fetchProducts]);
return {
products,
productDetails,
isLoading,
error,
};
};
Great! Our store is acting as a cache layer and we are only requesting data when we don’t already have it.
A custom hook is not enough
Looking fresh! Not.
This simple cache implementation introduces the problem of stale data. Only the initially fetched products would be displayed and any new products or updates will require a full page refresh.
Abort, abort
Even though we extracted duplicated logic into a reusable hook, we are still using useEffect
to trigger requests. React 18 in strict mode renders components twice which means that requests will fire off twice as well.
To avoid this we should set up AbortControl
and cancel any ongoing requests in the useEffect
cleanup function.
Parent first, children later
When a component is loading data it often shows some sort of a loading indicator or skeleton blocking the rendering of its children.
This creates a waterfall of network requests where the parent data is fetched, children are rendered, and then children data is fetched …etc
In our case, every time we visit the ProductDetails
page we’ll wait until the details are fetched and rendered before the ProductReviews
can request its data.
In turn, this results in an interface filled with loading indicators.
TL;DR
A lot needs to be taken care of when using a mix of useEffect
and a global store:
- Data fetching
- Request cancellation
- Loading state and error state handling
- Caching
- Stale data and revalidation
- Request waterfalls
How, where, and when
To address all these challenges we need to think about data from three different perspectives. How to perform network requests, where to store data, and when to initiate the requests.
How to perform network requests
Fetch is quite a popular and powerful interface for fetching (and mutating) resources. It’s a native API that’s available in all modern browsers.
However, in more complex scenarios it requires a lot of manual work to implement things like error handling and interceptors.
Before fetch came into existence Axios was the go-to library for network requests. It still has about 36 million weekly downloads today. Its development and maintenance slowed down with the rise of Fetch which drove even more people to avoid it. However, maintenance picked up again recently with new versions being released.
Axios is a promise based just like Fetch but built upon XMLHttpRequest
instead. It supports better error handling, instances, and interceptors which make things like authenticated requests a lot easier.
Ky is a tiny HTTP client based on the Fetch API. Just like Axios, it supports better error handling and instances. However, it takes a different approach than Axios and implements what they call hooks
instead of interceptors.
There are additionally various other HTTP client libraries you could pick from and which one you choose should be based on your project needs. It’s very common to write a small wrapper around Fetch to avoid using third-party libraries.
Where to store data
Fetching data is only one part of the story. In the context of React, data can live in a local component state, a hook state, a react context, a global store, or in a cache.
To pick the best place we need to differentiate between server and client data.
Server data (or async data) is everything that originates from an external server. This is typically anything we fetch over the network.
Client data on the other hand (often referred to as client state) is anything that originates from the client. This is mainly the UI state such as components’ open/closed state, form fields values, and themes.
Client data can be managed in different ways as well and we’ll explore the different patterns in another post. In this one, we’ll focus on server data.
Referring back to the status quo, we saved our server data in a custom hook first and then in a global store which solved some problems but not all.
To recap, we need:
- Cache layer that also takes care of revalidation.
- Loading and error state management
- Automatic request cancellation
TanStack Query (ReactQuery)
Similar to how the Apollo client caches GraphQl queries and eliminates the need for a global state. Query caches the results of any async operations and controls its revalidation and sync with the server.
It also enables things like optimistic updates and automatic error and loading state handling.
Lets refactor our useProducts
hook and see the benefits of a proper client cache layer
export const useProducts = () => {
const { isLoading, error, data } = useQuery({
queryKey: ["products"],
queryFn: () =>
fetch(getApiURL("products")).then((response) => response.json()),
});
return {
products: data,
isLoading,
error,
};
};
export const useProduct = (producId) => {
const { isLoading, error, data } = useQuery({
queryKey: ["products", producId],
queryFn: () =>
fetch(getApiURL(`products/${productId}`)).then((response) =>
response.json()
),
});
return {
productDetails: data,
isLoading,
error,
};
};
First of all, we split our hook into two to keep the loading and error states separate. We then got rid of the store, the useEffect, the local state, and manual error handling.
When used, each hook will trigger the network request and store its result in the cache with the queryKey
for later reference.
It’ll automatically cancel duplicated requests and return the cached value while re-fetching the data and re-validating the cache in the background.
Navigating between the product listing and details pages now feels instant after the first load.
Another great hidden feature is refreshing the cache on window focus.
By default Query
is aggressively revalidating the cache. However, it’s configurable and we can provide a duration for which no revalidation is required, saving network bandwidth and reducing server load when needed.
Query
have so much more to provide and I encourage you to visit its website to learn more about it.
You may also check the video below, it’s a little outdated and the syntax has changed a bit since but the core concepts still stand.
When to initiate requests
So far we managed to overcome almost all our challenges regarding fetching and storing and managing data. The only thing remaining from our list is parents blocking children and the loading indicators.
Even though Query
helps a little with caching, children still need to wait for parents to render before initiating their requests.
Requests can be initiated at different points during the application life cycle. In SSR, data is fetched on the server before even the view is constructed, in CSR requests are commonly initiated in a useEffect
after rendering causing requests waterfall.
If we could somehow initiate requests before rendering, we could load data for both the product details and its reviews at the same time, eliminating the need for nested loading indicators.
The secret is in the URL
In almost all cases, the URL determines what data is needed for the layout. In our example, we load the product listing when on /products
and the single product details when on /products/:productId
.
Since we already know what data is needed for each route we can initiate the request before the page is rendered.
This can be achieved through ReactRouter v6.4 route loaders.
import {
createBrowserRouter,
createRoutesFromElements,
} from "react-router-dom";
// App.ts
createBrowserRouter(
createRoutesFromElements(
<Route
path="products"
loader={() => {
// Loaders understand the Fetch API and will automatically
// unwrap response.json() when directly returning `fetch`
return fetch(getApiURL("products"));
}}
element={<Products />}
errorElement={<ErrorBoundary />}
>
<Route
path=":productId"
loader={async ({ params }) => {
const productDetails = await fetch(
getApiURL(`products/${params.productId}`)
).then((response) => response.json());
const productReviews = await fetch(
getApiURL(`reviews/${params.productId}`)
).then((response) => response.json());
return {
productDetails,
productReviews,
};
}}
element={<ProductDetails />}
/>
</Route>
)
);
We use the createBrowserRouter
and createRoutesFromElements
functions to register our routes in JSX. ReactRouter v6 introduced some API changes. Check the docs for more information on how to use ReactRouter V6.
For each route, we set up a loader function that should return the data that the component needs. React Router will request and load the data before the component is rendered.
In the products/:productId
route, we fetch both the product details as well as its reviews at the same time.
In the case of errors, the errorElement
component would render instead of element
, and errors in nested routes with no errorElement
would bubble to the nearest parent with an errorElement
.
Loaded data is accessible through the useLoderData()
hook.
import { useLoaderData } from "react-router-dom";
const Products = () => {
const products = useLoaderData();
return (
<div>
<h1>Our Products</h1>
{products.map((product) => {
return (
<ProductLink
key={product.id}
image={product.image}
name={product.name}
price={product.price}
/>
);
})}
</div>
);
};
const ProductDetails = () => {
const { productDetails, productReviews } = useLoaderData();
return (
<div>
<h1>{productDetails.name}</h1>
<img src={productDetails.image} alt={productDetails.description} />
<p>{productDetails.description}</p>
<p>{productDetails.price}</p>
<ProductReviews reviews={productReviews} />
</div>
);
};
No loading state, no conditional rendering, and no nested loading indicator.
Of course, we may choose to instantly load the components with some sort of a loading indicator if the requested data takes some time to load, otherwise, our application might appear frozen.
React Router provides a way to do so through the defer
utility along with React Suspense.
Feel free to read more about this and all the other features of ReactRouter
on their website.
What about Query?
We just spent almost all of this article moving to Query
for data management. How does it integrate with route loaders?
We can quickly refer back to the how, where, and when of data management. ReactRouter is about the “when” and it doesn’t store any data nor provide caching. Every time we visit a route, its data will be requested again.
It’s time to bring Query back again to the mix to cache the results of route loaders.
Next to the useProducts
hook we define and export the route loader. It takes the queryClient as a dependency and returns either cached data or fetches fresh one and as we said above when accessing cached data Query
will refresh in the background anyways.
// useProducts.ts
const query = {
queryKey: ["products"],
queryFn: () =>
fetch(getApiURL("products")).then((response) => response.json()),
};
// Loaders can be defined anywhere and exported
// for usage with route registration
export const productsLoader = (queryClient) => async () => {
return (
// return cached data or fetch it if not available
queryClient.getQueryData(query.queryKey) ??
(await queryClient.fetchQuery(query))
);
};
export const useProducts = () => {
const { isLoading, error, data } = useQuery(query);
return {
products: data,
isLoading,
error,
};
};
In the route registration, we use the exported productsLoader
and supply it with queryClient
.
// App.ts
import {
createBrowserRouter,
createRoutesFromElements,
} from "react-router-dom";
import { QueryClient } from "@tanstack/react-query";
import { productsLoader } from "./useProducts";
const queryClient = new QueryClient();
createBrowserRouter(
createRoutesFromElements(
<Route
path="products"
loader={productsLoader(queryClient)}
element={<Products />}
errorElement={<ErrorBoundary />}
>
...
</Route>
)
);
In our component, we use the useProducts
hook as usual.
// Products.ts
const Products = () => {
const { products } = useProducts();
return (
<div>
<h1>Our Products</h1>
{products.map((product) => {
return (
<ProductLink
key={product.id}
image={product.image}
name={product.name}
price={product.price}
/>
);
})}
</div>
);
};
This might be a lot to digest so let us go through what will happen step by step:
- User visits the
/products
route - Before rendering
ReactRouter
invokes theproductsLoader
productLoader
will look inside the cache to see if we have any products, it won’t find any.productLoader
initiates a request to fetch the products, return the data, and cache it.ReactRouter
will render the<Products>
component<Products>
will useuseProducts
which in turn usesuseQuery
to look inside the cache.useProducts
finds the cached data and return it
In other words, we populate the cache when the route is visited before rendering the component. By the time the component renders the data would already be cached and accessible.
Summary
By utilizing Query
and ReactRouter
we can overcome most of the challenges that come with server (async) data.
Fetching and loading resources as early as possible increases performance while caching eliminates loading indicators and increases perceived performance and speed.
Together they also greatly improve the development experience by reducing the complexity and amount of the needed code.
For more details about mutations, error handling, web sockets, and more checkout this blog series by one of Query
maintainers, TkDodo.