jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 13 — Offline-first & Persistence sâu

Giữ cache qua reload với persistQueryClient + persister sync/async, kiểm soát buster và maxAge, lọc dữ liệu nhạy cảm khi dehydrate, hàng đợi paused mutations chạy lại khi online, và đồng bộ cache đa tab.

Mặc định cache React Query nằm trong RAM: reload trang là mất sạch, người dùng lại thấy spinner. Với app cần mở ra là thấy data ngay (PWA, dashboard, app mobile-web), ta persist cache xuống storage và rã đông lúc khởi động. Đi xa hơn nữa là offline-first: thao tác ghi vẫn xếp hàng khi mất mạng và tự gửi lại khi online.

Phần 7 đã xem nhanh persistence. Phần này đào tới đáy: persister, busting, lọc nhạy cảm, paused mutation, và đa tab — đủ để ship một trải nghiệm offline thật.


1. Bức tranh tổng thể

Khởi động app

   ├─ Đọc snapshot cache đã lưu từ storage (localStorage / IndexedDB)
   ├─ Kiểm tra buster + maxAge
   │     ├─ hợp lệ      → HYDRATE vào QueryClient → UI thấy data NGAY
   │     └─ không hợp lệ → bỏ (đổi version / quá hạn) → fetch mới

App chạy:
   ├─ Mỗi khi cache đổi → dehydrate → ghi (throttle) xuống storage
   ├─ Online : query/mutation chạy bình thường
   └─ Offline (networkMode 'online'):
         ├─ query mới  → fetchStatus 'paused' (không gọi mạng)
         └─ mutation   → 'paused' + xếp hàng trong MutationCache

         Online lại → onlineManager phát tín hiệu
               → resumePausedMutations() gửi hàng đợi

Hai trụ cột độc lập:

  • PersistencepersistQueryClient + một persister lưu/khôi phục snapshot cache qua reload.
  • Offline writesnetworkMode + onlineManager đẩy mutation sang paused, xếp hàng, rồi tự gửi lại khi có mạng.

Bạn có thể chỉ persist (giữ data qua reload) mà không lo offline-write, hoặc ngược lại. Ghép cả hai mới ra “offline-first” đầy đủ.


2. networkMode — bộ điều phối online/offline

networkMode quyết định Query làm gì khi không có mạng. Đặt global ở defaultOptions hoặc theo từng hook.

Giá trịKhi offlineKhi onlineDùng khi
'online' (mặc định)Không fetch; query → fetchStatus: 'paused', mutation xếp hàngChạy bình thườngApp phụ thuộc API thật (mặc định an toàn)
'always'Vẫn chạy queryFn/mutationFnChạy bình thườngData không cần mạng (đọc IndexedDB cục bộ, tính toán local)
'offlineFirst'Chạy một lần; fail mới pausedChạy bình thườngCó lớp cache HTTP / service worker đứng trước (PWA)
// global
new QueryClient({
  defaultOptions: {
    queries: { networkMode: 'offlineFirst' },
    mutations: { networkMode: 'offlineFirst' },
  },
});

// hoặc từng hook
useQuery({ queryKey: ['local'], queryFn: readLocal, networkMode: 'always' });

'offlineFirst' khác 'always' ở đâu? 'always' bỏ qua trạng thái mạng hoàn toàn — luôn chạy. 'offlineFirst' vẫn thử một phát dù offline (để service worker / HTTP cache có cơ hội trả lời), nhưng nếu request thật sự fail vì mất mạng thì nó dừng và paused, chờ online để retry. Với PWA có Workbox, 'offlineFirst' là lựa chọn đúng.

onlineManager — nguồn sự thật về trạng thái mạng

Query không tự đoán online/offline; nó hỏi onlineManager. Mặc định manager nghe sự kiện online/offline của windownavigator.onLine.

APIÝ nghĩa
onlineManager.isOnline()Trạng thái hiện tại (boolean)
onlineManager.setOnline(bool)Ép trạng thái thủ công (test, hoặc tín hiệu mạng riêng)
onlineManager.subscribe(cb)Lắng nghe đổi trạng thái; trả về hàm unsubscribe
onlineManager.setEventListener(fn)Thay nguồn sự kiện mặc định (vd navigator.connection)
import { onlineManager } from '@tanstack/react-query';

// Dùng tín hiệu mạng tuỳ biến (vd Capacitor / React Native NetInfo).
onlineManager.setEventListener((setOnline) => {
  const sub = Network.addListener('networkStatusChange', (s) => setOnline(s.connected));
  return () => sub.remove();
});

navigator.onLine nổi tiếng hay nói dối: true không có nghĩa Internet thông, chỉ là có card mạng. Cần chính xác hơn thì tự ping một endpoint nhẹ rồi onlineManager.setOnline(result).


3. fetchStatus: 'paused' — query & mutation khi mất mạng

Nhắc lại từ Phần 1/9: status nói về data (pending/error/success), còn fetchStatus nói về hoạt động mạng (fetching/paused/idle). Offline tạo ra tổ hợp đặc trưng:

Mở app lần đầu, OFFLINE, networkMode 'online'


status: 'pending'  +  fetchStatus: 'paused'
   │  (chưa có data, nhưng KHÔNG quay spinner mạng — đang chờ online)

Online lại → onlineManager báo → fetchStatus: 'fetching' → 'success'
statusfetchStatusNghĩa
pendingpausedChưa có data, đang chờ mạng để fetch lần đầu
successpausedĐã có data (từ persist), muốn refetch nhưng đang offline
successidleCó data, không làm gì
pendingfetchingLần đầu, đang gọi mạng

UI nên phân biệt: isPending && fetchStatus === 'paused' → hiển thị “Đang chờ kết nối…” thay vì spinner vô tận.

const { data, status, fetchStatus } = useQuery({ queryKey: ['todos'], queryFn });
if (status === 'pending' && fetchStatus === 'paused') return <OfflineBanner />;

4. Cài đặt PersistQueryClientProvider

pnpm add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
// app/providers.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { queryClient } from '@/lib/query-client';

const persister = createSyncStoragePersister({
  storage: window.localStorage,
  throttleTime: 1000, // gộp ghi: tối đa 1 lần/giây thay vì mỗi lần cache nhúc nhích
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister,
        maxAge: 24 * 60 * 60 * 1000,
        buster: import.meta.env.VITE_APP_VERSION,
      }}
    >
      {children}
    </PersistQueryClientProvider>
  );
}

PersistQueryClientProvider làm ba việc: (1) restore snapshot trước khi render con, (2) subscribe cache để ghi tiếp khi thay đổi, (3) sau restore tự gọi resumePausedMutations(). Nó chặn render tới khi restore xong (với async persister) để tránh nháy “no data → data”.

Cần chạy code sau khi restore xong (vd refetch có chủ đích)? Dùng prop onSuccess của provider — nó fire một lần khi hydrate hoàn tất.


5. Sync (localStorage) vs Async (IndexedDB)

Sync — createSyncStoragePersisterAsync — createAsyncStoragePersister
Backend điển hìnhwindow.localStorageIndexedDB qua idb-keyval
Dung lượng~5 MB / originhàng trăm MB
Kiểu APIĐồng bộ — block main thread khi serializeBất đồng bộ — không block
Cache lớnDễ vượt quota, ghi giậtMượt, hợp cache nặng
Render lúc restoreGần như tức thìProvider chờ Promise xong mới render
Hợp vớiApp nhỏ, vài KB cachePWA, danh sách lớn, ảnh/blob

Async persister cho cache lớn:

pnpm add @tanstack/query-async-storage-persister idb-keyval
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';

const persister = createAsyncStoragePersister({
  storage: {
    getItem: (key) => get(key),
    setItem: (key, value) => set(key, value),
    removeItem: (key) => del(key),
  },
  throttleTime: 1000,
});

API dùng giống hệt sync; PersistQueryClientProvider tự nhận ra persister async và chờ restore xong rồi mới render con (tránh nháy “no data → data”).


6. persistQueryClient options đầy đủ

Dù dùng provider hay gọi tay persistQueryClient(...), các tuỳ chọn giống nhau:

OptionKiểuÝ nghĩa
persisterPersisterBộ lưu/khôi phục (sync hoặc async) — bắt buộc
maxAgenumberTuổi tối đa (ms) của snapshot; quá hạn → bỏ. Mặc định 24h
busterstring”Dấu vân tay” phiên bản; đổi → vứt cache cũ ngay
dehydrateOptionsobjectLọc cái gì được lưu (xem §8)
hydrateOptionsobjectTuỳ biến lúc khôi phục (hiếm khi cần)

Hai cách dùng:

// Cách 1: provider (khuyên dùng cho app React)
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister, maxAge, buster }} />
// Cách 2: gọi tay (ngoài React, hoặc kiểm soát thủ công)
import { persistQueryClient } from '@tanstack/react-query-persist-client';

const [unsubscribe, restorePromise] = persistQueryClient({
  queryClient,
  persister,
  maxAge: 1000 * 60 * 60 * 24,
  buster: APP_VERSION,
});
await restorePromise; // chờ hydrate xong rồi mới render / refetch

7. buster & maxAge — vô hiệu hoá cache đúng lúc

  • maxAge — tuổi tối đa của toàn bộ snapshot. Quá hạn → bỏ hết, fetch mới. Mặc định 24h.
  • buster — định danh phiên bản. buster đổi → cache cũ bị vứt ngay bất kể tuổi. Dùng cho deploy đổi shape data/schema.
persistOptions={{
  persister,
  maxAge: 1000 * 60 * 60 * 24,
  buster: `${APP_VERSION}-${SCHEMA_VERSION}`, // đổi 1 trong 2 → bust
}}

Bài học xương máu: quên cập nhật buster khi đổi shape data → user cũ restore cache hình dạng cũ → code mới .map field không tồn tại → crash. buster rẻ, hãy gắn nó vào version build.

gcTime phải ≥ maxAge

maxAge chỉ kiểm soát snapshot trên storage. Nhưng entry trong RAM bị gcTime chi phối: nếu gcTime < maxAge, entry có thể bị GC khỏi cache ngay trong phiên trước khi snapshot hết hạn, khiến lần dehydrate sau ghi ra data thiếu. Quy tắc: đặt gcTime ≥ maxAge (thường bằng nhau) cho data cần persist.

new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24 } } }); // = maxAge

8. Cái gì được persist — mặc định chỉ success

Khi dehydrate, mặc định Query chỉ lưu query ở status: 'success' — query pending/error bị bỏ qua (lưu lỗi vào storage là vô nghĩa và nguy hiểm). Bạn tinh chỉnh thêm bằng dehydrateOptions:

OptionKiểuÝ nghĩa
shouldDehydrateQuery(query) => booleanCó lưu query này không (mặc định: chỉ success)
shouldDehydrateMutation(mutation) => booleanCó lưu mutation này không (cho hàng đợi offline)
serializeData(data) => unknownBiến đổi data trước khi lưu (vd nén)
persistOptions={{
  persister,
  dehydrateOptions: {
    shouldDehydrateQuery: (query) => {
      const root = query.queryKey[0];
      // KHÔNG persist data nhạy cảm.
      if (root === 'me' || root === 'auth' || root === 'payment') return false;
      // Giữ mặc định: chỉ persist query đã thành công.
      return query.state.status === 'success';
    },
    // Persist hàng đợi mutation đang paused để sống sót reload (xem §11).
    shouldDehydrateMutation: (m) => m.state.isPaused,
  },
}}

Nguyên tắc: persist data công khai, mặc định loại data nhạy cảm. localStorage/IndexedDB không phải kho bí mật — token/PII/số dư không bao giờ được ghi xuống.


9. Khôi phục lúc reload — luồng restore

Reload trang


persister.restoreClient() đọc snapshot từ storage

   ├─ buster trong snapshot ≠ buster hiện tại? ──yes──► bỏ snapshot, removeClient()
   │                                                     → QueryClient rỗng → fetch mới
   ├─ (now - snapshot.timestamp) > maxAge?     ──yes──► bỏ (như trên)

   └─ hợp lệ → hydrate(queryClient, dehydratedState)

                  ├─ Mỗi query khôi phục với dataUpdatedAt cũ
                  ├─ staleTime quyết định: stale → refetch nền; fresh → dùng luôn
                  └─ Mutation paused khôi phục vào hàng đợi


                  Provider gọi resumePausedMutations() (nếu online)

Điểm quan trọng: data khôi phục vẫn tuân theo staleTime. Snapshot 2 giờ trước với staleTime: 0 → hydrate xong sẽ refetch nền ngay (UI thấy data cũ tức thì, rồi tự cập nhật). Muốn “data tin tưởng được lâu”, tăng staleTime.


10. Offline mutations: pause → queue → resume

Khi networkMode: 'online' và mất mạng, mutation paused — không gửi, nhưng được ghi vào MutationCache. Để nó tự gửi lại sau khi online (kể cả sau reload) cần hai mảnh: setMutationDefaults (đăng ký mutationFn theo key) và onlineManagerresumePausedMutations.

queryClient.setMutationDefaults(['addTodo'], {
  mutationFn: addTodo,
  // Khi offline, optimistic update vào cache để UI phản hồi ngay.
  onMutate: async (variables: NewTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData<Todo[]>(['todos']);
    queryClient.setQueryData<Todo[]>(['todos'], (old) => [
      ...(old ?? []),
      { ...variables, id: `temp-${Date.now()}`, pending: true },
    ]);
    return { previous };
  },
  onError: (_err, _vars, context) => {
    if (context?.previous) queryClient.setQueryData(['todos'], context.previous);
  },
  retry: 3, // mạng chập khi vừa online lại → thử vài lần
});
// Component: KHÔNG khai báo mutationFn — đã đăng ký qua defaults để survive reload.
const mutation = useMutation<Todo, Error, NewTodo>({ mutationKey: ['addTodo'] });

Resume khi online (provider làm sẵn; nếu gọi tay):

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

onlineManager.subscribe((isOnline) => {
  if (isOnline) queryClient.resumePausedMutations();
});

Vì sao mutationFn phải nằm ở setMutationDefaults chứ không chỉ trong component? Vì sau reload, hàng đợi mutation được khôi phục từ storage dưới dạng dữ liệu (variables, key) — nhưng closure mutationFn thì không (không serialize được hàm). setMutationDefaults đăng ký lại mutationFn theo mutationKey để resumePausedMutations biết gọi gì.


11. Persist hàng đợi mutation + mutationCache retry

Mặc định dehydrate không lưu mutation; muốn hàng đợi offline sống qua reload phải bật shouldDehydrateMutation (§8) để lưu các mutation isPaused. Sau hydrate, chúng nằm lại trong MutationCacheresumePausedMutations() sẽ gửi.

persistOptions={{
  persister,
  dehydrateOptions: {
    shouldDehydrateQuery: (q) => q.state.status === 'success',
    shouldDehydrateMutation: (m) => m.state.isPaused, // chỉ lưu cái đang chờ gửi
  },
}}

Cấu hình retry mặc định cho mọi mutation qua defaultOptions để hàng đợi không gãy khi mạng chập lúc vừa online:

new QueryClient({
  defaultOptions: {
    mutations: {
      retry: 3,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // backoff
    },
  },
});
Tình huốngHành vi mong muốn
Offline lúc submitpaused + xếp hàng, không retry vô ích
Online lạiresumePausedMutations() gửi theo thứ tự FIFO
Server 500 sau khi gửiretry/retryDelay thử lại vài lần
Reload khi còn hàng đợiHydrate lại + resume nếu online

Thứ tự gửi là FIFO theo thời điểm tạo. Nếu hai mutation phụ thuộc nhau (tạo rồi sửa cùng record), giữ chúng cùng mutationKey hoặc xử lý idempotency phía server để tránh lệch.


12. Đồng bộ cache đa tab — broadcastQueryClient

Mở app ở hai tab: sửa data ở tab A, tab B vẫn thấy data cũ tới khi focus refetch. broadcastQueryClient dùng BroadcastChannel đồng bộ cache giữa các tab cùng origin tức thì:

pnpm add @tanstack/query-broadcast-client-experimental
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';

broadcastQueryClient({ queryClient, broadcastChannel: 'my-app-cache' });

Giờ setQueryData/invalidate ở tab A lan sang tab B ngay — hợp với CRM/admin nhiều tab. (Còn “experimental” — kiểm thử kỹ trước khi tin tưởng tuyệt đối.)


13. Gotchas thường gặp

GotchaHậu quảCách tránh
Persist data nhạy cảm (token/PII)Lộ bí mật trong localStorageshouldDehydrateQuery trả false cho key auth/payment
Persist cả lỗiRestore ra trạng thái error “đông cứng”Giữ mặc định success-only; đừng nới shouldDehydrateQuery
Quên đổi buster khi đổi schemaCode mới crash trên data hình dạng cũGắn buster vào version/schema build
gcTime < maxAgeEntry bị GC → restore thiếu dataĐặt gcTime ≥ maxAge
mutationFn chỉ ở componentHàng đợi mất mutationFn sau reloadĐăng ký qua setMutationDefaults(key, ...)
Cache lớn vào localStorageVượt quota 5MB, ghi giật main threadDùng async IndexedDB persister
Không bật shouldDehydrateMutationHàng đợi offline biến mất khi reloadBật để lưu mutation isPaused
Tin navigator.onLine tuyệt đối”Online” giả khi có card mạng nhưng mất InternetTự ping + onlineManager.setOnline

14. Recipes thực chiến

Danh sách đọc offline (persist + offlineFirst)

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    networkMode: 'offlineFirst', // có service worker / HTTP cache đỡ
    staleTime: 1000 * 60 * 5,    // tin data 5 phút sau hydrate
    gcTime: 1000 * 60 * 60 * 24, // = maxAge để persist không bị GC
  });
}

Tạo bản ghi xếp hàng khi offline

// lib/mutations.ts — đăng ký 1 lần lúc khởi tạo client
queryClient.setMutationDefaults(['createProduct'], {
  mutationFn: (input: NewProduct) => api.createProduct(input),
  onMutate: async (input: NewProduct) => {
    await queryClient.cancelQueries({ queryKey: ['products'] });
    const previous = queryClient.getQueryData<Product[]>(['products']);
    queryClient.setQueryData<Product[]>(['products'], (old) => [
      ...(old ?? []),
      { ...input, id: `temp-${crypto.randomUUID()}`, pending: true },
    ]);
    return { previous };
  },
  onError: (_e, _v, ctx) => {
    if (ctx?.previous) queryClient.setQueryData(['products'], ctx.previous);
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
});

// component
function NewProductForm() {
  const create = useMutation<Product, Error, NewProduct>({ mutationKey: ['createProduct'] });
  // offline: thêm vào UI ngay (pending) + xếp hàng; online: tự gửi.
  return <button onClick={() => create.mutate({ name: 'Bàn phím' })}>Thêm</button>;
}

Luồng đầy đủ: offline → bấm Thêm → optimistic vào cache (UI thấy item pending) → mutation paused + persist → reload (vẫn offline, item còn nhờ persist query + hàng đợi) → bật mạng → resumePausedMutations() gửi → onSettled invalidate → danh sách thật về.


15. Bài tập

1. Vì sao gcTime phải ≥ maxAge khi persist?

Lời giải

maxAge là tuổi tối đa của snapshot trên storage, nhưng gcTime quyết định khi nào entry bị xoá khỏi RAM. Nếu gcTime < maxAge, entry có thể bị GC khỏi cache trong phiên trước khi snapshot hết hạn, dẫn tới restore ra data không đầy đủ/không nhất quán. Đặt gcTimemaxAge (thường bằng nhau) để vòng đời RAM khớp vòng đời persist.

2. buster khác maxAge thế nào, và khi nào bắt buộc phải đổi buster?

Lời giải

maxAge vứt cache theo tuổi; buster vứt cache theo phiên bản bất kể tuổi. Bắt buộc đổi buster khi shape/schema data thay đổi (deploy version mới đổi field), để user cũ không restore data hình dạng cũ rồi crash code mới.

3. Sau reload, vì sao paused mutation cần setMutationDefaults chứ không chỉ mutationFn trong component?

Lời giải

Hàng đợi mutation được persist dưới dạng dữ liệu (variables, key), nhưng hàm mutationFn (một closure) không serialize được nên không sống sót qua reload. setMutationDefaults(key, { mutationFn }) đăng ký lại hàm theo mutationKey, để khi khôi phục hàng đợi, resumePausedMutations biết gọi hàm nào để gửi mutation đã xếp hàng.

4. networkMode: 'always''offlineFirst' khác nhau ra sao khi offline?

Lời giải

'always' bỏ qua trạng thái mạng — luôn chạy queryFn/mutationFn (hợp data thuần local, không cần API). 'offlineFirst' vẫn thử một lần dù offline để lớp cache HTTP / service worker có cơ hội trả lời; nếu request fail thật vì mất mạng thì mới paused chờ online. Vậy 'always' cho data không cần mạng, 'offlineFirst' cho PWA có cache layer.

5. Vì sao dehydrate mặc định chỉ persist query success? Nếu cố tình nới shouldDehydrateQuery để lưu cả error thì hại gì?

Lời giải

Success-only vì lưu pending (chưa có data) hay error (sự cố nhất thời) là vô nghĩa. Persist error → reload sẽ restore ra trạng thái lỗi “đông cứng”: user mở app thấy lỗi cũ dù mạng đã ổn, và message lỗi có thể chứa thông tin nhạy cảm bị ghi xuống storage. Cứ để Query refetch tự nhiên thay vì persist lỗi.

Nâng cao: Dựng offline todo: bật DevTools → Network → Offline, thêm 2 todo (thấy chúng pending trong UI nhờ optimistic), reload trang (vẫn offline, todo vẫn còn nhờ persist), rồi bật mạng lại — xác nhận cả hai tự gửi đi qua resumePausedMutations.


Tóm tắt

  • Persistence: PersistQueryClientProvider + persister (sync localStorage cho cache nhỏ, async IndexedDB cho cache lớn) giữ cache qua reload; provider chặn render tới khi restore xong để khỏi nháy data.
  • persistQueryClient options: persister (bắt buộc), maxAge (theo tuổi), buster (theo phiên bản), dehydrateOptions (lọc cái gì lưu). Luôn đổi buster khi schema đổi; đặt gcTime ≥ maxAge.
  • Lọc: mặc định chỉ persist query success; dùng shouldDehydrateQuery để không lưu token/PII/payment, và shouldDehydrateMutation để giữ hàng đợi offline.
  • networkMode ('online' | 'always' | 'offlineFirst') + onlineManager điều phối offline: query → fetchStatus: 'paused', mutation → paused + xếp hàng.
  • Offline writes: setMutationDefaults đăng ký lại mutationFn để sống sót reload; onlineManagerresumePausedMutations() gửi FIFO khi online; cấu hình retry/retryDelay để hàng đợi không gãy.
  • broadcastQueryClient đồng bộ cache giữa các tab cùng origin (experimental).

Phần tiếp theo

Phần 14 — Realtime: WebSocket, SSE & cache: đẩy cập nhật từ server vào cache bằng setQueryData khi có sự kiện (thay vì invalidate gây refetch), quyết định khi nào patch và khi nào invalidate, gom nhiều sự kiện, dùng một query làm “kênh subscription”, và cập nhật từng phần cache an toàn về type.