jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 3 — Query keys, zod & queryOptions

Thiết kế query key phân cấp chống gõ sai, viết apiFetch validate dữ liệu tại biên bằng zod để any không lọt vào app, gói query bằng queryOptions tái dùng, và làm dependent/parallel queries.

Hai phần trước ta fetch bằng queryKey: ['users']fetch().then(r => r.json()) cho nhanh. Trong dự án thật, cách đó nhanh chóng sinh bug: query key rải rác dễ gõ sai, và res.json() trả về any khiến TypeScript “tin server mù quáng”. Phần này dựng nền tảng chuẩn cho phần còn lại của series:

  1. Một hệ query key phân cấp chống gõ sai và giúp invalidation chính xác.
  2. Một apiFetch dùng chung validate dữ liệu tại biên bằng zod.
  3. Gói query bằng queryOptions để tái dùng giữa useQuery, prefetch, setQueryData.
  4. Dependentparallel queries.

1. Query key — định danh của cache

Query key là danh tính (identity) của một mẩu dữ liệu trong cache. Mỗi key được băm thành một chuỗi ổn định; chuỗi đó là khoá trong Map nội bộ của QueryCache. Cùng key → cùng cache entry → chia sẻ data, status, observer. Khác key → entry mới hoàn toàn.

queryKey: ['customers','list',{ status:'active' }]

        ▼  hashKey()  — JSON.stringify ổn định, SORT key của object
 '["customers","list",{"status":"active"}]'

        ▼  dùng làm khoá
 QueryCache  Map<hash, Query>
   ├─ '["customers"]'                              → Query
   ├─ '["customers","list",{"status":"active"}]'    → Query   ← entry này
   └─ '["customers","detail","42"]'                → Query

Hai quy tắc vàng:

  • Key phải mô tả đầy đủ mọi thứ mà queryFn phụ thuộc. queryFn dùng userIdfilters → cả hai phải nằm trong key. (Như “dependency array” của useEffect, nhưng cho cache.)
  • Key dạng mảng, đi từ tổng quát → cụ thể: ['customers', 'list', { status: 'active' }]. Cấu trúc này mở khoá invalidation theo tiền tố (mục 3).

Băm key là xác định (deterministic)

React Query băm key bằng một bản JSON.stringify ổn định: nó sắp xếp key của object trước khi stringify. Hệ quả:

// Thứ tự field trong OBJECT không quan trọng → cùng hash → cùng entry
['todos', { status: 'done', page: 1 }]
['todos', { page: 1, status: 'done' }] // ← GIỐNG nhau

// Nhưng thứ tự phần tử trong MẢNG thì quan trọng → khác hash → khác entry
['todos', 'done', 1]
['todos', 1, 'done'] // ← KHÁC nhau

// Khác giá trị → khác key → khác cache entry
['todos', { status: 'active', page: 1 }]
['todos', { status: 'active', page: 2 }]

Vì sao object thì độc-lập-thứ-tự còn mảng thì không? Mảng mã hoá thứ bậc (tổng quát→cụ thể) nên thứ tự là ngữ nghĩa; object mã hoá tập tham số nên thứ tự field chỉ là ngẫu nhiên. hashKey tôn trọng cả hai.

Vì key đã chứa mọi phụ thuộc, bạn không cần dependency array thủ công: đổi tham số trong key ⇒ Query tự fetch lại với một cache entry riêng cho tham số mới (entry cũ vẫn còn). Đây cũng là lý do Query miễn nhiễm race condition (Phần 1).

Cái gì được phép nằm trong key?

Key phải serializableổn định giữa các lần render. Bảng tra:

Loại giá trịHợp lệ trong key?Ghi chú
string, number, booleanNguyên thuỷ, băm ổn định
nullBăm thành null
Mảng các giá trị trênThứ tự quan trọng
Object phẳng (plain)Thứ tự field không quan trọng
Object lồng object/mảngMiễn mọi lá đều serializable
undefined trong object⚠️Bị bỏ qua khi stringify → dễ nhầm; tránh
Date, Map, Set, instance classKhông serialize ổn định → key “trôi”
Hàm, Symbol, BigIntKhông stringify được

Quy tắc thực dụng: chỉ nhét nguyên thuỷ và plain object/array vào key. Cần Date? Đưa date.toISOString(). Cần Set? Đưa [...set].sort().


2. Query key factory — chống gõ sai

Rải string literal khắp nơi (['customers'] chỗ này, ['customer', id] chỗ kia — số ít/số nhiều đã lệch!) là mầm bug: gõ sai một ký tự = âm thầm tạo cache thứ hai, hoặc invalidation trượt. Giải pháp: gom toàn bộ key của một feature vào một factory duy nhất.

// src/features/customers/keys.ts
export const customerKeys = {
  all: ['customers'] as const,
  lists: () => [...customerKeys.all, 'list'] as const,
  list: (filters: { q?: string; status?: string }) =>
    [...customerKeys.lists(), filters] as const,
  details: () => [...customerKeys.all, 'detail'] as const,
  detail: (id: string) => [...customerKeys.details(), id] as const,
};

as const giữ key là tuple readonly để TypeScript suy đúng literal type (không bị “mở rộng” thành string[]). Vì mỗi tầng trải (...) tầng trên, các key tạo thành một cây có tiền tố chung:

all              ['customers']
 ├─ lists()      ['customers','list']
 │   └─ list(f)  ['customers','list', { …filters }]
 └─ details()    ['customers','detail']
     └─ detail(id) ['customers','detail', id]

Bảng tra factory → key array:

GọiKey sinh raDùng khi
customerKeys.all['customers']Invalidate mọi thứ liên quan customer
customerKeys.lists()['customers','list']Invalidate mọi danh sách (mọi filter)
customerKeys.list({ q })['customers','list',{ q }]Một danh sách cụ thể
customerKeys.details()['customers','detail']Invalidate mọi chi tiết
customerKeys.detail('42')['customers','detail','42']Một chi tiết cụ thể

alltiền tố của mọi key khác, invalidation có thể chính xác hoặc lan rộng tuỳ tầng bạn chọn:

queryClient.invalidateQueries({ queryKey: customerKeys.detail('42') }); // chỉ 1 chi tiết
queryClient.invalidateQueries({ queryKey: customerKeys.lists() });      // mọi danh sách
queryClient.invalidateQueries({ queryKey: customerKeys.all });          // tất cả customer

Đặt factory cạnh schema và queryOptions của feature (src/features/customers/). Một feature = một file keys. Đừng có hai nơi cùng định nghĩa key cho customer.


3. Khớp tiền tố (fuzzy matching) — vì sao factory đáng giá

invalidateQueries({ queryKey }) không đòi khớp tuyệt đối. Mặc định nó khớp theo tiền tố: mọi query có key bắt đầu bằng mảng bạn đưa đều bị nhắm tới. Đây là lý do thiết kế key phân cấp quan trọng đến vậy.

Giả sử cache đang có các entry:

A ['customers']
B ['customers','list',{ status:'active' }]
C ['customers','list',{ status:'churned' }]
D ['customers','detail','42']
E ['customers','detail','99']
F ['invoices','list']

Bảng: filter nào khớp entry nào?

Filter queryKeyKhớpKhông khớp
['customers']A, B, C, D, EF
['customers','list']B, CA, D, E, F
['customers','list',{ status:'active' }]Bcòn lại
['customers','detail']D, Ecòn lại
['customers','detail','42']Dcòn lại
['invoices']Fcòn lại

Hai công cụ tinh chỉnh độ rộng:

// exact: chỉ khớp ĐÚNG key này (tắt khớp tiền tố)
queryClient.invalidateQueries({ queryKey: customerKeys.all, exact: true }); // chỉ A

// predicate: lọc tuỳ ý khi key không đủ diễn đạt điều kiện
queryClient.invalidateQueries({
  predicate: (q) => {
    const [scope, kind, filters] = q.queryKey;
    return (
      scope === 'customers' &&
      kind === 'list' &&
      typeof filters === 'object' &&
      filters !== null &&
      'status' in filters &&
      filters.status === 'churned' // → chỉ C
    );
  },
});

Cùng cơ chế khớp này áp cho removeQueries, cancelQueries, refetchQueries, getQueriesData, setQueriesData. Học một lần, dùng khắp nơi. Chi tiết invalidation thực chiến ở Phần 5–6.


4. Validate tại biên với zod

Tài liệu: zod.

Vấn đề

res.json() trả Promise<any>. TypeScript tin server vô điều kiện. Nếu API đổi tên field, hay trả về null bất ngờ, bạn không phát hiện ở biên mà ở tận sâu trong component — một crash khó truy. Quy tắc của project (xem prohibitions): không any, không as trừ khi đã validate runtime.

Giải pháp: apiFetch + zod

// src/lib/api-client.ts
import { z } from 'zod';

export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

const BASE_URL = import.meta.env.VITE_API_URL ?? '';

/**
 * Fetch + validate tại biên. Đọc `unknown` rồi parse bằng schema,
 * nên `any` không bao giờ lọt vào trong app.
 */
export async function apiFetch<T>(
  path: string,
  schema: z.ZodType<T>,
  init?: RequestInit,
): Promise<T> {
  const res = await fetch(`${BASE_URL}${path}`, {
    headers: { 'Content-Type': 'application/json', ...init?.headers },
    ...init,
  });

  if (!res.ok) {
    throw new ApiError(res.status, `Request lỗi: ${res.status}`);
  }

  const json: unknown = await res.json();
  // Kiểm tra hình dạng dữ liệu TRƯỚC khi nó vào app — fail to, fail sớm.
  return schema.parse(json);
}

Định nghĩa schema theo feature và suy type từ schema — một nguồn chân lý cho cả runtime lẫn compile time:

// src/features/customers/schema.ts
import { z } from 'zod';

export const customerSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  plan: z.enum(['starter', 'pro', 'enterprise']),
  mrr: z.number(),
  status: z.enum(['active', 'trial', 'churned']),
});

export const customerListSchema = z.array(customerSchema);

// Suy type TỪ schema → validator và type không bao giờ lệch nhau.
export type Customer = z.infer<typeof customerSchema>;

z.infer lấy type Customer từ schema, nên khi schema đổi, type tự đổi theo. Bạn không bao giờ phải viết một interface Customer song song rồi quên cập nhật.

as nói dối, parse kiểm thật

data as Customer[]customerListSchema.parse(data)
Khi nào kiểmKhông bao giờ (chỉ compile-time)Runtime, tại biên
API đổi fieldIm lặng → crash ở chỗ khácNém ZodError ngay, đúng chỗ
null/thiếu fieldLọt qua → cannot read x of nullBắt ngay với đường dẫn field
Nguồn typeTay viết, dễ lệchz.infer — luôn khớp validator
An toàn❌ “tin tưởng mù”✅ “tin nhưng có kiểm”

Khi muốn xử lý lỗi mềm (không ném), dùng safeParse:

const parsed = customerListSchema.safeParse(json);
if (!parsed.success) {
  // parsed.error.issues: danh sách field sai + đường dẫn
  throw new ApiError(422, 'Dữ liệu API không hợp lệ');
}
return parsed.data; // đã có type Customer[]

Đây là chỗ duy nhất nên có “ép kiểu”: ngay sau parse/safeParse, type đã được runtime bảo chứng. Mọi chỗ khác cấm as — vì lúc đó nó chỉ là lời hứa suông.


5. Gói query bằng queryOptions

Tài liệu: queryOptions.

Thay vì rải { queryKey, queryFn } khắp nơi, gói chúng vào một helper queryOptions. Lợi ích: một định nghĩa, tái dùng giữa useQuery, useQueries, prefetchQuery, setQueryData… và type liên kết chặt giữa key và data.

// src/features/customers/api.ts
import { queryOptions } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api-client';
import { customerListSchema, customerSchema } from './schema';
import { customerKeys } from './keys';

export function customersQuery(filters: { q?: string } = {}) {
  return queryOptions({
    queryKey: customerKeys.list(filters),
    queryFn: () => {
      // Dựng query string thủ công — KHÔNG `as`, đúng tinh thần "không ép kiểu thiếu kiểm".
      const params = new URLSearchParams();
      if (filters.q) params.set('q', filters.q);
      return apiFetch(`/customers?${params}`, customerListSchema);
    },
  });
}

export function customerQuery(id: string) {
  return queryOptions({
    queryKey: customerKeys.detail(id),
    queryFn: () => apiFetch(`/customers/${id}`, customerSchema),
  });
}

Hook của feature trở nên mỏng dính:

// src/features/customers/hooks.ts
import { useQuery } from '@tanstack/react-query';
import { customersQuery, customerQuery } from './api';

export function useCustomers(filters: { q?: string } = {}) {
  return useQuery(customersQuery(filters));
}

export function useCustomer(id: string) {
  return useQuery(customerQuery(id));
}

Và component chỉ còn lo render:

function CustomersTable() {
  const { data, isPending, isError, error } = useCustomers();

  if (isPending) return <TableSkeleton />;
  if (isError) return <ErrorState message={error.message} />;
  if (data.length === 0) return <EmptyState label="Chưa có khách hàng" />;

  return <DataTable rows={data} />; // data có type Customer[] đầy đủ
}

Một định nghĩa, nhiều nơi dùng

customerQuery(id) giờ là một “viên gạch” tái dùng. Cùng object đó cắm vào mọi API của Query — và type (key ⇄ data) luôn khớp:

APICách dùng lạiĐược gì
useQueryuseQuery(customerQuery(id))Đọc trong component
useQueriesqueries: ids.map(customerQuery)Danh sách song song (mục 8)
prefetchQueryqc.prefetchQuery(customerQuery(id))Nạp trước khi điều hướng
ensureQueryDataawait qc.ensureQueryData(customerQuery(id))Lấy data (cache hoặc fetch) trong loader
setQueryDataqc.setQueryData(customerQuery(id).queryKey, next)Ghi cache với key đúng kiểu
getQueryDataqc.getQueryData(customerQuery(id).queryKey)Đọc cache, suy ra Customer | undefined
// Prefetch khi hover link — không lặp lại key/queryFn/schema
function onHover(id: string) {
  queryClient.prefetchQuery(customerQuery(id));
}

// setQueryData lấy key TỪ chính options → không thể lệch key
queryClient.setQueryData(customerQuery('42').queryKey, (old) => old);

Cái win: trước đây key sống ở component, queryFn ở chỗ khác, type ở file thứ ba — ba nguồn dễ lệch. queryOptions gộp cả ba thành một giá trị có type liên kết: sai key là sai type, IDE bắt ngay.

Quy ước của project: tách logic khỏi UI. Key/schema/queryFn nằm ở lib/feature, component chỉ gọi hook và render trạng thái. Đây cũng là điều giúp test dễ (Phần 8).


6. Dependent queries — query phụ thuộc query khác

Đôi khi query B cần kết quả của query A. Ví dụ: lấy user trước, rồi mới lấy projects của user đó. Dùng option enabled để hoãn query B cho tới khi có dữ liệu:

function UserProjects({ email }: { email: string }) {
  // Query 1: lấy user theo email
  const userQ = useQuery({
    queryKey: ['user', email],
    queryFn: () => apiFetch(`/users?email=${email}`, userSchema),
  });

  const userId = userQ.data?.id;

  // Query 2: chỉ chạy khi đã có userId
  const projectsQ = useQuery({
    queryKey: ['projects', userId],
    queryFn: () => apiFetch(`/users/${userId}/projects`, projectListSchema),
    enabled: Boolean(userId), // ← hoãn cho tới khi userId tồn tại
  });

  // Khi enabled=false, query ở trạng thái pending nhưng fetchStatus='idle'
  if (projectsQ.isPending) return <Spinner />;
  // ...
}

Khi enabled: false, query không chạy queryFn; nó nằm ở status: 'pending', fetchStatus: 'idle'. Nhớ điều này để render đúng (đừng tưởng nhầm là đang loading mạng).

enabledstatusfetchStatusÝ nghĩa render
false'pending''idle'Đang chờ điều kiện — đừng hiện spinner mạng
true, lần đầu'pending''fetching'Loading thật — hiện skeleton
true, có data'success''idle'Render data

Mẹo phân biệt: dùng isLoading (= isPending && isFetching) khi muốn hỏi “có đang tải mạng lần đầu không”; isPending đơn thuần chỉ nói “chưa có data” — đúng cả khi đang bị enabled:false chặn.


7. Parallel queries — chạy song song, tránh waterfall

“Waterfall” là sát thủ hiệu năng: component A fetch xong mới render B, B rồi mới fetch của nó — các round-trip nối đuôi nhau. Nếu các query độc lập, hãy bắn song song.

Hai useQuery trong cùng component đã tự chạy song song:

function Dashboard() {
  const stats = useQuery(statsQuery());          // hai cái này
  const activity = useQuery(recentActivityQuery()); // chạy SONG SONG
  // ...
}

Với một danh sách động các query, dùng useQueries:

import { useQueries } from '@tanstack/react-query';

function CustomerCards({ ids }: { ids: string[] }) {
  const results = useQueries({
    queries: ids.map((id) => customerQuery(id)), // tái dùng queryOptions ở mục 4
  });

  const isLoading = results.some((r) => r.isPending);
  // ...
}

Phản mẫu cần tránh: render cha (fetch), và chỉ trong nhánh thành công của cha mới mount con (fetch). Điều đó nối tiếp hai request độc lập. Hãy đưa các query độc lập lên cùng cấp để chúng chồng lên nhau.

WATERFALL (xấu)              SONG SONG (tốt)
A ───▶ done                  A ───▶ done
       B ───▶ done          B ───▶ done   (chồng lên A)
       (B chờ A vô cớ)      tổng thời gian ≈ max(A,B)
tổng ≈ A + B
Tình huốngDùngVì sao
Vài query cố định, độc lậpnhiều useQuery anh emTự song song, code phẳng
Query B cần data của AuseQuery + enabled (mục 6)Buộc phải tuần tự
Số lượng query động (theo mảng)useQueriesKhông vi phạm Rules of Hooks
Cần gộp kết quả nhiều queryuseQueries({ combine })Trả về một giá trị đã gộp, ổn định

8. Gotchas thường gặp

Triệu chứngNguyên nhânCách sửa
Đổi filter/trang nhưng data không đổiKey thiếu phụ thuộc (['orders'] thay vì ['orders', userId, page])Đưa đủ biến vào key
Hai cache “ma” cho cùng dữ liệuGõ sai literal ('customer' vs 'customers')Dùng key factory, cấm string rời
Key “trôi” mỗi render, query refetch loạnNhét new Date()/{}/hàm vào keyChỉ dùng nguyên thuỷ + plain object; serialize Date
Invalidate một thứ nhưng mọi thứ refetchFilter quá rộng (all thay vì lists())Chọn đúng tầng tiền tố, hoặc exact: true
data vẫn là any, mất gợi ý IDEqueryFn trả res.json() chưa parseValidate bằng zod, suy type qua z.infer
setQueryData không cập nhật UIGhi sai key (lệch object/thứ tự mảng)Lấy key từ queryOptions(...).queryKey
enabled:false hiện spinner mãiHiểu nhầm isPending = đang tảiPhân biệt status vs fetchStatus (mục 6)

9. Recipes nhanh

“Public API” gọn gàng của một feature (keys + schema + options + hooks ở cùng chỗ):

// src/features/customers/index.ts
export { customerKeys } from './keys';
export { customerSchema, customerListSchema, type Customer } from './schema';
export { customersQuery, customerQuery } from './api';
export { useCustomers, useCustomer } from './hooks';

Invalidate sau khi sửa một customer (đón đầu Phần 5):

function useUpdateCustomer() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (input: Customer) =>
      apiFetch(`/customers/${input.id}`, customerSchema, {
        method: 'PUT',
        body: JSON.stringify(input),
      }),
    onSuccess: (saved) => {
      qc.setQueryData(customerKeys.detail(saved.id), saved); // cập nhật chi tiết
      qc.invalidateQueries({ queryKey: customerKeys.lists() }); // làm tươi danh sách
    },
  });
}

Gộp nhiều query thành một giá trị với useQueries({ combine }):

function CustomerMrr({ ids }: { ids: string[] }) {
  const totalMrr = useQueries({
    queries: ids.map((id) => customerQuery(id)),
    combine: (results) =>
      results.reduce((sum, r) => sum + (r.data?.mrr ?? 0), 0),
  });
  return <strong>{totalMrr}</strong>;
}

10. Bài tập

1. Vì sao nên validate res.json() bằng zod thay vì ép kiểu as Customer[]?

Lời giải

as là “lời nói dối lúc compile” — bảo TypeScript tin dữ liệu runtime chưa kiểm. Nếu API đổi/lỗi, type sai âm thầm và crash ở chỗ khác. schema.parse kiểm hình dạng thật tại biên và ném lỗi ngay khi lệch, biến lỗi runtime mơ hồ thành lỗi rõ ràng đúng chỗ.

2. Một query có queryFn dùng userIdpage, nhưng queryKey chỉ là ['orders']. Bug gì sẽ xảy ra?

Lời giải

Mọi userId/page chia sẻ cùng một cache entry → đổi trang hay đổi user vẫn trả data cũ (cache hit sai), và refetch ghi đè lẫn nhau. Key phải chứa đủ phụ thuộc: ['orders', userId, page].

3. Khi enabled: false, statusfetchStatus của query là gì?

Lời giải

status: 'pending' (chưa có data) nhưng fetchStatus: 'idle' (không gọi mạng). Đừng hiển thị spinner “đang tải mạng” cho trạng thái này; nên hiện placeholder kiểu “đang chờ điều kiện”.

4. Cache có các key ['customers'], ['customers','list',{status:'active'}], ['customers','detail','42']. Gọi invalidateQueries({ queryKey: ['customers','list'] }) sẽ làm tươi những entry nào?

Lời giải

Chỉ ['customers','list',{status:'active'}]. Khớp theo tiền tố: entry phải bắt đầu bằng ['customers','list']. ['customers'] ngắn hơn (không chứa đủ tiền tố), còn ['customers','detail','42'] rẽ nhánh detail nên đều trượt.

5. ['t', { a: 1, b: 2 }]['t', { b: 2, a: 1 }] có cùng cache entry không? Còn ['t', 1, 2] với ['t', 2, 1]?

Lời giải

Cặp object: cùng entry — hashKey sort field object trước khi stringify nên thứ tự field không đổi danh tính. Cặp mảng: khác entry — thứ tự phần tử mảng là ngữ nghĩa (tổng quát→cụ thể) nên được giữ nguyên khi băm.

Nâng cao: Viết customerKeys cho feature của bạn, một apiFetch + zod schema, và queryOptions cho cả list lẫn detail. Thêm hook useCustomer(id) rồi render ở trang chi tiết.


Tóm tắt

  • Query key là danh tính cache: được băm xác định (object không phụ thuộc thứ tự field, mảng thì có). Key phải chứa đủ mọi phụ thuộc của queryFn, và chỉ chứa giá trị serializable.
  • Dùng key factory mỗi feature (all/lists/list/details/detail) để chống gõ sai và mở khoá invalidation phân cấp.
  • invalidateQueries khớp theo tiền tố — chọn đúng tầng để nhắm hẹp hay rộng; exact: true hoặc predicate khi cần tinh chỉnh.
  • Validate mọi response bằng zod tại biên trong apiFetch, suy type qua z.inferany không lọt vào app, và as chỉ hợp lệ ngay sau parse.
  • Gói query bằng queryOptions để tái dùng giữa useQuery/useQueries/prefetchQuery/ensureQueryData/setQueryData, với key ⇄ data type liên kết.
  • Dependent query dùng enabled (phân biệt status vs fetchStatus); parallel query dùng useQuery anh em hoặc useQueries để tránh waterfall.

Phần tiếp theo

Phần 4 — Pagination & useInfiniteQuery: giữ trang cũ mượt mà khi chuyển trang với placeholderData: keepPreviousData, rồi dựng infinite scroll thực thụ bằng useInfiniteQuery với getNextPageParam/fetchNextPage và một IntersectionObserver.