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, so sánh với hàng xóm, 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.


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

Đừng gom mọi query vào một file queries.ts khổng lồ. Tổ chức theo feature, 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 lại cho UI)

Quy tắc: component chỉ import từ hooks.ts, không bao giờ gọi useQuery với key/fn tay trong component. Điều này giữ key tập trung, type nhất quán, và đổi data layer không phải sửa component.

// features/customers/hooks.ts — biên giới UI dùng
import { useQuery } from '@tanstack/react-query';
import { customersQuery } from './queries';

export function useCustomers() {
  return useQuery(customersQuery());
}

2. Quy ước loading/error nhất quán

Mỗi team nên chốt một cách xử lý loading/error và áp khắp nơi. Hai lựa chọn chính (Phần 7):

CáchLoadingErrorHợp khi
Imperativeif (isPending)if (isError)Cần kiểm soát inline, granular
Declarative<Suspense><ErrorBoundary>Muốn component sạch, loading/error gom ở biên

Chọn declarative cho app mới: useSuspenseQuery + Suspense/ErrorBoundary cho từng 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.


3. Devtools & logging trong production

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

import { lazy, Suspense } from 'react';

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

function App() {
  const showDevtools = useDevtoolsToggle(); // vd bật qua localStorage flag
  return (
    <>
      {/* ...app... */}
      {showDevtools && (
        <Suspense fallback={null}>
          <ReactQueryDevtoolsProd />
        </Suspense>
      )}
    </>
  );
}

Cho logging/observability, gắn vào cache (Phần 12):

queryClient.getQueryCache().subscribe((event) => {
  if (event.type === 'updated' && event.query.state.status === 'error') {
    logToObservability('query_error', {
      key: event.query.queryKey,
      error: event.query.state.error,
    });
  }
});

4. Migrate v4 → v5 — những đổi thay phải biết

Nếu kế thừa codebase v4, đây là các breaking change quan trọng nhất:

v4v5Ghi chú
isLoadingisPendingisLoading giờ = isPending && isFetching
cacheTimegcTimeĐổi tên, ý nghĩa giữ nguyên
useQuery(key, fn, opts)useQuery({ queryKey, queryFn, ...opts })Chỉ còn một object argument
onSuccess/onError/onSettled trên useQueryĐã bỏChuyển sang QueryCache global hoặc useEffect
keepPreviousData: trueplaceholderData: keepPreviousDataImport keepPreviousData
useQueries trả mảngthêm combine(Phần 16)

Bỏ callback trên useQuery là thay đổi gây “đau” nhất. Lý do: chúng chạy không đoán được với cache chia sẻ. Thay thế: side-effect “sau khi data về” chuyển sang useEffect theo data, còn xử lý global (toast/log) lên QueryCache.onError.

Có codemod chính thức: npx jscodeshift ... @tanstack/react-query/codemods/v5/... cho phần đổi tên và gộp argument. Chạy nó trước, rồi dọn tay phần callback.


5. So sánh nhanh: RTK Query & SWR

TanStack QueryRTK QuerySWR
Gắn vớiĐộc lập (mọi nguồn async)Redux ToolkitNext.js-friendly, nhẹ
CacheMạnh, linh hoạt, thao tác chủ độngTốt, theo endpointĐơn giản, key-based
Mutation/optimisticĐầy đủ, kiểm soát caoCơ bản
Offline/persistChính thức (Phần 13)Hạn chếQua middleware
Khi nào chọnApp có server state phức tạp, cần kiểm soátĐã dùng Redux nặng, thích codegen từ OpenAPIApp nhỏ/vừa, ưu tiên đơn giản

TanStack Query thắng khi server state là trung tâm và bạn cần toàn quyền với cache. SWR thắng khi muốn tối giản. RTK Query hợp khi team đã sống trong Redux.


6. Checklist production-ready

Chấm dự án của bạn theo danh sách này:

Cấu hình

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

Tổ chức

  • 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
  • Component chỉ import qua hook layer

Type & data

  • Validate biên bằng zod; type = z.infer<schema>; không any, không as ngoài sau parse
  • skipToken cho dependent query

Hiệu năng

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

Bền & UX

  • Quy ước loading/error nhất quán (Suspense+Boundary hoặc imperative)
  • Optimistic update có cancel → snapshot → patch → rollback → invalidate
  • (Nếu offline) persist + paused mutations + buster theo schema

Chất lượng

  • Test hook với MSW (Phần 8)
  • Devtools sẵn sàng cho dev (và bật được ở prod khi cần)

7. 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.

Nâng cao: Lấy một feature trong dự án, tái cấu trúc theo layout mục 1 (keys/schema/api/queries/mutations/hooks), rồi chạy checklist mục 6 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: keys/schema/api/queries/mutations/hooks; component chỉ chạm hook layer.
  • Chốt một quy ước loading/error; ưu tiên declarative + QueryCache.onError toàn cục.
  • Devtools lazy-load được ở prod; gắn observability vào cache subscribe.
  • Migrate v4→v5: chạy codemod cho đổi tên/gộp argument, dọn tay phần callback useQuery đã bỏ.
  • 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.

Toàn series


Điều cốt 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. Giờ bạn đã có cả 18 mảnh — hãy ghép chúng vào dự án thật.