Mình từng thấy một màn hình có cả isLoading, error, data, lastFetchedAt và logic refetch tự viết trong component. Code không sai, nhưng nó đang tự dựng lại một lớp cache. TanStack Query hữu ích đúng ở đoạn đó: dữ liệu có chủ ở server, có thể đến chậm, lỗi hoặc cũ.

Bài dùng API object của TanStack Query v5 (@tanstack/react-query 5.105). Nếu bạn gặp snippet useQuery(key, fn, options), đó là dấu hiệu cần kiểm tra lại phiên bản.

BloodyCoTech chưa có API người dùng, nên package chỉ typecheck ví dụ và không nằm trong bundle trang.

Một query là một key và một hàm

Key mô tả dữ liệu. Hàm lấy dữ liệu. Mọi thứ ảnh hưởng tới kết quả phải nằm trong key, nếu không hai user sẽ dùng chung cache.

tsx
"use client";
 
import { useState, type ReactNode } from "react";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
 
type User = {
  id: string;
  name: string;
};
 
function isUser(value: unknown): value is User {
  if (typeof value !== "object" || value === null) return false;
  const record = value as Record<string, unknown>;
  return typeof record.id === "string" && typeof record.name === "string";
}
 
export function AppQueryProvider({ children }: { children: ReactNode }) {
  const [client] = useState(() => new QueryClient());
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
 
export function UserName({ userId }: { userId: string }) {
  const query = useQuery({
    queryKey: ["user", userId],
    queryFn: async ({ signal }) => {
      const response = await fetch(`/api/users/${userId}`, { signal });
      if (!response.ok) {
        throw new Error(`Request failed: ${response.status}`);
      }
      const body: unknown = await response.json();
      if (!isUser(body)) {
        throw new Error("Invalid user payload");
      }
      return body;
    },
  });
 
  if (query.isPending) return <p>Đang tải…</p>;
  if (query.isError) return <p>Không tải được dữ liệu.</p>;
  return <p>{query.data.name}</p>;
}

queryKey có userId. Đổi id là một cache entry khác. signal được truyền vào fetch để request cũ bị hủy khi query không còn cần. Đó là việc nên làm, không phải tối ưu sau.

response.json() trả về unknown theo cách ví dụ này viết, rồi một type guard kiểm tra id và name. Ép as User không kiểm tra gì lúc chạy. Nếu API đổi field, giao diện sẽ đọc undefined mà TypeScript vẫn tin. Guard thì ném lỗi, và Query đưa lỗi đó vào isError.

Provider và vòng đời của client

useQuery cần QueryClientProvider ở phía trên. Tạo QueryClient bằng useState để mỗi lần render không tạo client mới và xóa cache. Trong ví dụ, AppQueryProvider làm đúng việc đó.

Một client cho cả cây là đủ với hầu hết app. Tạo client trong từng trang làm mất cache khi điều hướng nếu component bị unmount và tạo lại. Đặt provider gần gốc của phần app có dữ liệu server, không nhất thiết bọc cả một website tĩnh không gọi API.

staleTime, gcTime, và các cờ trạng thái

Dữ liệu vừa fetch được coi là stale ngay nếu staleTime là 0, tức mặc định. Lần mount sau có thể refetch. Đặt staleTime vài chục giây hoặc vài phút khi dữ liệu không cần mới hơn thế, ví dụ hồ sơ hiển thị. Đây là lựa chọn theo nghiệp vụ, không có một số mặc định đúng cho mọi API.

gcTime là thời gian giữ cache sau khi không còn observer. Ở v4 tên này là cacheTime. Bài viết và snippet cũ dùng cacheTime sẽ không khớp kiểu của v5.

isPending nghĩa là chưa có data. Dùng nó cho lần tải đầu. isFetching nghĩa là đang có request, kể cả refetch khi data cũ vẫn còn trên màn hình. Đừng giấu cả màn hình sau một spinner chỉ vì isFetching bật trong lúc refetch. Giữ data cũ, và chỉ báo nhẹ nếu bạn cần.

isError đi với query.error. Hãy hiển thị một câu người đọc hiểu được. Đừng render error.message thô nếu message đó chứa chi tiết nội bộ.

Mutation không nằm trong query

Sửa dữ liệu trên server là useMutation. Khi mutation thành công, invalidateQueries với key liên quan bảo các query đó là stale và fetch lại. Đừng tự ghi đè cache bằng một object bạn đoán là server sẽ trả, trừ khi bạn cố ý làm cập nhật lạc quan và có đường hoàn tác khi request lỗi.

Key của invalidation phải khớp phần prefix của query. invalidateQueries({ queryKey: ["user"] }) làm cũ mọi query bắt đầu bằng user. Nếu bạn chỉ muốn một id, hãy đưa id vào key lúc invalidate.

Khi không dùng TanStack Query

Đừng dùng Query cho theme, ô input, hay điểm game. Những thứ đó không có server, không có refetch, và không nên có isPending. Chúng thuộc bài về Context, Zustand và Jotai.

Đừng dùng Query trên một trang tĩnh không có API. Không có request thì không có server state. Thêm provider chỉ để “sau này có API” là một dependency chưa cần.

Đừng dùng Query như một loader tổng quát bọc localStorage. Đọc storage là đồng bộ và có thể ném lỗi quota hoặc JSON hỏng. Xử lý nó bằng hàm có kiểm tra, như trong bài localStorage, không bằng cache request.

Đừng copy một ví dụ useEffect cộng fetch rồi bọc Query lên trên mà vẫn giữ useEffect. Query đã gọi queryFn khi component mount. Thêm effect là hai đường fetch.

Static export và Query

Query chạy trên client sau khi trang đã tải. Nó tương thích với một website xuất tĩnh, miễn là queryFn gọi một API thật sự tồn tại ở đâu đó. Nó không tạo API đó. Với Next.js static export, không có route handler chạy theo request. Nếu API chưa có, Query không phải bước tiếp theo. Bài React và Next.js nói về ranh giới này.

Nếu sau này BloodyCoTech có công cụ cần đọc API, Query là chỗ đứng của dữ liệu đó. Cho đến lúc có endpoint, các bài trên site không giả lập một client.

Việc nên khóa lại trước khi thêm query mới

  1. Viết key gồm mọi tham số của request.
  2. Kiểm tra response.ok và hình dạng JSON, đừng ép kiểu.
  3. Chọn staleTime theo độ cũ bạn chấp nhận.
  4. Tách client state sang store hoặc useState.
  5. Invalidate đúng key sau mutation, rồi mới nghĩ tới cập nhật lạc quan.

Làm năm bước đó trước khi thêm middleware, persist, hay một lớp wrapper riêng. Phần lớn app không cần wrapper. Chúng cần key ổn định và một hàm fetch nói thật khi lỗi.

Tài liệu