Implement Caching Strategies with React Query

Introduction

  • You can view the theoretical content about Caching strategies presented here
  • These Caching Patterns are not only used on the Backend but can also be implemented on the Frontend
  • You only need to replace the role of Redis typically used with Memory (or use Local Storage, IndexedDB on the Browser), while replacing the query fetching data from the Database with response data from the Server API
  • In this article, I will guide you on using React Query combined with IndexedDB (using @tanstack/react-query-persist-client and idb-keyval) to implement Caching strategies in a simple and effective way
alt text

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, using useMutation + onMutate
  • Update the UI and Cache before the request is sent to the Server. If the Server returns an error, automatically Rollback to 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 * 24 means the cache will be stored for 24 hours
  • staleTime: 1000 * 60 * 5 means 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 PersistQueryClientProvider and define indexedDBPersister to interact with IndexedDB

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 useGetProducts to implement CACHE-ASIDE (Lazy Loading) & READ-THROUGH strategy
    • 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
  • Function usePrefetchProduct implements REFRESH-AHEAD (Prefetching), we will use it to prefetch data when the user hovers over the product title
  • Function useCreateProductWriteThrough implements WRITE-THROUGH, you can see in the onSuccess field that it directly updates the cache after creating a new product successfully
  • Function useUpdateProductWriteAround implements WRITE-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 useUpdateProductWriteBehind implements WRITE-BEHIND (Optimistic Update)
    • onMutate is run before calling the API to update data in cache for keys ['products'] and ['products', newProduct.id]
    • onError handles rolling back both List and Detail to their original state if an API error occurs
    • onSettled always 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>
  )
}

The result is as followsalt textalt text

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 againalt text

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Understanding React Server Component

Kubernetes Deployment for Zero Downtime

Sitemap

Deploying a NodeJS Server on Google Kubernetes Engine

React Practice Series

Docker Practice Series

Helm for beginer - Deploy nginx to Google Kubernetes Engine

A Handy Guide to Using Dynamic Import in JavaScript

DevOps Practice Series