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.
Why This Library Exists {Vì sao thư viện này tồn tại}
Most React bugs around data are not UI bugs — they are server-state bugs {Hầu hết bug dữ liệu trong React không phải bug UI — chúng là bug server-state}: stale data, double fetches, race conditions, loading spinners that never end {dữ liệu cũ, fetch trùng, race condition, spinner quay mãi không dừng}.
TanStack Query (formerly React Query) is async server-state management {TanStack Query (trước đây là React Query) là quản lý server-state bất đồng bộ}. It does not replace Redux/Zustand for client state {Nó không thay Redux/Zustand cho state client}; it owns the data that lives on your server and is merely cached on the client {nó sở hữu dữ liệu nằm ở server và chỉ được cache ở client}.
By the end you should be able to apply it confidently, know its trade-offs, and understand exactly what you lose by not using it {Đọ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ó}.
Version note {Lưu ý phiên bản}: this guide targets TanStack Query v5 (
@tanstack/react-query), which requires React 18+ {bài này nhắm TanStack Query v5, yêu cầu React 18+}.
Server State vs Client State {Server State vs Client State}
The single most important mental model {Mô hình tư duy quan trọng nhất}: not all state is the same {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
The mistake most teams make {Sai lầm phổ biến}: storing server data in Redux/Zustand as if it were client state {lưu dữ liệu server trong Redux/Zustand như thể nó là client state}. Server state is a cache, not a source of truth {Server state là cache, không phải nguồn chân lý} — and caches need invalidation, staleness rules, and background refresh {và cache cần invalidation, quy tắc cũ, và refresh nền}. That is precisely what TanStack Query provides {Đó đúng là thứ TanStack Query cung cấp}.
The Pain Without It {Nỗi đau khi không dùng nó}
Let’s hand-roll a “simple” fetch {Cùng tự viết tay một fetch “đơn giản”}. This is what most tutorials show {Đâ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 };
}
It looks fine — until production {Nhìn ổn — cho tới khi lên production}. Here is everything this code does not handle {Đây là mọi thứ đoạn code này không xử lý}:
- No caching {Không cache}: every component that calls
useUsersrefetches from scratch {mỗi component gọiuseUsersđều fetch lại từ đầu}. - No deduplication {Không khử trùng lặp}: render the hook in 3 components → 3 identical network requests {render hook ở 3 component → 3 request mạng giống hệt}.
- No background refetch {Không refetch nền}: data goes stale and never updates on tab focus or reconnect {dữ liệu cũ đi và không bao giờ cập nhật khi focus tab hay reconnect}.
- No retries {Không retry}: one flaky network blip = an error screen {một cú chập chờn mạng = màn hình lỗi}.
- Manual race handling {Tự xử lý race}: the
cancelledflag is easy to forget; with params it gets ugly fast {cờcancelleddễ quên; có params vào là rối ngay}. - No shared updates {Không cập nhật chung}: mutate a user in one place, every other view shows stale data {sửa user một chỗ, các view khác hiện dữ liệu cũ}.
- No pagination/infinite {Không phân trang/vô hạn}: you reinvent page caching and “keep previous data” {bạn phải tự chế cache trang và “giữ dữ liệu cũ”}.
- No devtools {Không devtools}: debugging cache state is
console.logarchaeology {debug cache là khảo cổ bằngconsole.log}.
The punchline {Điểm mấu chốt}: if you keep fixing these, you will slowly rebuild a worse, untested version of TanStack Query {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}. That is the real cost of “not using it” {Đó là cái giá thật của việc “không dùng nó”}.
Core Concept 1 — Queries {Khái niệm 1 — Query}
A query is a declarative subscription to async data, identified by a query key {Một query là subscription khai báo tới dữ liệu async, định danh bằng query key}. In v5 the signature is a single options object {Ở 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>
);
}
That tiny block already gives you caching, dedupe, retries, background refetch, and race-safety for free {Khối nhỏ đó đã cho bạn cache, dedupe, retry, refetch nền, và an toàn race miễn phí}.
Query keys {Query key}
The query key is the cache identity {Query key là danh tính cache}. Same key = same cache entry (shared + deduped); different key = different entry {Cùng key = cùng entry cache (chia sẻ + dedupe); khác key = entry khác}. Always include every input the queryFn depends on {Luôn đưa vào mọi input mà queryFn phụ thuộc}:
useQuery({
queryKey: ['user', userId, { status: 'active' }],
queryFn: () => fetchUser(userId, { status: 'active' }),
});
When userId changes, the key changes, and Query fetches (and caches) the new entry automatically {Khi userId đổi, key đổi, và Query tự fetch (và cache) entry mới}. Treat the key like a dependency array you never forget {Coi key như mảng dependency mà bạn không bao giờ quên}.
Status vs fetchStatus {Status vs fetchStatus}
A frequent v5 confusion {Một điểm hay nhầm ở v5}. There are two independent states {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{isPending}: no data in cache yet (first ever load) {chưa có dữ liệu trong cache (lần load đầu tiên)}.isFetching{isFetching}: a request is in flight right now — including silent background refetches {đang có request chạy — kể cả refetch nền im lặng}.isLoading{isLoading}: shorthand forisPending && isFetching(the first hard load) {viết tắt choisPending && isFetching(lần load cứng đầu tiên)}.
This separation is why Query can show cached data and refresh in the background without a spinner flash {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}.
Core Concept 2 — Caching: staleTime vs gcTime {Khái niệm 2 — Cache: staleTime vs gcTime}
These two options confuse everyone, so be precise {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)
A mental picture {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
Default refetch triggers when a query is stale {Trigger refetch mặc định khi query stale}: on component mount, on window refocus, and on network reconnect {khi component mount, khi cửa sổ focus lại, và khi mạng kết nối lại}. Tune them globally or per query {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,
});
Rule of thumb {Quy tắc ngón cái}: set a non-zero staleTime for data that doesn’t change every second {đặt staleTime khác 0 cho dữ liệu không đổi mỗi giây} — it removes most “why is it refetching?” surprises {nó loại bỏ hầu hết bất ngờ “sao nó refetch?”}.
Core Concept 3 — Mutations {Khái niệm 3 — Mutation}
Queries read; mutations write (POST/PUT/PATCH/DELETE) {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 is the heartbeat of staying in sync {invalidateQueries là nhịp tim của việc giữ đồng bộ}: after a write, mark related queries stale so they refresh {sau khi ghi, đánh dấu các query liên quan là stale để chúng refresh}.
Migration gotcha {Bẫy khi nâng cấp}: in v5, the
onSuccess/onError/onSettledcallbacks were removed fromuseQuery{ở v5, các callbackonSuccess/onError/onSettledđã bị gỡ khỏiuseQuery}. They still exist onuseMutation{Chúng vẫn còn trênuseMutation}. For query side effects, react to the returneddatainstead {Với side effect của query, hãy phản ứng theodatatrả về}.
Optimistic updates {Cập nhật lạc quan}
Update the UI before the server confirms, then roll back on error {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
},
});
Doing this by hand without a cache layer is genuinely hard to get right {Tự làm điều này mà không có lớp cache thì rất khó làm đúng} — this is one of Query’s biggest wins {đây là một trong những điểm thắng lớn nhất của Query}.
Setup — QueryClient {Cài đặt — QueryClient}
One provider at the app root {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>
);
}
The Devtools alone justify adoption {Riêng Devtools đã đủ lý do dùng}: you can see every query, its status, staleness, and cached data live {bạn thấy mọi query, status, độ cũ, và dữ liệu cache trực tiếp}.
Practical Patterns {Các pattern thực chiến}
Pagination without flicker {Phân trang không nhấp nháy}
In v5, keepPreviousData was replaced by placeholderData {Ở 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
});
Infinite scroll {Cuộn vô hạn}
v5 useInfiniteQuery requires initialPageParam and getNextPageParam {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,
});
Dependent queries {Query phụ thuộc}
Run a query only after another resolves with enabled {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
});
Transform with select {Biến đổi với select}
Derive/narrow data without re-rendering on unrelated changes {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,
});
Prefetching {Prefetch}
Warm the cache before navigation for instant pages {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),
});
This composes with SSR/streaming via the hydration boundary (dehydrate / HydrationBoundary) in Next.js and other frameworks {Điều này kết hợp với SSR/streaming qua hydration boundary (dehydrate / HydrationBoundary) trong Next.js và framework khác}.
Strengths {Điểm mạnh}
- Eliminates a whole bug class {Xoá cả một lớp bug}: caching, dedupe, race-safety, retries are solved and battle-tested {cache, dedupe, an toàn race, retry đã được giải và kiểm chứng}.
- Less code {Ít code hơn}: a
useQueryreplaces a custom hook + reducer + effect cleanup {mộtuseQuerythay cho custom hook + reducer + dọn effect}. - Great DX {DX tuyệt vời}: first-class Devtools and excellent TypeScript inference {Devtools hạng nhất và suy luận TypeScript tốt}.
- Framework-agnostic core {Lõi không phụ thuộc framework}: React, Vue, Svelte, Solid adapters share concepts {adapter React, Vue, Svelte, Solid chung khái niệm}.
- Background freshness {Tươi mới ở nền}: refetch on focus/reconnect keeps the UI honest {refetch khi focus/reconnect giữ UI trung thực}.
- Composable {Ghép được}: optimistic updates, pagination, infinite, prefetch, SSR hydration all built in {… đều tích hợp}.
Weaknesses & When NOT to Use It {Điểm yếu & Khi nào KHÔNG dùng}
It is not a silver bullet {Nó không phải viên đạn bạc}:
- It is not a client-state manager {Không phải quản lý client-state}: modal open, form draft, theme → use
useState/Zustand, not Query {… dùnguseState/Zustand, không phải Query}. - Learning curve {Đường cong học}: keys, staleTime vs gcTime, invalidation take time to internalize {key, staleTime vs gcTime, invalidation cần thời gian thấm}.
- Bundle size {Kích thước bundle}: ~12-14KB gzipped — negligible for an app, but overkill for a single fetch on a landing page {… không đáng kể cho app, nhưng thừa cho một fetch đơn trên landing page}.
- Not a data-fetching client {Không phải client fetch dữ liệu}: it manages async state but you still bring
fetch/axios/graphql-request{nó quản lý state async nhưng bạn vẫn tự mangfetch/axios/graphql-request}. - Overlap with framework loaders {Chồng lấn với loader của framework}: Next.js App Router (RSC +
fetchcache) or Remix/React Router loaders may already cover server data {Next.js App Router hoặc loader Remix/React Router có thể đã lo phần dữ liệu server}. Use Query for client-side interactive caching, not to fight the framework {Dùng Query cho cache tương tác phía client, không phải để chống lại framework}.
When to skip it {Khi nào bỏ qua}: a static page with one request and no interactivity, or a fully RSC app where the server owns all data fetching {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}.
What You Lose Without It {Mất gì khi không có nó}
A blunt comparison {So sánh thẳng}:
| Concern {Vấn đề} | With TanStack Query | Hand-rolled {Tự viết tay} |
|---|---|---|
| Caching across components {Cache xuyên component} | Automatic {Tự động} | Build a cache + keys yourself {Tự xây cache + key} |
| Request dedupe {Khử trùng lặp} | Automatic {Tự động} | Manual in-flight map {Tự map request đang chạy} |
| Background refetch {Refetch nền} | Built in {Tích hợp} | Focus/reconnect listeners by hand {Tự nghe focus/reconnect} |
| Retries + backoff {Retry + backoff} | Config option {Một option} | Custom retry loop {Vòng retry tự viết} |
| Race conditions {Race condition} | Handled {Đã xử lý} | Easy to get wrong {Dễ sai} |
| Pagination / infinite {Phân trang / vô hạn} | placeholderData / useInfiniteQuery | Reinvent page cache {Tự chế cache trang} |
| Optimistic updates {Cập nhật lạc quan} | First-class {Hạng nhất} | Fragile rollback logic {Logic rollback dễ vỡ} |
| Debugging {Gỡ lỗi} | Devtools {Devtools} | console.log {console.log} |
The difficulty isn’t writing one fetch {Cái khó không phải viết một fetch} — it’s keeping dozens of them consistent, fresh, and de-duplicated across a growing app {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}. That maintenance burden is exactly what you take on by not using it {Gánh nặng bảo trì đó chính là thứ bạn ôm vào khi không dùng nó}.
Best Practices {Thực hành tốt}
- Centralize query keys {Tập trung query key}: a
queryKeysfactory avoids typo-driven cache misses {factoryqueryKeystránh miss cache do gõ sai}. - Set a global
staleTime{ĐặtstaleTimeglobal}: stops the “refetches constantly” complaint {chặn than phiền “refetch liên tục”}. - Encapsulate in custom hooks {Đóng gói vào custom hook}:
useUsers(),useUser(id)— keep components clean {… giữ component sạch}. - Invalidate, don’t manually setState {Invalidate, đừng tự setState} unless doing optimistic updates {trừ khi làm optimistic update}.
- Keep server state out of Redux/Zustand {Giữ server state ngoài Redux/Zustand}: let Query own it {để Query sở hữu nó}.
Quick Reference {Tham khảo nhanh}
| Need {Cần} | API |
|---|---|
| Read data {Đọc dữ liệu} | useQuery({ queryKey, queryFn }) |
| Write data {Ghi dữ liệu} | useMutation({ mutationFn }) |
| Refresh after write {Refresh sau khi ghi} | queryClient.invalidateQueries({ queryKey }) |
| Keep page during load {Giữ trang khi load} | placeholderData: keepPreviousData |
| Infinite list {Danh sách vô hạn} | useInfiniteQuery + getNextPageParam |
| Run conditionally {Chạy có điều kiện} | enabled: !!dep |
| Derive data {Dẫn xuất dữ liệu} | select: (d) => ... |
| Warm cache {Làm nóng cache} | queryClient.prefetchQuery(...) |
| Fresh window {Cửa sổ tươi} | staleTime |
| Cache retention {Giữ cache} | gcTime |
Summary {Tóm tắt}
TanStack Query treats server data as what it actually is — a cache of remote state {TanStack Query xem dữ liệu server đúng bản chất — một cache của state từ xa} — and gives you caching, deduping, background refresh, retries, mutations, and optimistic updates as declarative defaults {và cho bạn cache, dedupe, refresh nền, retry, mutation, và optimistic update dưới dạng mặc định khai báo}. Its weaknesses are real but narrow {Điểm yếu của nó có thật nhưng hẹp}: it is not for client state, adds a learning curve, and overlaps with modern framework loaders {nó không cho client state, có đường cong học, và chồng lấn với loader framework hiện đại}. The strongest argument for it is the cost of the alternative {Lập luận mạnh nhất cho nó là cái giá của phương án thay thế}: hand-rolling server-state correctly means rebuilding this library, badly {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}.
Related reading {Đọc thêm}: Frontend Caching deep dive and Signals & Fine-Grained Reactivity.