Every frontend application is part of a distributed system. We usually need to fetch and send information to a server connected to a database, and in a small and simple application, this works without major issues.
However, as our application and data consumption grow, we need to start thinking about strategies to improve the user experience and make pages faster, smoother, and more dynamic.
Caching is one of those strategies.
After all, why should we fetch information that was already retrieved a short time ago?
By using cache, we can avoid unnecessary requests to the server and the frequent display of loaders on the page.
However, this raises other questions, such as:
- How long can we trust that the cached data is up to date?
- How can we keep it synchronized with the database?
- When should we update it?
There are libraries that help us implement and handle these challenges, such as TanStack Query, SWR, RTK Query, and Apollo Client for GraphQL.
In this article, we will explore how to manage this lifecycle, from the first request to strategies such as invalidation, direct cache updates, optimistic updates, polling, and prefetching.
Fetching data from the server
As an example for this article, we will use a simple page that fetches and displays a list of products.
With React, we can use the useEffect and useState hooks to implement it like this:
import { useEffect, useState } from "react";
export function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchProducts().then((data) => {
setProducts(data);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <p>Loading products...</p>;
}
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — $ {product.price}
</li>
))}
</ul>
);
}
Although we need to handle several scenarios manually, such as loading, storage, and error handling, this code works for a simple page.
This is the flow:
The page is mounted
↓
We fetch the data from an API — We display a loader
↓
We store the response in local state — We hide the loader
↓
We display the data in the interface
Now imagine that the user navigates to another page and then comes back. With this implementation, the user will go through the same flow again:
The page is mounted
↓
We fetch the data from an API — We display a loader
↓
We store the response in local state — We hide the loader
↓
We display the data in the interface
We can improve the user experience by moving server data into an application-level cache using TanStack Query, previously known as React Query.
Application-level cache
Using cache makes the interface feel faster because we can reuse the response from a previous request without needing to fetch it again and display a loader or a blank page.
First, let us install and configure the library in our application:
npm install @tanstack/react-query
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { ReactNode } from "react";
import { ProductList } from "./ProductList";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ProductList />
</QueryClientProvider>
);
}
Fetching data with useQuery
The list can now be loaded using useQuery:
import { useQuery } from "@tanstack/react-query";
export function ProductList() {
const {
data: products,
error,
isPending,
isFetching,
} = useQuery({
queryKey: ["products"], // Identifies the data inside the cache
queryFn: fetchProducts, // Defines how the data will be fetched
});
if (isPending) {
return <p>Loading products...</p>;
}
if (error) {
return <p>{error.message}</p>;
}
return (
<section>
{isFetching && <p>Updating products...</p>}
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — $ {product.price}
</li>
))}
</ul>
</section>
);
}
The first time the component is rendered:
- React Query looks for
["products"]in the cache. - Because the data does not exist yet, it executes
fetchProducts. - The component enters its initial loading state.
- The API responds.
- The data is stored in the cache.
- Components subscribed to that query receive the result.
If the user leaves the page and returns later, React Query can immediately display the cached data.
Depending on the configuration, the library may also execute a new request in the background to check whether more recent information is available.
The difference between fresh and stale data
The data stored in the React Query cache can be considered fresh or stale.
Fresh data is still considered recent enough.
While the data remains fresh, it does not need to be fetched again automatically.
Stale data can still be displayed, but React Query understands that it may need to be updated.
This distinction is important because it allows the application to display cached data immediately while updating it in the background.
Controlling updates with staleTime
The staleTime option determines how long data remains fresh in the cache.
const productsQuery = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000,
});
In this example, the products are considered fresh for 30 seconds.
During this period, if the component is unmounted and mounted again, React Query can reuse the data without immediately executing another request.
After 30 seconds, the data becomes stale. It is not deleted; it simply becomes eligible for revalidation.
We could also use staleTime: 5 * 60 * 1000 to keep the data fresh for five minutes.
For data that changes very rarely, we can use staleTime: Infinity.
In this case, the query never becomes stale automatically. An update will depend on manual invalidation or another explicit action.
A common mistake is to interpret staleTime as the amount of time before the data is removed.
It does not control removal. The staleTime option only controls when data stops being considered fresh.
The amount of time an unused query remains in the cache is controlled by gcTime:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000, // The data is considered fresh for 30 seconds
gcTime: 5 * 60 * 1000, // The inactive query can be removed from the cache after 5 minutes without observers
});
The difference between isPending, isLoading, and isFetching
The loading states isPending, isLoading, and isFetching should be used differently.
When the query is still in the pending state, we can display a loader using isPending:
if (isPending) {
return <ProductListSkeleton />;
}
Until version 4 of TanStack Query, isLoading represented the initial state of the query. In version 5, the previous loading status was renamed to pending, and the previous isLoading property was renamed to isPending.
isLoading still exists in version 5, but it is now a derived state based on isPending && isFetching. In practice, it indicates that the query is pending and that its first request is currently in progress.
The isFetching property indicates that a request is being executed, including when data is already available in the cache:
return (
<div>
{isFetching && <small>Updating...</small>}
<ProductTable products={products} />
</div>
);
The conceptual difference is:
isPending→ The query does not have data available yet.isLoading→ The query is pending and its first request is in progress.isFetching→ A request is in progress, regardless of whether data already exists in the cache.
It is not recommended to replace the entire interface with a spinner whenever isFetching is true, because that would remove one of the main benefits of caching.
A better experience is to keep the data visible and display only a subtle indicator:
export function ProductList() {
const {
data = [],
isPending,
isFetching,
error,
} = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000,
});
if (isPending) {
return <ProductListSkeleton />;
}
if (error) {
return <ErrorMessage message={error.message} />;
}
return (
<section>
<header>
<h1>Products</h1>
{isFetching && (
<span aria-live="polite">
Syncing…
</span>
)}
</header>
<ProductTable products={data} />
</section>
);
}
Synchronizing cached data
Caching becomes more complex when the user modifies data.
Consider a mutation that changes the price of a product.
The mutation may successfully update the server, but the list stored in the ["products"] cache will still contain the old data.
The frontend needs to decide how to synchronize the cache. There are three main strategies for doing this.
Strategy 1: invalidate the query
The simplest and safest option is usually to invalidate the related queries inside the mutation's onSuccess callback.
import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["products"],
});
},
});
}
When the mutation finishes, the server saves the change, React Query marks ["products"] as stale, active queries can execute another fetch, and the interface receives the updated data from the server.
Advantages
- Simple implementation
- Uses the server as the source of truth
- Reduces the risk of incorrectly building the new state
- Works well when several pieces of information may have been affected
Disadvantages
- Creates an additional request
- The visual update may take slightly longer
- It may be excessive for very small changes
When to use it
- Correctness is more important than avoiding an additional request
- The server performs additional calculations or business rules
- Several queries may have been affected
- Updating the cache manually would be complex
Strategy 2: update the cache directly
When the API returns the updated product in the response to an update request, we can replace that product directly in the React Query cache:
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onSuccess: (updatedProduct) => {
// Update the individual product cache
queryClient.setQueryData(
["product", updatedProduct.id],
updatedProduct,
);
// Update the products list cache
queryClient.setQueryData<Product[]>(
["products"],
(products) =>
products?.map((product) =>
product.id === updatedProduct.id
? updatedProduct
: product,
) ?? [],
);
},
});
}
Advantages
- Does not require an additional request
- The interface is updated immediately after the response
- Reuses the data returned by the API
Disadvantages
- You need to know every cache entry that was affected
- Filters and pagination make the update more complex
- Different cache entries may become inconsistent
When to use it
- The API response contains the complete updated resource
- The affected cache entries are known
- You can update the cache reliably
Strategy 3: optimistic update
An optimistic update changes the interface before the server confirms the operation.
The application temporarily assumes that the mutation will succeed. If an error occurs during the request, the value is reverted.
export function useUpdateProductPrice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onMutate: async (updatedProduct) => {
await queryClient.cancelQueries({
queryKey: ["products"],
});
const previousProducts =
queryClient.getQueryData<Product[]>(["products"]);
queryClient.setQueryData<Product[]>(
["products"],
(currentProducts) =>
currentProducts?.map((product) =>
product.id === updatedProduct.id
? {
...product,
price: updatedProduct.price,
}
: product,
) ?? [],
);
return {
previousProducts,
};
},
onError: (_error, _variables, context) => {
if (context?.previousProducts) {
queryClient.setQueryData(
["products"],
context.previousProducts,
);
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["products"],
});
},
});
}
Advantages
- Provides an almost immediate visual response
- Improves frequent interactions
- Works well for simple and predictable actions, such as liking a post, completing a task, favoriting an item, or reorganizing a list
Disadvantages
- Rollbacks are more complex
- Concurrent operations may create conflicts
- Business rules may prevent the change
- It is not ideal when failures are frequent or highly significant
When to use it
- The interaction needs to feel instant
- The operation usually succeeds
- The rollback is simple
- The impact of a temporary failure is small
When not to use it
- When handling payments
- For critical changes
Other strategies: refetching, polling, and prefetching
React Query also provides other ways to execute requests and update the cache in useful scenarios, including manual refetching, polling, and prefetching.
Manual refetching
The useQuery hook returns a method called refetch, which we can call to fetch the data and update the cache immediately.
export function ProductList() {
const {
data = [],
isPending,
isFetching,
refetch,
} = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000,
});
if (isPending) {
return <ProductListSkeleton />;
}
return (
<section>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Updating..." : "Update"}
</button>
<ProductTable products={data} />
</section>
);
}
Polling
See the documentation.
For information that needs to be updated frequently, we can execute requests at regular intervals:
useQuery({
queryKey: ["order-status", orderId],
queryFn: () => fetchOrderStatus(orderId),
refetchInterval: 10_000,
});
In this example, the order status is updated every ten seconds.
Polling is simple, but it has some costs:
- It increases the number of requests
- It may fetch data even when nothing has changed
- It consumes server resources
- It may be unsuitable at a large scale
Depending on the use case, WebSockets or Server-Sent Events may be more efficient.
Prefetching
See the documentation.
The cache can also be populated before the user opens a page.
import { useQueryClient } from "@tanstack/react-query";
export function ProductLink({
productId,
children,
}: {
productId: number;
children: React.ReactNode;
}) {
const queryClient = useQueryClient();
function prefetchProduct() {
queryClient.prefetchQuery({
queryKey: ["product", productId],
queryFn: () => fetchProduct(productId),
staleTime: 30_000,
});
}
return (
<a
href={`/products/${productId}`}
onMouseEnter={prefetchProduct}
onFocus={prefetchProduct}
>
{children}
</a>
);
}
When the user hovers over or focuses on the link, the application fetches the product details and stores the result in the cache. When the user opens the page, the data may already be available.
Prefetching improves perceived performance, but it should be used carefully to avoid loading information that will probably not be used.
Conclusion
Frontend caching goes far beyond avoiding repeated requests. It helps balance different application requirements, such as:
- Performance
- Perceived speed
- Network usage
- Data freshness
- Consistency
React Query simplifies this process by providing features for controlling the lifecycle of server data, including caching, revalidation, invalidation, and synchronization after changes.
However, using cache does not mean keeping all information stored indefinitely. The longer data remains in the cache, the greater the risk of displaying outdated information.
Caching also has costs:
- Memory consumption
- Invalidation complexity
- Possible inconsistencies between the client and the server
- Greater debugging difficulty
- The need to define update strategies
For this reason, cache configuration should not be treated only as a technical decision. It should also consider the needs of the product and the impact that outdated information may have on the user.
The greater the risk of displaying incorrect information, the shorter the period during which the application should rely exclusively on cached data.
The best strategy is not to store everything for as long as possible, but to consciously answer the following question:
How long can this data remain outdated without harming the user or the application?
