TanStack Query (React Query) — The Complete Practical Guide (2026)
A bilingual, hands-on guide to TanStack Query v5: server vs client state, useQuery/useMutation, caching (staleTime vs gcTime), optimistic updates, pagination, strengths, weaknesses, and what you lose without it.
Vì sao thư viện này tồn tại
Hầu hết bug dữ liệu trong React không phải bug UI — chúng là bug server-state: dữ liệu cũ, fetch trùng, race condition, spinner quay mãi không dừng.
TanStack Query (trước đây là React Query) là quản lý server-state bất đồng bộ. Nó không thay Redux/Zustand cho state client; nó sở hữu dữ liệu nằm ở server và chỉ được cache ở client.
Đọc xong bạn nên áp dụng được tự tin, biết đánh đổi, và hiểu rõ mất gì khi không dùng nó.
Lưu ý phiên bản: bài này nhắm TanStack Query v5, yêu cầu React 18+.
Server State vs Client State
Mô hình tư duy quan trọng nhất: không phải state nào cũng giống nhau.
CLIENT STATE {State client} SERVER STATE {State server}
- owned by your app - owned by a remote server
- synchronous, always "true" - asynchronous, only a SNAPSHOT
- e.g. modal open, theme, form input - e.g. user list, product, profile
- tools: useState, Zustand, Redux - can go STALE at any moment
- shared across users/tabs
- tools: TanStack Query
Sai lầm phổ biến: lưu dữ liệu server trong Redux/Zustand như thể nó là client state. Server state là cache, không phải nguồn chân lý — và cache cần invalidation, quy tắc cũ, và refresh nền. Đó đúng là thứ TanStack Query cung cấp.
Nỗi đau khi không dùng nó
Cùng tự viết tay một fetch “đơn giản”. Đây là thứ hầu hết tutorial chỉ:
function useUsers() {
const [data, setData] = useState<User[] | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetch('/api/users')
.then((r) => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((e) => {
if (!cancelled) setError(e);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true; // avoid setState after unmount / race
};
}, []);
return { data, error, isLoading };
}
Nhìn ổn — cho tới khi lên production. Đây là mọi thứ đoạn code này không xử lý:
- Không cache: mỗi component gọi
useUsersđều fetch lại từ đầu. - Không khử trùng lặp: render hook ở 3 component → 3 request mạng giống hệt.
- Không refetch nền: dữ liệu cũ đi và không bao giờ cập nhật khi focus tab hay reconnect.
- Không retry: một cú chập chờn mạng = màn hình lỗi.
- Tự xử lý race: cờ
cancelleddễ quên; có params vào là rối ngay. - Không cập nhật chung: sửa user một chỗ, các view khác hiện dữ liệu cũ.
- Không phân trang/vô hạn: bạn phải tự chế cache trang và “giữ dữ liệu cũ”.
- Không devtools: debug cache là khảo cổ bằng
console.log.
Điểm mấu chốt: nếu cứ sửa tiếp, bạn sẽ dần xây lại một bản TanStack Query tệ hơn, không được test. Đó là cái giá thật của việc “không dùng nó”.
Khái niệm 1 — Query
Một query là subscription khai báo tới dữ liệu async, định danh bằng query key. Ở v5, chữ ký là một object options duy nhất:
import { useQuery } from '@tanstack/react-query';
function Users() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to fetch users');
return res.json() as Promise<User[]>;
},
});
if (isPending) return <Spinner />;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Khối nhỏ đó đã cho bạn cache, dedupe, retry, refetch nền, và an toàn race miễn phí.
Query key
Query key là danh tính cache. Cùng key = cùng entry cache (chia sẻ + dedupe); khác key = entry khác. Luôn đưa vào mọi input mà queryFn phụ thuộc:
useQuery({
queryKey: ['user', userId, { status: 'active' }],
queryFn: () => fetchUser(userId, { status: 'active' }),
});
Khi userId đổi, key đổi, và Query tự fetch (và cache) entry mới. Coi key như mảng dependency mà bạn không bao giờ quên.
Status vs fetchStatus
Một điểm hay nhầm ở v5. Có hai trạng thái độc lập:
status → do we HAVE data? : 'pending' | 'error' | 'success'
fetchStatus → is the queryFn RUNNING? : 'fetching' | 'paused' | 'idle'
isPending: chưa có dữ liệu trong cache (lần load đầu tiên).isFetching: đang có request chạy — kể cả refetch nền im lặng.isLoading: viết tắt choisPending && isFetching(lần load cứng đầu tiên).
Sự tách bạch này là lý do Query có thể hiện dữ liệu cache và refresh nền mà không nháy spinner.
Khái niệm 2 — Cache: staleTime vs gcTime
Hai option này ai cũng nhầm, nên nói chính xác:
staleTime — how long data is considered FRESH.
{staleTime — dữ liệu được coi là MỚI trong bao lâu}
fresh → reads from cache, NO refetch.
stale → still shown, but refetched in background on triggers.
default: 0 (data is stale immediately)
gcTime — how long an UNUSED (no observers) cache entry is kept
{gcTime — entry cache KHÔNG còn ai dùng được giữ bao lâu}
before garbage collection.
default: 5 minutes (300_000 ms). (was `cacheTime` in v4)
Hình dung:
mount ──fetch──▶ FRESH ──(staleTime elapses)──▶ STALE ──(component unmounts)──▶
cached & shown cached & shown, still cached for gcTime,
refetch on focus/mount then garbage-collected
Trigger refetch mặc định khi query stale: khi component mount, khi cửa sổ focus lại, và khi mạng kết nối lại. Chỉnh global hoặc theo từng query:
useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: 5 * 60 * 1000, // fresh for 5 min → no needless refetch
gcTime: 30 * 60 * 1000, // keep unused cache for 30 min
refetchOnWindowFocus: false,
});
Quy tắc ngón cái: đặt staleTime khác 0 cho dữ liệu không đổi mỗi giây — nó loại bỏ hầu hết bất ngờ “sao nó refetch?”.
Khái niệm 3 — Mutation
Query đọc; mutation ghi (POST/PUT/PATCH/DELETE):
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useAddUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newUser: NewUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then((r) => r.json()),
onSuccess: () => {
// Invalidate → the users list refetches and shows the new row.
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// usage
const addUser = useAddUser();
addUser.mutate({ name: 'Vinh' });
invalidateQueries là nhịp tim của việc giữ đồng bộ: sau khi ghi, đánh dấu các query liên quan là stale để chúng refresh.
Bẫy khi nâng cấp: ở v5, các callback
onSuccess/onError/onSettledđã bị gỡ khỏiuseQuery. Chúng vẫn còn trênuseMutation. Với side effect của query, hãy phản ứng theodatatrả về.
Cập nhật lạc quan
Cập nhật UI trước khi server xác nhận, rồi rollback nếu lỗi:
useMutation({
mutationFn: toggleTodo,
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
);
return { previous }; // context passed to onError
},
onError: (_err, _todo, context) => {
queryClient.setQueryData(['todos'], context?.previous); // rollback
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); // reconcile
},
});
Tự làm điều này mà không có lớp cache thì rất khó làm đúng — đây là một trong những điểm thắng lớn nhất của Query.
Cài đặt — QueryClient
Một provider ở gốc app:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // sensible global default
retry: 2,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Routes />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Riêng Devtools đã đủ lý do dùng: bạn thấy mọi query, status, độ cũ, và dữ liệu cache trực tiếp.
Các pattern thực chiến
Phân trang không nhấp nháy
Ở v5, keepPreviousData được thay bằng placeholderData:
import { keepPreviousData, useQuery } from '@tanstack/react-query';
useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData, // show old page while next loads
});
Cuộn vô hạn
v5 useInfiniteQuery yêu cầu initialPageParam và getNextPageParam:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
Query phụ thuộc
Chạy query chỉ sau khi query khác xong, dùng enabled:
const { data: user } = useQuery({ queryKey: ['me'], queryFn: fetchMe });
useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id),
enabled: !!user?.id, // wait until we have an id
});
Biến đổi với select
Dẫn xuất/thu hẹp dữ liệu mà không re-render khi phần khác đổi:
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) => users.filter((u) => u.active).length,
});
Prefetch
Làm nóng cache trước khi điều hướng để trang hiện tức thì:
await queryClient.prefetchQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
Điều này kết hợp với SSR/streaming qua hydration boundary (dehydrate / HydrationBoundary) trong Next.js và framework khác.
Điểm mạnh
- Xoá cả một lớp bug: cache, dedupe, an toàn race, retry đã được giải và kiểm chứng.
- Ít code hơn: một
useQuerythay cho custom hook + reducer + dọn effect. - DX tuyệt vời: Devtools hạng nhất và suy luận TypeScript tốt.
- Lõi không phụ thuộc framework: adapter React, Vue, Svelte, Solid chung khái niệm.
- Tươi mới ở nền: refetch khi focus/reconnect giữ UI trung thực.
- Ghép được: … đều tích hợp.
Điểm yếu & Khi nào KHÔNG dùng
Nó không phải viên đạn bạc:
- Không phải quản lý client-state: … dùng
useState/Zustand, không phải Query. - Đường cong học: key, staleTime vs gcTime, invalidation cần thời gian thấm.
- Kích thước bundle: … không đáng kể cho app, nhưng thừa cho một fetch đơn trên landing page.
- Không phải client fetch dữ liệu: nó quản lý state async nhưng bạn vẫn tự mang
fetch/axios/graphql-request. - Chồng lấn với loader của framework: Next.js App Router hoặc loader Remix/React Router có thể đã lo phần dữ liệu server. Dùng Query cho cache tương tác phía client, không phải để chống lại framework.
Khi nào bỏ qua: trang tĩnh một request không tương tác, hoặc app RSC thuần nơi server lo toàn bộ fetch.
Mất gì khi không có nó
So sánh thẳng:
| Vấn đề | With TanStack Query | Tự viết tay |
|---|---|---|
| Cache xuyên component | Tự động | Tự xây cache + key |
| Khử trùng lặp | Tự động | Tự map request đang chạy |
| Refetch nền | Tích hợp | Tự nghe focus/reconnect |
| Retry + backoff | Một option | Vòng retry tự viết |
| Race condition | Đã xử lý | Dễ sai |
| Phân trang / vô hạn | placeholderData / useInfiniteQuery | Tự chế cache trang |
| Cập nhật lạc quan | Hạng nhất | Logic rollback dễ vỡ |
| Gỡ lỗi | Devtools | console.log |
Cái khó không phải viết một fetch — mà là giữ hàng chục cái nhất quán, tươi mới, và không trùng trong một app lớn dần. Gánh nặng bảo trì đó chính là thứ bạn ôm vào khi không dùng nó.
Thực hành tốt
- Tập trung query key: factory
queryKeystránh miss cache do gõ sai. - Đặt
staleTimeglobal: chặn than phiền “refetch liên tục”. - Đóng gói vào custom hook:
useUsers(),useUser(id)— … giữ component sạch. - Invalidate, đừng tự setState trừ khi làm optimistic update.
- Giữ server state ngoài Redux/Zustand: để Query sở hữu nó.
Tham khảo nhanh
| Cần | API |
|---|---|
| Đọc dữ liệu | useQuery({ queryKey, queryFn }) |
| Ghi dữ liệu | useMutation({ mutationFn }) |
| Refresh sau khi ghi | queryClient.invalidateQueries({ queryKey }) |
| Giữ trang khi load | placeholderData: keepPreviousData |
| Danh sách vô hạn | useInfiniteQuery + getNextPageParam |
| Chạy có điều kiện | enabled: !!dep |
| Dẫn xuất dữ liệu | select: (d) => ... |
| Làm nóng cache | queryClient.prefetchQuery(...) |
| Cửa sổ tươi | staleTime |
| Giữ cache | gcTime |
Tóm tắt
TanStack Query xem dữ liệu server đúng bản chất — một cache của state từ xa — và cho bạn cache, dedupe, refresh nền, retry, mutation, và optimistic update dưới dạng mặc định khai báo. Điểm yếu của nó có thật nhưng hẹp: nó không cho client state, có đường cong học, và chồng lấn với loader framework hiện đại. Lập luận mạnh nhất cho nó là cái giá của phương án thay thế: tự viết server-state cho đúng nghĩa là xây lại thư viện này, một cách tệ hơn.
Đọc thêm: