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.
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.
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
npm i @tanstack/react-query
npm i -D @tanstack/react-query-devtools @tanstack/eslint-plugin-queryVới app Vite, bọc toàn bộ app trong QueryClientProvider:
// 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.
// 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.
// 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ị:
// 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
categorylà 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
isPendingvàisError, TypeScript biếtdatachắ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:
- Key là mảng, phần tử phải serialize được bằng
JSON.stringify. - Mọi biến mà
queryFndùng đều phải có trong key. Đổicategorymà key không đổi thì bạn nhận data của danh mục cũ. Ruleexhaustive-depstrong@tanstack/eslint-plugin-querybắt lỗi này giúp bạn. - 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
staleTime | gcTime | |
|---|---|---|
| Trả lời câu hỏi | Data 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 định | 0 (vừa tải xong đã coi là stale) | 5 phút |
| Bắt đầu đếm | Từ 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ầm | Data 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ằnginvalidateQueries.'static': chặt hơn cảInfinity, kể cảinvalidateQueriescũ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ũ:
// 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() }),
})
}// 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:
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:
// 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:
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 trongstartTransition. - Nhiều
useSuspenseQuerytrong cùng một component chạy tuần tự, tạo waterfall. Cần tải song song thì dùnguseSuspenseQueries.
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 v5 | SWR 2.x | |
|---|---|---|
| API đọc | useQuery({ queryKey, queryFn }) | useSWR(key, fetcher) |
| API ghi | useMutation + invalidateQueries | useSWRMutation, mutate |
| Optimistic update | onMutate hoặc variables | optimisticData + rollbackOnError |
| Cấu hình staleTime | Có | Không có staleTime; điều chỉnh qua dedupingInterval, revalidateOnFocus, revalidateIfStale hoặc useSWRImmutable |
| Invalidate nhiều key cùng lúc | Theo tiền tố key, có sẵn | Qua hàm filter truyền vào mutate, tự viết điều kiện khớp |
| Hủy request qua AbortSignal | Có sẵn | Không hỗ trợ chính thức |
| Tự dọn cache không dùng (gc) | Có | Không |
| Framework | React, 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ẳngdata, cần biến đổi thì dùngselecthoặc tính khi render. - Tắt hết refetch vì thấy "nhiều request quá". Hãy tăng
staleTimecho 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
- React Hooks: useState và useEffect
- Quản lý state trong React
- React Server Components
- Zustand là gì
- REST API là gì
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.