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.

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ờ cancelled dễ 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 cho isPending && 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 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ỏi useQuery. Chúng vẫn còn trên useMutation. Với side effect của query, hãy phản ứng theo data trả 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 initialPageParamgetNextPageParam:

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 useQuery thay 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 QueryTự viết tay
Cache xuyên componentTự độngTự xây cache + key
Khử trùng lặpTự độngTự map request đang chạy
Refetch nềnTích hợpTự nghe focus/reconnect
Retry + backoffMột optionVòng retry tự viết
Race conditionĐã xử lýDễ sai
Phân trang / vô hạnplaceholderData / useInfiniteQueryTự chế cache trang
Cập nhật lạc quanHạng nhấtLogic rollback dễ vỡ
Gỡ lỗiDevtoolsconsole.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

  1. Tập trung query key: factory queryKeys tránh miss cache do gõ sai.
  2. Đặt staleTime global: chặn than phiền “refetch liên tục”.
  3. Đóng gói vào custom hook: useUsers(), useUser(id) — … giữ component sạch.
  4. Invalidate, đừng tự setState trừ khi làm optimistic update.
  5. Giữ server state ngoài Redux/Zustand: để Query sở hữu nó.

Tham khảo nhanh

CầnAPI
Đọc dữ liệuuseQuery({ queryKey, queryFn })
Ghi dữ liệuuseMutation({ mutationFn })
Refresh sau khi ghiqueryClient.invalidateQueries({ queryKey })
Giữ trang khi loadplaceholderData: keepPreviousData
Danh sách vô hạnuseInfiniteQuery + getNextPageParam
Chạy có điều kiệnenabled: !!dep
Dẫn xuất dữ liệuselect: (d) => ...
Làm nóng cachequeryClient.prefetchQuery(...)
Cửa sổ tươistaleTime
Giữ cachegcTime

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: