Implement Caching Strategies with React Query
Introduction
- You can view the theoretical content about
Caching strategiespresented here - These
Caching Patternsare not only used on theBackendbut can also be implemented on theFrontend - You only need to replace the role of
Redistypically used withMemory(or useLocal Storage, IndexedDBon theBrowser), while replacing the query fetching data from theDatabasewith response data from theServer API - In this article, I will guide you on using React Query combined with
IndexedDB(using@tanstack/react-query-persist-clientandidb-keyval) to implementCaching strategiesin a simple and effective way
Cache patterns
CACHE-ASIDE (Lazy Loading) & READ-THROUGH
This is the default mechanism of useQuery.
Cache-Aside: Check the cache first, if it does not exist, fetch from the API and save to the cache.Read-Through: The client interacts through an abstraction layer (hook) and the hook automatically decides whether to retrieve data from the cache or the server.
REFRESH-AHEAD (Prefetching)
Use queryClient.prefetchQuery to proactively preload data into the cache before the user actually needs it (such as when hovering over a button or navigating pages).
WRITE-THROUGH
- Use
useMutation + queryClient.setQueryData - Simultaneously execute the API call to save data and update the cache to synchronize data
WRITE-AROUND
- Use
useMutation + queryClient.invalidateQueries - Call the API to update data, then mark the old cache as Invalidated. When the component needs to use it, it will fetch again.
WRITE-BEHIND (Write-Back)
- Also known as
Optimistic Update, usinguseMutation + onMutate - Update the UI and
Cachebefore the request is sent to theServer. If theServerreturns an error, automaticallyRollbackto the previous state.
Detail
In a NextJS project, create the file provider/persist-query-client.provider.tsx to define the configuration for react-query
'use client'
import {QueryClient} from '@tanstack/react-query'
import {
PersistQueryClientProvider,
type Persister,
} from '@tanstack/react-query-persist-client'
import {del, get, set} from 'idb-keyval'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * 5,
},
},
})
export const indexedDBPersister: Persister = {
persistClient: async client => {
await set('REACT_QUERY_OFFLINE_CACHE', client)
},
restoreClient: async () => {
return await get('REACT_QUERY_OFFLINE_CACHE')
},
removeClient: async () => {
await del('REACT_QUERY_OFFLINE_CACHE')
},
}
export function ReactQueryProviders({children}: {children: React.ReactNode}) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{persister: indexedDBPersister}}
onSuccess={() => {
console.log('Cache restored from IndexedDB')
}}
>
{children}
</PersistQueryClientProvider>
)
}
gcTime: 1000 * 60 * 60 * 24means the cache will be stored for 24 hours
staleTime: 1000 * 60 * 5means data is considered fresh for 5 minutes- During this timeframe, no API call will be made to retrieve new data
- When it expires, if the cache still holds data, it returns the cached data and calls the API in the background to fetch new data
- Default is
0, meaning it will always call the API
- Use
PersistQueryClientProviderand defineindexedDBPersisterto interact withIndexedDB
Modify file app/layout.tsx to use ReactQueryProviders
import {ReactQueryProviders} from '@/provider/persist-query-client.provider'
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className="min-h-full font-sans">
<main>
<ReactQueryProviders>
{children}
</ReactQueryProviders>
</main>
</body>
</html>
)
}
Create file app/react-query-cache-strategies/useApi.ts to define API calling hooks with react-query as follows
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import axios from 'axios'
export interface Product {
id: string
name: string
price: number
}
const API_URL = `${process.env.NEXT_PUBLIC_SERVER_HOST}/products`
export function useGetProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const {data} = await axios.get<Product[]>(API_URL)
return data
},
})
}
export function usePrefetchProduct() {
const queryClient = useQueryClient()
const prefetchProduct = (id: string) => {
queryClient.prefetchQuery({
queryKey: ['product', id],
queryFn: async () => {
const {data} = await axios.get<Product>(`${API_URL}/${id}`)
return data
},
staleTime: 1000 * 60 * 10,
})
}
return {prefetchProduct}
}
export function useCreateProductWriteThrough() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newProduct: Omit<Product, 'id'>) => {
const {data} = await axios.post<Product>(API_URL, newProduct)
return data
},
onSuccess: savedProduct => {
queryClient.setQueryData<Product[]>(['products'], oldProducts => {
return oldProducts ? [...oldProducts, savedProduct] : [savedProduct]
})
},
})
}
export function useUpdateProductWriteAround() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (updatedProduct: Product) => {
const {data} = await axios.patch<Product>(
`${API_URL}/${updatedProduct.id}`,
updatedProduct
)
return data
},
onSuccess: (_data, updatedProduct) => {
queryClient.invalidateQueries({
queryKey: ['products'],
})
queryClient.invalidateQueries({
queryKey: ['product', updatedProduct.id],
refetchType: 'none',
})
},
})
}
export function useUpdateProductWriteBehind() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (updatedProduct: Partial<Product> & {id: string}) => {
const {data} = await axios.patch<Product>(
`${API_URL}/${updatedProduct.id}`,
updatedProduct
)
return data
},
onMutate: async newProduct => {
const detailQueryKey = ['products', newProduct.id]
const listQueryKey = ['products']
await queryClient.cancelQueries({queryKey: listQueryKey})
await queryClient.cancelQueries({queryKey: detailQueryKey})
const previousProducts = queryClient.getQueryData<Product[]>(listQueryKey)
const previousProductDetail =
queryClient.getQueryData<Product>(detailQueryKey)
queryClient.setQueryData<Product[]>(listQueryKey, old => {
return old?.map(prod =>
prod.id === newProduct.id ? {...prod, ...newProduct} : prod
)
})
queryClient.setQueryData<Product>(detailQueryKey, old => {
return old ? {...old, ...newProduct} : undefined
})
return {previousProducts, previousProductDetail}
},
onError: (_err, newProduct, context) => {
if (context?.previousProducts) {
queryClient.setQueryData(['products'], context.previousProducts)
}
if (context?.previousProductDetail) {
queryClient.setQueryData(
['products', newProduct.id],
context.previousProductDetail
)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: ['products'],
refetchType: 'none',
})
queryClient.invalidateQueries({
queryKey: ['product', variables.id],
refetchType: 'none',
})
},
})
}
- Function
useGetProductsto implementCACHE-ASIDE (Lazy Loading) & READ-THROUGHstrategy- React Query automatically checks the cache to call the API if miss/stale and saves it into the
Cache - We only need to interact through this hook without manually executing the task of saving data to
IndexedDB
- React Query automatically checks the cache to call the API if miss/stale and saves it into the
- Function
usePrefetchProductimplementsREFRESH-AHEAD (Prefetching), we will use it to prefetch data when the user hovers over the product title - Function
useCreateProductWriteThroughimplementsWRITE-THROUGH, you can see in theonSuccessfield that it directly updates the cache after creating a new product successfully - Function
useUpdateProductWriteAroundimplementsWRITE-AROUND, after the API call succeeds, it revalidates the cache so that next time it is needed, it will call the API instead of reusing the cached version - Function
useUpdateProductWriteBehindimplementsWRITE-BEHIND (Optimistic Update)onMutateis run before calling the API to update data in cache for keys['products']and['products', newProduct.id]onErrorhandles rolling back both List and Detail to their original state if an API error occursonSettledalways revalidates both List and Detail to accurately sync with the Server
Create file app/react-query-cache-strategies/page.tsx as the UI to consume the defined API hooks
'use client'
import {EditOutlined, PlusOutlined} from '@ant-design/icons'
import {
Button,
Card,
Form,
Input,
InputNumber,
Popconfirm,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import {useState} from 'react'
import {
Product,
useCreateProductWriteThrough,
useGetProducts,
usePrefetchProduct,
useUpdateProductWriteAround,
useUpdateProductWriteBehind,
} from './useApi'
const {Title, Text} = Typography
export default function ProductManagementPage() {
const [form] = Form.useForm()
const [createForm] = Form.useForm()
const [editingKey, setEditingKey] = useState<string>('')
const {data: products, isLoading, isFetching} = useGetProducts()
const {prefetchProduct} = usePrefetchProduct()
const createWriteThrough = useCreateProductWriteThrough()
const updateWriteAround = useUpdateProductWriteAround()
const updateWriteBehind = useUpdateProductWriteBehind()
const isEditing = (record: Product) => record.id === editingKey
const edit = (record: Product) => {
form.setFieldsValue({...record})
setEditingKey(record.id)
}
const cancel = () => {
setEditingKey('')
}
const handleCreate = async (values: {
name: string
price: number
description?: string
}) => {
try {
await createWriteThrough.mutateAsync(values)
message.success('Product created successfully (Write-Through)!')
createForm.resetFields()
} catch {
message.error('Failed to create product.')
}
}
const handleUpdateWriteAround = async (id: string) => {
try {
const row = await form.validateFields()
await updateWriteAround.mutateAsync({id, ...row})
message.success('Product updated (Write-Around / Invalidate Cache)!')
setEditingKey('')
} catch {
message.error('Failed to update product.')
}
}
const handleUpdateWriteBehind = async (id: string) => {
try {
const row = await form.validateFields()
await updateWriteBehind.mutateAsync({id, ...row})
message.success('Product updated optimistic (Write-Behind)!')
setEditingKey('')
} catch {
message.error('Failed to update product.')
}
}
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: '10%',
},
{
title: 'Product Name',
dataIndex: 'name',
key: 'name',
width: '20%',
render: (text: string, record: Product) => {
if (isEditing(record)) {
return (
<Form.Item 'Please 0}} input message: name="name" name!'}]} product rules="{[{required:" style="{{margin:" true,>
<Input/>
</Form.Item>
)
}
return (
<Text className="cursor-pointer hover:text-blue-500" onMouseEnter="{()"> prefetchProduct(record.id)}
>
{text}
</Text>
)
},
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
width: '40%',
render: (text: string, record: Product) => {
if (isEditing(record)) {
return (
<Form.Item 0}} name="description" style="{{margin:">
<Input placeholder="Optional description"/>
</Form.Item>
)
}
return text || <Text type="secondary">N/A</Text>
},
},
{
title: 'Price ($)',
dataIndex: 'price',
key: 'price',
width: '12%',
render: (price: number, record: Product) => {
if (isEditing(record)) {
return (
<Form.Item 'Please 0}} input message: name="price" price!'}]} rules="{[{required:" style="{{margin:" true,>
<InputNumber className="w-full" min="{0}"/>
</Form.Item>
)
}
return `${price}`
},
},
{
title: 'Views',
dataIndex: 'views',
key: 'views',
width: '8%',
render: (views: number) => <Tag color="cyan">{views ?? 0}</Tag>,
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: Product) => {
const editable = isEditing(record)
if (editable) {
return (
<Space>
<Button onClick="{()" size="small" type="primary"> handleUpdateWriteAround(record.id)}
loading={updateWriteAround.isPending}
>
Save (Write-Around)
</Button>
<Button danger onClick="{()" size="small" type="dashed"> handleUpdateWriteBehind(record.id)}
loading={updateWriteBehind.isPending}
>
Save (Optimistic)
</Button>
<Popconfirm onConfirm="{cancel}" title="Cancel editing?">
<Button size="small">Cancel</Button>
</Popconfirm>
</Space>
)
}
return (
<Space>
<Button icon="{<EditOutlined"/>}
disabled={editingKey !== ''}
onClick={() => edit(record)}
>
Edit
</Button>
</Space>
)
},
},
]
return (
<div className="max-w-6xl mx-auto p-4 space-y-6">
<div className="flex justify-between items-center border-b pb-4">
<Title level="{2}">Product</Title>
{isFetching && <Tag color="processing">Fetching Data...</Tag>}
</div>
<Card className="shadow-sm mb-5" title="Add New Product">
<Form className="flex gap-4 items-center flex-wrap" form="{createForm}" layout="inline" onFinish="{handleCreate}">
<Form.Item 'Enter className="mb-0" message: name="name" name'}]} rules="{[{required:" true,>
<Input placeholder="Product Name"/>
</Form.Item>
<Form.Item className="mb-0" name="description">
<Input placeholder="Description (Optional)"/>
</Form.Item>
<Form.Item 'Enter className="mb-0" message: name="price" price'}]} rules="{<a href="https://howtodevez.blogspot.com/2025/04/all-practice-series.html" target="_blank">{required:" true,>
<InputNumber min="{0}" placeholder="Price"/>
</Form.Item>
<Form.Item className="mb-0">
<Button htmlType="submit" icon="{<PlusOutlined" type="primary"/>}
loading={createWriteThrough.isPending}
>
Create Product
</Button>
</Form.Item>
</Form>
</Card>
<Card className="shadow-sm" title="Product List">
<Form component="{false}" form="{form}">
<Table 5}} bordered columns="{columns}" dataSource="{products}" loading="{isLoading}" pagination="{{pageSize:" rowKey="id"/>
</Form>
</Card>
</div>
)
}
You can check if the data has been stored in IndexedDB and compare: if data is already present in cache, react-query will not call the API again
Happy coding!
Comments
Post a Comment