jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 18 — Kiến trúc production & Migration (Capstone)

Gói 18 phần thành một query layer theo feature: cấu trúc thư mục, quy ước loading/error, devtools & logging cho production, lộ trình migrate v4 → v5, so sánh với RTK Query/SWR, và checklist production-ready.

Đây là bài kết của series. Mười bảy phần trước cho bạn từng mảnh — query, cache, mutation, SSR, offline, realtime, hiệu năng, type. Phần này lắp chúng thành một kiến trúc mà cả team áp dụng được, rồi cho bạn lộ trình migrate v4 → v5, so sánh với hàng xóm (RTK Query, SWR), và một checklist để tự chấm điểm dự án.

Mục tiêu: rời series với một bản thiết kế trong đầu cho lớp data của bất kỳ app React nào — đủ chi tiết để mở một repo trống và gõ ra cấu trúc thư mục từ trí nhớ.

Bài này tổng hợp 17 phần trước. Mỗi mục dưới đây dẫn ngược về phần đã đào sâu cơ chế; ở đây ta chỉ chốt cách ghép chúng lại thành quy ước cho cả team.


1. Vì sao cần một “query layer”

Khi app nhỏ, gọi useQuery({ queryKey, queryFn }) thẳng trong component thấy gọn. Nhưng đến khi 30 component cùng đọc customers, bạn gặp bốn vấn đề kinh điển:

Triệu chứngNguyên nhân gốcHậu quả
Cache “không invalidate”Mỗi nơi gõ key một kiểu (['customers'] vs ['customer','list'])Mutation invalidate trượt key → UI cũ
Type lệchqueryFn trả any, mỗi component tự castLỗi runtime, refactor sợ hãi
Đổi endpoint = sửa 30 chỗURL + fetch logic rải khắp componentPR khổng lồ, dễ sót
Khó testComponent vừa lo UI vừa lo fetchMock nửa vời, test giòn

Gốc rễ: server state là một tài nguyên dùng chung, nhưng code lại đối xử với nó như biến cục bộ của component. Query layer kéo phần “đồng bộ server state” ra khỏi component và đặt nó sau một biên giới rõ ràng:

TRƯỚC (không có layer)              SAU (có query layer)

Component ──gọi──► useQuery          Component ──gọi──► useCustomers()
   │  key tự gõ, fn tự viết             │  chỉ biết hook, không biết key
   │  type tự cast                       ▼
   ▼                                  hooks.ts ─► queries.ts ─► api.ts
fetch('/api/...')                         keys.ts  schema.ts (zod)
(lặp lại ở 30 nơi)                     (một nguồn sự thật / feature)

Lợi ích đo được: đổi cách fetch sửa một file; key tập trung nên invalidate luôn trúng; type chảy từ zod schema ra tới component không cast tay; test mock ở tầng api thay vì mock fetch toàn cục.


2. Cấu trúc thư mục theo feature

Đừng gom mọi query vào một queries.ts khổng lồ. Tổ chức theo feature (vertical slice), mỗi feature tự chứa key, schema, api, query/mutation options và hook:

src/
├── lib/
│   ├── query-client.ts        # QueryClient + defaultOptions + caches (Phần 9)
│   └── api-client.ts          # apiFetch + ApiError + zod helper (Phần 3)
├── app/
│   └── providers.tsx          # QueryClientProvider / Persist / Hydration
└── features/
    └── customers/
        ├── keys.ts            # customerKeys (factory phân cấp)
        ├── schema.ts          # zod schema + z.infer types
        ├── api.ts             # fetchCustomers, createCustomer... (throw ApiError)
        ├── queries.ts         # queryOptions: customersQuery, customerQuery
        ├── mutations.ts       # mutationOptions: createCustomerMutation...
        ├── hooks.ts           # useCustomers, useCreateCustomer (gói cho UI)
        └── components/        # UI riêng của feature (tùy chọn)

Trách nhiệm từng file — hợp đồng để cả team gõ giống nhau:

FileChịu trách nhiệmKHÔNG được chứaPhụ thuộc
keys.tsKey factory phân cấp, duy nhất 1 root/featureFetch, React
schema.tszod schema + z.infer typeNetwork, keyzod
api.tsGọi mạng, parse zod, throw ApiErrorReact hook, keyschema, api-client
queries.tsqueryOptions({ queryKey, queryFn })useQuery, JSXkeys, api
mutations.tsmutationOptions({ mutationFn })useMutation, JSXkeys, api
hooks.tsuseQuery/useMutation gói options thành hookFetch tay, key tayqueries, mutations

Hướng phụ thuộc luôn một chiều: keys/schema → api → queries/mutations → hooks → component. Không có mũi tên ngược. Đó là điều giúp refactor an toàn.


3. Giải phẫu một feature (6 file)

Một feature đầy đủ, từ ngoài vào trong. Mỗi file ngắn và một-trách-nhiệm.

keys.ts — nguồn sự thật cho key

// features/customers/keys.ts
import type { CustomerFilters } from './schema';

export const customerKeys = {
  all: ['customers'] as const,
  lists: () => [...customerKeys.all, 'list'] as const,
  list: (filters: CustomerFilters) => [...customerKeys.lists(), filters] as const,
  details: () => [...customerKeys.all, 'detail'] as const,
  detail: (id: string) => [...customerKeys.details(), id] as const,
};

schema.ts — zod là biên kiểu

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

export const customerSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});
export const customerListSchema = z.array(customerSchema);

export type Customer = z.infer<typeof customerSchema>;
export type CustomerFilters = { q?: string; status?: 'active' | 'churned' };

api.ts — parse ngay tại ranh giới

// features/customers/api.ts
import { apiFetch } from '@/lib/api-client';
import { customerSchema, customerListSchema } from './schema';
import type { Customer, CustomerFilters } from './schema';

export async function fetchCustomers(filters: CustomerFilters): Promise<Customer[]> {
  const data = await apiFetch('/customers', { query: filters });
  return customerListSchema.parse(data); // sau dòng này type đã chuẩn, không cast tay
}

export async function createCustomer(
  input: Omit<Customer, 'id' | 'createdAt'>,
): Promise<Customer> {
  const data = await apiFetch('/customers', { method: 'POST', body: input });
  return customerSchema.parse(data);
}

queries.tsqueryOptions để type chảy đi khắp nơi

// features/customers/queries.ts
import { queryOptions } from '@tanstack/react-query';
import { customerKeys } from './keys';
import { fetchCustomers } from './api';
import type { CustomerFilters } from './schema';

export function customersQuery(filters: CustomerFilters = {}) {
  return queryOptions({
    queryKey: customerKeys.list(filters),
    queryFn: () => fetchCustomers(filters),
    staleTime: 30_000,
  });
}

mutations.tsmutationOptions (v5.80+) hoặc factory object

// features/customers/mutations.ts
import { mutationOptions } from '@tanstack/react-query';
import { customerKeys } from './keys';
import { createCustomer } from './api';

export function createCustomerMutation() {
  return mutationOptions({
    mutationFn: createCustomer,
    meta: { invalidates: [customerKeys.lists()] }, // MutationCache đọc meta (Phần 9)
  });
}

Nếu bản TanStack Query của bạn chưa có mutationOptions (helper thêm ở v5.80+), trả về một object literal có type UseMutationOptions cũng đạt cùng mục tiêu — quan trọng là factory tập trung ở mutations.ts.

hooks.ts — biên giới duy nhất UI được chạm

// features/customers/hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { customersQuery } from './queries';
import { createCustomerMutation } from './mutations';
import { customerKeys } from './keys';
import type { CustomerFilters } from './schema';

export function useCustomers(filters: CustomerFilters = {}) {
  return useQuery(customersQuery(filters));
}

export function useCreateCustomer() {
  const qc = useQueryClient();
  return useMutation({
    ...createCustomerMutation(),
    onSuccess: () => qc.invalidateQueries({ queryKey: customerKeys.lists() }),
  });
}

Component sẽ không bao giờ biết key ['customers','list',...] tồn tại. Nó chỉ biết useCustomers. Đổi key, đổi staleTime, thêm select — tất cả nằm trong feature, UI không đụng tới.


4. Quy tắc vàng: component chỉ import hooks.ts

Đây là quy ước quan trọng nhất của cả kiến trúc. Hướng import phải một chiều:

✅ ĐƯỢC                          ❌ KHÔNG ĐƯỢC
component → hooks.ts             component → queries.ts (gọi useQuery tay)
                                 component → api.ts     (fetch tay, mất cache)
                                 component → keys.ts    (gõ key inline)
Vi phạmVì sao nguy hiểm
useQuery({ queryKey: ['customers'] }) trong componentKey lệch với factory → invalidate trượt
Gọi fetchCustomers() trong useEffectMất cache, dedup, retry — quay về thời tiền-Query
Import customerKeys vào component để setQueryDataCache mutation rải khắp UI, khó truy vết

Đừng chỉ trông vào kỷ luật — enforce bằng lint. Chặn features/*/api, features/*/keys, features/*/queries khỏi tầng component:

// eslint.config.js (rút gọn)
'no-restricted-imports': ['error', {
  patterns: [{
    group: ['@/features/*/api', '@/features/*/keys', '@/features/*/queries'],
    message: 'Component chỉ được import từ features/<x>/hooks',
  }],
}]

Component dùng feature giờ rất sạch — không URL, không key, không zod:

import { useCustomers } from '@/features/customers/hooks';

function CustomerList() {
  const { data, isPending, isError } = useCustomers({ status: 'active' });
  if (isPending) return <Spinner />;
  if (isError) return <ErrorState />;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

5. Quy ước loading/error: Suspense vs cờ trạng thái

Mỗi team nên chốt một cách xử lý loading/error và áp khắp nơi (Phần 7). Hai trường phái:

CáchLoadingErrorComponentHợp khi
Imperative (cờ)if (isPending)if (isError)Tự render mọi stateCần kiểm soát inline, granular
Declarative (Suspense)<Suspense><ErrorBoundary>Sạch, chỉ lo happy-pathGom loading/error ở biên route

Suspense vs cờ — đánh đổi cụ thể:

useQuery + cờuseSuspenseQuery
data ở happy pathT | undefined (phải narrow)T (đã chắc chắn)
LoadingisPending trong component<Suspense fallback> ở cha
ErrorisError trong component<ErrorBoundary> ở cha
enabled/skipTokenKhông (luôn chạy)
Rủi ro waterfallThấpCao nếu xếp tuần tự → useSuspenseQueries

Khuyến nghị cho app mới: declarativeuseSuspenseQuery + một <Suspense>/<ErrorBoundary> cho mỗi route, cộng một QueryCache.onError toàn cục (Phần 9) cho toast/log. Đừng trộn hai cách trong cùng một feature — gây khó đoán.

// app/providers.tsx — loading/error gom ở biên route
<ErrorBoundary FallbackComponent={RouteError}>
  <Suspense fallback={<RouteSkeleton />}>
    <CustomerList /> {/* dùng useSuspenseQuery, component sạch */}
  </Suspense>
</ErrorBoundary>

Quy ước rõ giúp reviewer chỉ liếc qua là biết đúng/sai. “Mỗi route một boundary” dễ enforce hơn “mỗi component tự lo state”.


6. Sở hữu query key theo feature

Mỗi feature sở hữu root key của mình; không feature nào gõ key của feature khác.

features/customers/keys.ts  ──owns──►  ['customers', ...]
features/orders/keys.ts     ──owns──►  ['orders', ...]
features/billing/keys.ts    ──owns──►  ['billing', ...]
Quy tắcLý do
1 root array/feature (['customers'])Invalidate cả feature = { queryKey: customerKeys.all }
Cross-feature invalidate đi qua factory đã exportorders cần refresh customers? Dùng customerKeys.lists(), đừng gõ ['customers','list']
Key chỉ chứa giá trị serialize ổn địnhHash key phải nhất quán giữa các render (Phần 3)

Khi feature A cần invalidate feature B, import factory đã export của B — vẫn một nguồn sự thật, không chép chuỗi key. Muốn ranh giới chặt hơn nữa, B export sẵn một hook useInvalidateCustomers() để A gọi mà không cần biết cả factory.


7. Devtools & logging trong production

Devtools mặc định chỉ vào dev (process.env.NODE_ENV). Nhưng đôi khi cần debug production (bug chỉ xảy ra trên prod). Lazy-load bản production có điều kiện để không thêm bytes vào bundle thường:

import { lazy, Suspense, useState, useEffect } from 'react';

const ReactQueryDevtoolsProd = lazy(() =>
  import('@tanstack/react-query-devtools/production').then((d) => ({
    default: d.ReactQueryDevtools,
  })),
);

function useDevtoolsToggle() {
  const [on, setOn] = useState(false);
  useEffect(() => {
    // bật bằng console: window.__toggleDevtools()
    (window as unknown as { __toggleDevtools?: () => void }).__toggleDevtools = () =>
      setOn((v) => !v);
  }, []);
  return on;
}

function App() {
  const showDevtools = useDevtoolsToggle();
  return (
    <>
      {/* ...app... */}
      {showDevtools && (
        <Suspense fallback={null}>
          <ReactQueryDevtoolsProd />
        </Suspense>
      )}
    </>
  );
}

Cho observability, gắn vào cả hai cache (Phần 12): QueryCache cho lỗi đọc, MutationCache cho lỗi ghi.

// lib/query-client.ts
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) =>
      logToObservability('query_error', { key: query.queryKey, error }),
  }),
  mutationCache: new MutationCache({
    onError: (error, _vars, _ctx, mutation) =>
      logToObservability('mutation_error', { key: mutation.options.mutationKey, error }),
  }),
});

Nên log gì trong production:

Sự kiệnNguồnDùng để
Query errorQueryCache.onErrorPhát hiện endpoint hỏng, 5xx tăng đột biến
Mutation errorMutationCache.onErrorTheo dõi ghi thất bại (mất tiền/đơn)
Slow querydataUpdatedAt - fetchStartTìm endpoint chậm
Cache sizegetQueryCache().getAll().lengthCảnh báo rò bộ nhớ

Đừng log dữ liệu nhạy cảm (PII) trong payload. Chỉ log key + mã lỗi, không log nguyên data.


8. Migrate v4 → v5

Nếu kế thừa codebase v4, đây là các breaking change quan trọng nhất — đọc kỹ trước khi npm install v5:

v4v5LoạiGhi chú
useQuery(key, fn, opts)useQuery({ queryKey, queryFn })SignatureChỉ còn một object argument cho mọi hook
isLoadingisPendingRenameisPending = chưa có data; isLoading cũ ≈ isPending && isFetching
isInitialLoadingisLoading (đã đổi nghĩa)RenameisInitialLoading bị deprecate; dùng isLoading mới
status: 'loading'status: 'pending'EnumMọi chỗ so === 'loading' đổi thành 'pending'
cacheTimegcTimeRename”garbage collection time”, ý nghĩa giữ nguyên
keepPreviousData: trueplaceholderData: keepPreviousDataAPIImport hàm keepPreviousData từ package
onSuccess/onError/onSettled trên useQueryĐã bỏRemovedChuyển sang QueryCache global hoặc useEffect
useInfiniteQuery không cần page paramBắt buộc initialPageParamRequiredgetNextPageParam(lastPage, pages, lastPageParam) cũng đổi chữ ký
mutation status: 'loading'status: 'pending'Enummutation.status === 'pending'
useQueries({ queries })thêm combineThêmGộp kết quả nhiều query (Phần 16)
HydrateHydrationBoundaryRename(Phần 10)
contextSharing propĐã bỏRemovedDùng QueryClientProvider thường

Bỏ callback trên useQuery là thay đổi gây “đau” nhất. Lý do under-the-hood: với cache chia sẻ, nhiều observer cùng subscribe một query — onSuccess sẽ chạy bao nhiêu lần, ở observer nào, là không xác định. v5 loại bỏ sự mơ hồ đó. Thay thế:

// v4 (đã bỏ)
useQuery({ queryKey, queryFn, onSuccess: (d) => setLocal(d) });

// v5 — side-effect theo data: useEffect
const { data } = useQuery({ queryKey, queryFn });
useEffect(() => { if (data) setLocal(data); }, [data]);

// v5 — xử lý global (toast/log/logout): QueryCache.onError
new QueryCache({ onError: (e) => toast.error(getMessage(e)) });

Codemod chính thức — chạy trước, dọn tay sau:

# Gộp argument + bỏ overload (signature đa-arg → single object)
npx jscodeshift@latest ./src \
  --extensions=tsx,ts --parser=tsx \
  --transform=./node_modules/@tanstack/react-query/build/codemods/v5/remove-overloads/remove-overloads.cjs

# isLoading → isPending, status 'loading' → 'pending'
npx jscodeshift@latest ./src \
  --extensions=tsx,ts --parser=tsx \
  --transform=./node_modules/@tanstack/react-query/build/codemods/v5/is-loading/is-loading.cjs

# keepPreviousData → placeholderData: keepPreviousData
npx jscodeshift@latest ./src \
  --extensions=tsx,ts --parser=tsx \
  --transform=./node_modules/@tanstack/react-query/build/codemods/v5/keep-previous-data/keep-previous-data.cjs

Codemod lo phần cơ học (đổi tên, gộp argument). Phần cần đầu óc — bỏ callback useQuery, thêm initialPageParam, kiểm tra placeholderData — vẫn phải làm tay. Chạy codemod trên nhánh sạch rồi review diff.


9. So sánh: TanStack Query vs RTK Query vs SWR

Tiêu chíTanStack QueryRTK QuerySWR
Gắn vớiĐộc lập (mọi nguồn async)Redux ToolkitNext.js-friendly, nhẹ
Mô hình cacheKey phân cấp, fuzzy-match, thao tác chủ độngTheo endpoint + tagKey string, đơn giản
Mutation/optimisticĐầy đủ (onMutate/rollback)Có (onQueryStarted)Cơ bản (mutate)
InvalidationFilter key (exact/prefix/predicate)Tag-based tự độngmutate(key) thủ công
SSR/HydrationChính thức, đầy đủ (Phần 10)Có (Next.js)
Offline/persistChính thức (Phần 13)Hạn chếQua middleware
RealtimePatch cache thủ công (Phần 14)Streaming updatesHạn chế
CodegenCộng đồng (OpenAPI)Chính thức (OpenAPI/GraphQL)
Bundle (gzip, xấp xỉ)~12–13 kBđi kèm Redux Toolkit~4–5 kB
Khi nào chọnServer state phức tạp, cần toàn quyền cacheĐã sống trong Redux, thích codegenApp nhỏ/vừa, ưu tiên tối giản

Tóm gọn:

  • TanStack Query thắng khi server state là trung tâm và bạn cần toàn quyền: thao tác cache, optimistic đa query, offline, realtime.
  • SWR thắng khi muốn tối giản, bundle nhỏ, app vừa phải.
  • RTK Query hợp team đã dùng Redux nặng và muốn codegen từ OpenAPI/GraphQL.

Cả ba giải cùng một bài toán gốc (server state). Khác biệt là trục kiểm soát ↔ tối giảncó gắn Redux hay không.


10. Checklist production-ready

Chấm dự án của bạn theo danh sách này (mỗi mục dẫn về phần đã đào sâu):

Cấu hình (Phần 9)

  • Một QueryClient module-scope (browser) / mới mỗi request (server)
  • defaultOptions.queriesstaleTime > 0, retry bỏ qua 4xx, gcTime hợp lý
  • QueryCache.onError/MutationCache.onError toàn cục (401 → logout, toast)
  • meta có type (Register augmentation) cho invalidates/silent

Tổ chức (Phần 3, 18)

  • Query/mutation gói trong queryOptions/mutationOptions, không key/fn tay trong component
  • Key factory phân cấp tập trung mỗi feature; 1 root/feature
  • Component chỉ import qua hook layer (enforce bằng ESLint)
  • Hướng phụ thuộc một chiều: keys/schema → api → queries → hooks → UI

Type & data (Phần 3, 17)

  • Validate biên bằng zod; type = z.infer<schema>; không any, as chỉ ngay sau parse
  • skipToken cho dependent query (không enabled + queryFn!)
  • queryOptions/mutationOptions để type chảy tới setQueryData/getQueryData

Hiệu năng (Phần 11, 16)

  • Subscription đặt gần nơi dùng data; select thu hẹp re-render
  • Prefetch theo ý định / route loader cho điều hướng chính
  • Tránh waterfall (Promise.all, useSuspenseQueries, combine)

Bền & UX (Phần 6, 7, 13)

  • Quy ước loading/error nhất quán (Suspense+Boundary hoặc imperative — chọn một)
  • Optimistic update đủ 5 bước: cancel → snapshot → patch → rollback → invalidate
  • (Nếu offline) persist + paused mutations + buster theo schema version

Chất lượng (Phần 8)

  • Test hook với MSW; test client retry: false, gcTime: Infinity
  • Devtools sẵn cho dev (và bật được ở prod khi cần)
  • Observability gắn vào cache subscribe (không log PII)

11. Gotchas thường gặp

GotchaVì saoCách tránh
Component gọi useQuery tay → key lệchKhông qua factoryChỉ import hooks.ts; enforce ESLint
mutationOptions không tồn tại ở bản cũHelper thêm sau (v5.80+)Dùng factory trả object literal nếu chưa nâng cấp
Migrate v5 nhưng status === 'loading' còn sótCodemod không bắt 100%Grep 'loading' sau khi chạy codemod
useInfiniteQuery đỏ type sau v5Thiếu initialPageParamThêm initialPageParam + sửa chữ ký getNextPageParam
Devtools prod làm phình bundleImport tĩnh bản productionlazy() + điều kiện toggle
Log data đầy đủ lên observabilityPII rò rỉChỉ log key + mã lỗi
Cross-feature invalidate gõ key tayChép chuỗi ['orders']Import factory đã export của feature kia
Một feature, hai quy ước loading/errorTrộn imperative + SuspenseChốt một cách/feature

12. Recipes production

Provider gốc (browser + SSR-safe)

// app/providers.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { getQueryClient } from '@/lib/query-client';

export function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient(); // singleton browser / per-request server (Phần 9)
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Một feature đầy đủ, gọi từ component

import { useCustomers, useCreateCustomer } from '@/features/customers/hooks';

function Customers() {
  const { data, isPending } = useCustomers({ status: 'active' });
  const create = useCreateCustomer();
  if (isPending) return <Spinner />;
  return (
    <>
      <button onClick={() => create.mutate({ name: 'Acme', email: 'a@b.co' })}>
        Thêm
      </button>
      <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>
    </>
  );
}

Component không biết URL, key, hay zod schema — đúng tinh thần “chỉ chạm hook layer”.


Bài tập

1. Vì sao nên buộc component import qua “hook layer” thay vì gọi useQuery trực tiếp với key/fn?

Lời giải

Hook layer tập trung key, type và cấu hình ở một chỗ: đổi cách fetch, đổi key, thêm select chỉ sửa một file mà không đụng component. Gọi useQuery tay rải rác dễ gây lệch key (hỏng cache/invalidation), lặp type, và khó refactor. Đó là ranh giới sạch giữa data layer và UI.

2. Thay đổi v4 → v5 nào gây tái cấu trúc nhiều nhất, và thay thế ra sao?

Lời giải

Bỏ onSuccess/onError/onSettled trên useQuery (chúng chạy không đoán được với cache chia sẻ). Thay thế: side-effect theo data chuyển sang useEffect phụ thuộc data; xử lý toàn cục (toast/log/logout) lên QueryCache.onError. (Các đổi tên isLoading→isPending, cacheTime→gcTime, gộp argument có codemod lo.)

3. Khi nào TanStack Query là lựa chọn đúng hơn SWR hay RTK Query?

Lời giải

Khi server state phức tạp và bạn cần toàn quyền: thao tác cache chủ động, optimistic đa query, offline/persist chính thức, realtime patch, kiểm soát re-render chi tiết. SWR hợp app nhỏ/vừa ưu tiên đơn giản; RTK Query hợp team đã dùng Redux nặng và thích codegen từ OpenAPI.

4. Một feature orders sau khi tạo đơn cần làm mới danh sách customers (để cập nhật “tổng đơn”). Làm sao invalidate đúng mà không vi phạm “sở hữu key theo feature”?

Lời giải

orders không['customers','list'] tay. Thay vào đó import factory đã export của customers: import { customerKeys } from '@/features/customers/keys' rồi qc.invalidateQueries({ queryKey: customerKeys.lists() }). Vẫn một nguồn sự thật cho key — nếu customers đổi cấu trúc key, chỉ sửa trong feature customers. Muốn ranh giới chặt hơn nữa, customers export sẵn hook useInvalidateCustomers() để orders gọi mà không cần biết cả factory.

5. Sau khi chạy ba codemod v5, build vẫn đỏ ở vài chỗ useInfiniteQuery. Vì sao codemod không sửa được, và bạn phải làm gì tay?

Lời giải

Codemod lo phần cơ học (gộp argument, đổi tên isLoading/status). Nhưng useInfiniteQuery v5 bắt buộc initialPageParam và đổi chữ ký getNextPageParam(lastPage, allPages, lastPageParam, allPageParams) — đây là thay đổi ngữ nghĩa, cần người quyết initialPageParam bằng bao nhiêu. Phải thêm tay initialPageParam: 1 (hoặc giá trị đầu phù hợp) và cập nhật getNextPageParam/getPreviousPageParam. (Phần 4.)

Nâng cao: Lấy một feature trong dự án, tái cấu trúc theo layout mục 2–3 (keys/schema/api/queries/mutations/hooks), thêm rule ESLint mục 4, rồi chạy checklist mục 10 và sửa mọi mục chưa đạt. Đây là “capstone” thật của series.


Tóm tắt

  • Tổ chức theo feature (vertical slice): keys/schema/api/queries/mutations/hooks; hướng phụ thuộc một chiều.
  • Quy tắc vàng: component chỉ chạm hooks.ts — enforce bằng ESLint để key/type không rò ra UI.
  • Mỗi feature sở hữu root key; cross-feature invalidate đi qua factory đã export, không gõ key tay.
  • Chốt một quy ước loading/error; ưu tiên declarative (useSuspenseQuery + boundary) + QueryCache.onError toàn cục.
  • Devtools lazy-load được ở prod; gắn observability vào QueryCache/MutationCache (không log PII).
  • Migrate v4→v5: chạy ba codemod (remove-overloads, is-loading, keep-previous-data), rồi dọn tay callback useQuery đã bỏ + initialPageParam.
  • Chọn TanStack Query khi server state phức tạp; SWR cho tối giản; RTK Query cho hệ Redux.
  • Dùng checklist production-ready để tự chấm điểm dự án trước khi ship.

Toàn series


Kết thúc series

Bạn vừa đi hết 18 phần. Nhìn lại toàn bộ hành trình:

NỀN TẢNG (1–8)                      PRODUCTION (9–18)
1  Mental model & setup             9  QueryClient & defaults sâu
2  useQuery & vòng đời cache        10 SSR / Next.js / hydration
3  Query keys, zod, queryOptions    11 Prefetching & router
4  Pagination & infinite            12 QueryClient như một store
5  Mutations & invalidation         13 Offline-first & persistence
6  Optimistic updates               14 Realtime: WebSocket/SSE
7  Errors, retry, suspense, perf    15 Mutation nâng cao
8  Testing & capstone (milestone)   16 Hiệu năng render sâu
                                     17 Type-safety đỉnh cao
                                     18 Kiến trúc & migration (bạn ở đây)
  • Nền tảng (1–8) dạy bạn từng API: query, cache, key, mutation, optimistic, error, test. Hết phần 8 bạn đã làm được một app CRUD chỉn chu.
  • Production (9–18) dạy bạn vận hành ở quy mô thật: defaults, SSR, prefetch, store, offline, realtime, hiệu năng, type, và cuối cùng là kiến trúc gói tất cả lại.

TanStack Query không chỉ là “fetch giúp bạn” — nó là một lớp đồng bộ server state có cache thông minh, mutation có kiểm soát, hỗ trợ SSR/offline/realtime, và type chặt. Dùng đúng, nó xoá hàng nghìn dòng boilerplate và biến những bài toán khó (race, optimistic, offline) thành pattern có sẵn.

Đi tiếp từ đây:

  • Xây thứ thật. Chọn một app nhỏ (todo + auth + danh sách phân trang), dựng query layer theo Phần 18 từ đầu. Lý thuyết chỉ đọng lại khi gõ tay.
  • Đọc docs chính thức khi gặp ca lạ: tanstack.com/query/latest — phần “Guides” và API reference rất sát thực tế.
  • Bản tóm tắt một trang: nếu cần ôn nhanh hoặc giới thiệu cho đồng đội, xem hướng dẫn TanStack Query toàn diện.

Cảm ơn bạn đã đi cùng 18 phần. Giờ bạn đã có cả bộ — hãy ghép chúng vào dự án thật.