jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 useUsers refetches from scratch {mỗi component gọi useUsers đề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 cancelled flag is easy to forget; with params it gets ugly fast {cờ cancelled dễ 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.log archaeology {debug cache là khảo cổ bằng console.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 for isPending && isFetching (the first hard load) {viết tắt cho isPending && 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 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 / onSettled callbacks were removed from useQuery {ở v5, các callback onSuccess / onError / onSettled đã bị gỡ khỏi useQuery}. They still exist on useMutation {Chúng vẫn còn trên useMutation}. For query side effects, react to the returned data instead {Với side effect của query, hãy phản ứng theo data trả 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 initialPageParamgetNextPageParam}:

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 useQuery replaces a custom hook + reducer + effect cleanup {một useQuery thay 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ùng useState/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ự mang fetch/axios/graphql-request}.
  • Overlap with framework loaders {Chồng lấn với loader của framework}: Next.js App Router (RSC + fetch cache) 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 QueryHand-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 / useInfiniteQueryReinvent 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}

  1. Centralize query keys {Tập trung query key}: a queryKeys factory avoids typo-driven cache misses {factory queryKeys tránh miss cache do gõ sai}.
  2. Set a global staleTime {Đặt staleTime global}: stops the “refetches constantly” complaint {chặn than phiền “refetch liên tục”}.
  3. Encapsulate in custom hooks {Đóng gói vào custom hook}: useUsers(), useUser(id) — keep components clean {… giữ component sạch}.
  4. Invalidate, don’t manually setState {Invalidate, đừng tự setState} unless doing optimistic updates {trừ khi làm optimistic update}.
  5. 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.