React & Frontend⏱ 11 phút đọc · 7 thg 10, 2026

React Query là gì: dùng TanStack Query v5 thay cho fetch trong useEffect

Fetch trong useEffect chạy được, nhưng cache, race condition, refetch và cập nhật sau khi POST thì bạn tự lo. Bài này giải thích React Query (TanStack Query v5) là gì, cách dùng useQuery, useMutation, staleTime, gcTime, optimistic update và khi nào không cần.

HOLETEX · POST
QUERY
TanStack v5

Bạn làm trang danh sách sản phẩm có bộ lọc theo danh mục. Code quen thuộc: useState cho data, useState cho loading, useState cho error, một useEffect gọi fetch. Chạy ổn trên máy. Rồi tester báo bug: bấm nhanh "Laptop" rồi "Điện thoại", màn hình lại hiện laptop. Vào trang chi tiết rồi quay lại, spinner quay lại từ đầu dù data vừa tải xong. Thêm sản phẩm mới thì danh sách không cập nhật cho tới khi F5.

Không có lỗi nào ở đây là do bạn code ẩu. Đó là những bài toán có sẵn của server state, và useEffect không được thiết kế để giải chúng. TanStack Query (tên cũ là React Query) là thư viện sinh ra để làm việc này.

Vấn đề thật của fetch trong useEffect

Đây là phiên bản "làm đúng" theo hướng dẫn của React: có cờ ignore để chặn race condition và kiểm tra res.ok.

tsx
import { useEffect, useState } from 'react'

type Product = { id: number; name: string; price: number; category: string }

export function ProductList({ category }: { category: string }) {
  const [products, setProducts] = useState<Product[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    let ignore = false
    setLoading(true)
    setError(null)

    fetch(`/api/products?category=${encodeURIComponent(category)}`)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json() as Promise<Product[]>
      })
      .then((data) => {
        if (!ignore) setProducts(data)
      })
      .catch((err: Error) => {
        if (!ignore) setError(err.message)
      })
      .finally(() => {
        if (!ignore) setLoading(false)
      })

    return () => {
      ignore = true
    }
  }, [category])

  if (loading) return <p>Đang tải...</p>
  if (error) return <p>Lỗi: {error}</p>
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

Hơn 40 dòng, và vẫn còn thiếu:

  • Không có cache. Unmount rồi mount lại là fetch lại từ đầu, người dùng thấy spinner lần nữa.
  • Không dedupe. Header và sidebar cùng cần danh sách này thì gửi hai request giống nhau.
  • Không biết khi nào data cũ. Người dùng để tab mở cả buổi, quay lại vẫn thấy giá cũ.
  • Không có cơ chế làm mới sau khi ghi. POST xong, bạn phải tự nghĩ cách báo cho mọi component đang hiển thị danh sách.
  • Không retry, không hủy request khi không còn cần.

Chính docs React thừa nhận các nhược điểm này và gợi ý dùng data fetching của framework hoặc thư viện như TanStack Query, SWR. Nếu bạn chưa chắc về vòng đời useEffect, xem lại bài useState và useEffect trước.

React Query là gì

TanStack Query là thư viện fetch, cache, đồng bộ và cập nhật server state. Server state là dữ liệu nằm trên server, người khác có thể sửa bất cứ lúc nào, nên bản bạn đang giữ luôn có thể đã cũ. Nó khác client state như modal đang mở hay theme. Bài quản lý state trong React phân biệt kỹ hai loại này.

Mental model gọn nhất: TanStack Query là một cache phía client, đánh chỉ mục bằng key. Component không "gọi API", mà khai báo "tôi cần data của key này". Thư viện quyết định lúc nào dùng cache, lúc nào gọi lại server, và báo cho mọi component đang dùng key đó khi data thay đổi.

Về phiên bản: major hiện hành là v5 (bản mới nhất trên npm lúc viết bài là 5.104.1), yêu cầu React 18 trở lên. Thư viện còn có adapter cho Vue, Solid, Svelte, Angular; bản cho React vẫn tên @tanstack/react-query, nên nhiều người vẫn gọi là React Query.

Cài đặt và setup

bash
npm i @tanstack/react-query
npm i -D @tanstack/react-query-devtools @tanstack/eslint-plugin-query

Với app Vite, bọc toàn bộ app trong QueryClientProvider:

tsx
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30 * 1000, // ví dụ: coi data là mới trong 30 giây
    },
  },
})

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </StrictMode>,
)

QueryClient tạo một lần, bên ngoài component. Nếu tạo trong thân component, mỗi lần render bạn có một cache mới tinh.

Devtools hiện một nút nhỏ ở góc màn hình, mở ra thấy từng query: key, trạng thái (fresh, stale, fetching, inactive), data trong cache, kèm nút refetch hay invalidate bằng tay. Theo docs, devtools chỉ vào bundle khi NODE_ENV === 'development', nên không cần tự loại ra khi build production.

useQuery: viết lại ví dụ trên

Trước hết, một helper nhỏ. Điểm dễ sai: fetch không throw khi server trả 404 hay 500. TanStack Query chỉ coi là lỗi khi queryFn throw hoặc reject, nên phải tự kiểm tra res.ok.

ts
// src/api.ts
export type Product = { id: number; name: string; price: number; category: string }

export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, init)
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`)
  return (await res.json()) as T
}

Tiếp theo, gom key và hàm fetch vào một chỗ bằng helper queryOptions, để tái sử dụng ở useQuery, useSuspenseQuery, setQueryData... mà vẫn giữ type chính xác.

ts
// src/queries.ts
import { queryOptions } from '@tanstack/react-query'
import { fetchJson, type Product } from './api'

export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (category: string) => [...productKeys.lists(), { category }] as const,
  detail: (id: number) => [...productKeys.all, 'detail', id] as const,
}

export const productListOptions = (category: string) =>
  queryOptions({
    queryKey: productKeys.list(category),
    queryFn: ({ signal }) =>
      fetchJson<Product[]>(`/api/products?category=${encodeURIComponent(category)}`, { signal }),
  })

export const productDetailOptions = (id: number) =>
  queryOptions({
    queryKey: productKeys.detail(id),
    queryFn: ({ signal }) => fetchJson<Product>(`/api/products/${id}`, { signal }),
  })

Component giờ chỉ còn phần hiển thị:

tsx
// src/ProductList.tsx
import { useQuery } from '@tanstack/react-query'
import { productListOptions } from './queries'

export function ProductList({ category }: { category: string }) {
  const { data, isPending, isError, error, isFetching } = useQuery(productListOptions(category))

  if (isPending) return <p>Đang tải...</p>
  if (isError) return <p>Lỗi: {error.message}</p>

  return (
    <>
      {isFetching && <small>Đang cập nhật...</small>}
      <ul>
        {data.map((p) => (
          <li key={p.id}>
            {p.name}: {p.price.toLocaleString('vi-VN')}đ
          </li>
        ))}
      </ul>
    </>
  )
}

Những gì bạn có thêm mà không viết dòng nào:

  • Hết race condition. Mỗi category là một key riêng, response cũ nằm trong cache của key cũ, không đè lên màn hình hiện tại. Nhờ signal, request lỗi thời còn được hủy thật.
  • Cache và dedupe. Nhiều component dùng cùng key chỉ tạo một request. Quay lại trang cũ thấy data ngay, refetch ngầm nếu data đã stale.
  • Retry. Query lỗi được thử lại 3 lần với backoff tăng dần trước khi báo lỗi.
  • Type narrowing. Sau khi check isPending và isError, TypeScript biết data chắc chắn có giá trị.

Hai trạng thái hay bị nhầm: isPending là chưa có data nào, còn isFetching là đang gọi queryFn, kể cả khi đã có data cũ. Spinner toàn trang theo isPending; nếu theo isFetching, cả trang nhấp nháy mỗi lần refetch ngầm.

Thiết kế queryKey

Key là trái tim của cả thư viện. Ba quy tắc từ docs:

  1. Key là mảng, phần tử phải serialize được bằng JSON.stringify.
  2. Mọi biến mà queryFn dùng đều phải có trong key. Đổi category mà key không đổi thì bạn nhận data của danh mục cũ. Rule exhaustive-deps trong @tanstack/eslint-plugin-query bắt lỗi này giúp bạn.
  3. Thứ tự phần tử mảng có ý nghĩa, thứ tự thuộc tính trong object thì không. ['todos', { page, status }] và ['todos', { status, page }] là một.

Tổ chức key theo cấp như productKeys ở trên có lợi lớn khi invalidate, vì matching theo tiền tố: invalidate ['products', 'list'] sẽ trúng mọi danh sách ở mọi category, nhưng không đụng tới trang chi tiết. Muốn khớp chính xác một key thì thêm exact: true.

staleTime và gcTime: hai đồng hồ khác nhau

staleTimegcTime
Trả lời câu hỏiData này còn "mới" không, có cần hỏi lại server?Khi không còn component nào dùng, giữ data trong bộ nhớ bao lâu?
Mặc định0 (vừa tải xong đã coi là stale)5 phút
Bắt đầu đếmTừ lúc data được tải vềTừ lúc query thành inactive (không còn observer)
Hết hạn thìQuery đủ điều kiện refetch ngầmData bị xóa khỏi cache

Với staleTime: 0 mặc định, query stale sẽ tự refetch ngầm khi có component mới mount, khi cửa sổ được focus lại, và khi mạng kết nối lại. Đó là lý do request bắn ra mỗi lần chuyển tab trình duyệt. Không phải bug, nhưng với nhiều app thì hơi dày. Docs khuyên chỉnh staleTime thay vì tắt từng cờ refetchOnWindowFocus.

Chọn staleTime theo tính chất dữ liệu, không có con số đúng cho mọi app:

  • Giá cổ phiếu, số dư, trạng thái đơn hàng: để thấp hoặc giữ mặc định.
  • Danh mục sản phẩm, thông tin profile: vài chục giây tới vài phút là hợp lý.
  • Infinity: không bao giờ tự stale, nhưng vẫn làm mới được bằng invalidateQueries.
  • 'static': chặt hơn cả Infinity, kể cả invalidateQueries cũng không có tác dụng. Docs gợi ý dùng cho feature flag tải lúc khởi động hay bảng dữ liệu tham chiếu không đổi khi app đang chạy.

gcTime thì hiếm khi phải chỉnh. Để mặc định là đủ cho phần lớn trường hợp.

useMutation và invalidateQueries

Query để đọc, mutation để ghi. Sau khi ghi thành công, cách đơn giản và ít lỗi nhất là báo cho cache biết data liên quan đã cũ:

ts
// src/mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchJson, type Product } from './api'
import { productKeys } from './queries'

type NewProduct = Omit<Product, 'id'>

export function useCreateProduct() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (input: NewProduct) =>
      fetchJson<Product>('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      }),
    // return promise để mutation giữ trạng thái pending tới khi refetch xong
    onSuccess: () => queryClient.invalidateQueries({ queryKey: productKeys.lists() }),
  })
}
tsx
// src/AddProductButton.tsx
import { useCreateProduct } from './mutations'

export function AddProductButton() {
  const createProduct = useCreateProduct()

  return (
    <button
      disabled={createProduct.isPending}
      onClick={() => createProduct.mutate({ name: 'Bàn phím cơ', price: 1_290_000, category: 'phu-kien' })}
    >
      {createProduct.isPending ? 'Đang lưu...' : 'Thêm sản phẩm'}
    </button>
  )
}

invalidateQueries đánh dấu các query khớp là stale, query nào đang hiển thị sẽ refetch ngay, nên mọi component đang render danh sách tự cập nhật. onSuccess trả về promise thì mutation giữ isPending tới khi refetch xong, tránh cảnh nút hết loading mà danh sách chưa đổi. Cần await (submit form xong mới chuyển trang) thì dùng mutateAsync trong try/catch.

Optimistic update: hai cách

Optimistic update là cập nhật UI trước khi server trả lời, để app có cảm giác tức thì. Docs chính thức đưa ra hai cách.

Cách 1: qua UI, dùng variables. Không đụng vào cache. Trong lúc mutation đang pending, bạn render thêm một phần tử tạm từ chính dữ liệu vừa gửi:

tsx
const createProduct = useCreateProduct()

// trong JSX của danh sách
{createProduct.isPending && (
  <li style={{ opacity: 0.5 }}>{createProduct.variables.name}</li>
)}
{createProduct.isError && (
  <li style={{ color: 'red' }}>
    {createProduct.variables.name}
    <button onClick={() => createProduct.mutate(createProduct.variables)}>Thử lại</button>
  </li>
)}

Khi refetch xong, phần tử tạm biến mất và phần tử thật xuất hiện. Nếu lỗi, variables vẫn còn nên bạn hiện được nút "Thử lại". Không cần rollback. Cách này hợp khi chỉ một chỗ trên màn hình cần thấy kết quả tạm (component khác muốn thấy thì dùng useMutationState với mutationKey).

Cách 2: qua cache, dùng onMutate. Hợp khi nhiều chỗ cùng hiển thị data đó. Bạn sửa cache trực tiếp, lưu bản cũ để rollback nếu lỗi:

ts
// src/useUpdateProduct.ts
import { useMutation } from '@tanstack/react-query'
import { fetchJson, type Product } from './api'
import { productKeys, productListOptions } from './queries'

export function useUpdateProduct(category: string) {
  const { queryKey } = productListOptions(category)

  return useMutation({
    mutationFn: (product: Product) =>
      fetchJson<Product>(`/api/products/${product.id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product),
      }),
    onMutate: async (updated, context) => {
      // hủy refetch đang chạy để nó không ghi đè bản optimistic
      await context.client.cancelQueries({ queryKey })
      const previous = context.client.getQueryData(queryKey)
      context.client.setQueryData(queryKey, (old) =>
        old?.map((p) => (p.id === updated.id ? updated : p)),
      )
      return { previous }
    },
    onError: (_error, _updated, onMutateResult, context) => {
      context.client.setQueryData(queryKey, onMutateResult?.previous)
    },
    onSettled: (_data, _error, _updated, _onMutateResult, context) =>
      context.client.invalidateQueries({ queryKey: productKeys.lists() }),
  })
}

Vì queryKey lấy từ queryOptions, TypeScript biết old là Product[] | undefined mà không cần khai báo generic. Ví dụ giả định PUT không đổi category; nếu có, onSettled invalidate mọi list sẽ sửa lại cache. Lưu ý: tutorial cũ gọi giá trị trả về của onMutate là context (tham số thứ ba), còn docs v5 hiện tại gọi nó là onMutateResult và dành tên context cho tham số cuối chứa client.

useSuspenseQuery

Nếu bạn muốn khai báo trạng thái loading và lỗi bằng <Suspense> và Error Boundary thay vì if (isPending), dùng useSuspenseQuery:

tsx
import { Suspense } from 'react'
import { QueryErrorResetBoundary, useSuspenseQuery } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
import { productDetailOptions } from './queries'

function ProductDetail({ id }: { id: number }) {
  const { data } = useSuspenseQuery(productDetailOptions(id))
  return <h1>{data.name}</h1> // data luôn có giá trị, không cần check undefined
}

export function ProductPage({ id }: { id: number }) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ resetErrorBoundary }) => (
            <button onClick={() => resetErrorBoundary()}>Có lỗi, thử lại</button>
          )}
        >
          <Suspense fallback={<p>Đang tải...</p>}>
            <ProductDetail id={id} />
          </Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  )
}

Đổi lại cho sự gọn gàng, có vài giới hạn:

  • Không có enabled, nên không bật tắt query theo điều kiện được.
  • Không có placeholderData. Muốn giữ UI cũ khi đổi key thay vì hiện fallback, bọc thao tác đổi key trong startTransition.
  • Nhiều useSuspenseQuery trong cùng một component chạy tuần tự, tạo waterfall. Cần tải song song thì dùng useSuspenseQueries.

Khi nào không cần TanStack Query

App Next.js App Router hoặc framework có Server Components. Server Component có thể await data ngay trong component. Docs TanStack Query nói thẳng: app Server Components mới nên bắt đầu bằng công cụ fetch của framework, chỉ thêm React Query khi thật sự cần, có khi là không bao giờ. Chi tiết cách fetch trong Server Component có ở bài React Server Components.

Nó vẫn đáng dùng khi phía client tương tác nhiều với dữ liệu: infinite scroll, polling, optimistic update. Khi đó Server Component chỉ prefetch, rồi truyền cache xuống client qua dehydrate và HydrationBoundary. Lưu ý ở bản v5 hiện tại, prefetchQuery, fetchQuery và ensureQueryData đã được đánh dấu deprecated, thay bằng queryClient.query(). Thay thế không hoàn toàn 1-1: prefetchQuery cũ không bao giờ throw, còn query() throw khi lỗi, nên khi prefetch bạn viết void queryClient.query(productListOptions('laptop')).catch(noop) (noop import từ @tanstack/react-query). Muốn hành vi như ensureQueryData thì truyền thêm staleTime: 'static'. Các hàm cũ vẫn chạy nhưng sẽ bị bỏ ở major sau.

App nhỏ, ít data. Một landing page gọi một API lúc load, một tool nội bộ vài màn hình. Một useEffect cẩn thận hoặc một hàm fetch đơn giản là đủ.

Data không phải server state. Form đang nhập, filter trên URL, UI toggle không thuộc về query cache. Những thứ này để ở useState, URL, hoặc store client như Zustand.

So với SWR

SWR (của Vercel) giải cùng bài toán và đơn giản hơn ở phần đọc dữ liệu. Bảng dưới dựa trên docs SWR và trang so sánh của TanStack (do chính TanStack viết, nên cân nhắc cho khách quan).

TanStack Query v5SWR 2.x
API đọcuseQuery({ queryKey, queryFn })useSWR(key, fetcher)
API ghiuseMutation + invalidateQueriesuseSWRMutation, mutate
Optimistic updateonMutate hoặc variablesoptimisticData + rollbackOnError
Cấu hình staleTimeCóKhông có staleTime; điều chỉnh qua dedupingInterval, revalidateOnFocus, revalidateIfStale hoặc useSWRImmutable
Invalidate nhiều key cùng lúcTheo tiền tố key, có sẵnQua hàm filter truyền vào mutate, tự viết điều kiện khớp
Hủy request qua AbortSignalCó sẵnKhông hỗ trợ chính thức
Tự dọn cache không dùng (gc)CóKhông
FrameworkReact, Vue, Solid, Svelte, Angular...React

Chọn SWR nếu app chủ yếu đọc dữ liệu và bạn thích API tối giản. Chọn TanStack Query nếu có nhiều mutation, cần kiểm soát cache chi tiết, hoặc team dùng nhiều framework.

Sai lầm thường gặp

Ngoài các bẫy đã nhắc ở trên (tạo QueryClient trong component, quên biến trong key, spinner theo isFetching):

  • Copy data từ query sang useState. Mất luôn tính năng tự cập nhật. Dùng thẳng data, cần biến đổi thì dùng select hoặc tính khi render.
  • Tắt hết refetch vì thấy "nhiều request quá". Hãy tăng staleTime cho phù hợp dữ liệu trước.
  • Dùng query để POST/PUT/DELETE. Thay đổi dữ liệu trên server thuộc về useMutation.

Tóm lại

TanStack Query không thay thế fetch hay axios. Nó thay đoạn code bạn phải viết quanh chúng: loading, lỗi, race condition, cache, retry, refetch, cập nhật sau khi ghi. Nắm ba thứ là đủ dùng tốt: thiết kế key cẩn thận, chọn staleTime theo dữ liệu, và invalidate đúng tiền tố sau mỗi mutation.

Muốn làm chủ cả bức tranh: khi nào state thuộc server, khi nào thuộc client, khi nào dùng Server Component, và cách ghép TanStack Query vào một app thật có auth, phân trang, optimistic update? Khóa React PRO của HoleTex đi qua những phần này bằng dự án thực tế.

Bài liên quan

Nguồn tham khảo: TanStack Query Overview (tanstack.com), Important Defaults (tanstack.com), Queries (tanstack.com), Query Keys (tanstack.com), Query Options (tanstack.com), Query Cancellation (tanstack.com), Mutations (tanstack.com), Invalidations from Mutations (tanstack.com), Optimistic Updates (tanstack.com), Suspense (tanstack.com), Devtools (tanstack.com), Advanced Server Rendering (tanstack.com), Comparison (tanstack.com), @tanstack/react-query (npmjs.com), useEffect: fetching data (react.dev), SWR Mutation (swr.vercel.app), SWR API (swr.vercel.app). Cập nhật 2026-10-08.

Thấy hay? Chia sẻ