jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 15 — Mutation nâng cao

Làm chủ mutation ở mức production: mutationKey & setMutationDefaults, theo dõi mutation đang chạy bằng useMutationState, scope chạy tuần tự, optimistic update trải nhiều query, xử lý toàn cục ở MutationCache và hàng đợi offline.

Phần 5 dạy mutation cơ bản, Phần 6 dạy optimistic update một query, Phần 13 dạy mutation offline. Nhưng app thật đặt ra những câu hỏi khó hơn: làm sao hiện “đang lưu…” ở một chỗ khác nơi gọi mutation? Làm sao đảm bảo hai mutation cùng loại không chạy đè lên nhau? Làm sao render một item optimistic mà không đụng cache? Làm sao chia sẻ một định nghĩa mutation để mutation đã xếp hàng offline (Phần 13) chạy lại được? Phần này trả lời từng câu, biến mutation từ “gọi rồi quên” thành một hệ thống có kiểm soát.

Bản đồ tư duy. Mutation nâng cao xoay quanh một thứ: mutationKey. Nó là cái móc để (1) đọc trạng thái từ xa (useMutationState, useIsMutating), (2) cấu hình từ xa (setMutationDefaults), và (3) điều phối chạy tuần tự (scope). Cộng thêm hai kỹ thuật không cần key: optimistic qua variables và sequential mutations.

                         ┌──────────────────────────┐
                         │       mutationKey        │
                         │   ['customer','update']  │
                         └────────────┬─────────────┘
              ┌───────────────────────┼───────────────────────┐
              ▼                       ▼                        ▼
      ┌───────────────┐      ┌─────────────────┐      ┌────────────────┐
      │  ĐỌC từ xa    │      │  CẤU HÌNH từ xa │      │  ĐIỀU PHỐI      │
      │ useMutation   │      │ setMutation     │      │ scope:{id}      │
      │   State       │      │   Defaults      │      │ → chạy tuần tự  │
      │ useIsMutating │      │ (resume offline)│      │                 │
      └───────────────┘      └─────────────────┘      └────────────────┘

1. mutationKey — định danh để theo dõi, cấu hình & điều phối

Khác query, mutation không bắt buộc có key — bạn vẫn mutate() bình thường mà không cần key. Nhưng đặt mutationKey mở ra một loạt năng lực mà nếu không có key thì không thể làm:

useMutation({
  mutationKey: ['customer', 'update'],
  mutationFn: updateCustomer,
});

Coi mutationKey như “tên loại thao tác”, không phải “id một lần chạy” — mọi lần update customer dùng cùng key ['customer','update']. Đừng nhét id bản ghi vào key trừ khi bạn thật sự muốn lọc theo bản ghi.

Bảng tra: mutationKey mở khoá những gì

Năng lựcAPICần mutationKey?Mục
Đọc trạng thái mutation từ component khácuseMutationState({ filters })Có (để lọc theo loại)2
Đếm nhanh số mutation đang chạyuseIsMutating({ mutationKey })Tuỳ (lọc theo loại)3
Đăng ký mutationFn + options dùng lạisetMutationDefaults(key, opts)Bắt buộc5
Resume mutation offline sau reloadsetMutationDefaults + mutationKeyBắt buộc5
Chạy tuần tự (không liên quan key)scope: { id }Không (dùng scope riêng)4

mutationKey vs scope. Đây là hai trục độc lập. mutationKey = “thao tác loại gì” (để đọc/cấu hình). scope.id = “hàng đợi nào” (để điều phối thứ tự chạy). Hai mutation có thể cùng mutationKey nhưng khác scope, hoặc ngược lại.


2. useMutationState — đọc trạng thái mutation từ xa

Vấn đề kinh điển: nút “Lưu” nằm trong form, nhưng bạn muốn hiện spinner “đang đồng bộ” ở header (component khác cây). Không có useMutationState, bạn phải nâng state lên context/Redux thủ công. Với nó, bạn đọc trạng thái mọi mutation khớp filter, ở bất kỳ đâu:

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

function SyncIndicator() {
  // Đếm số mutation 'customer/update' đang chạy, dù chúng được gọi ở đâu.
  const pending = useMutationState({
    filters: { mutationKey: ['customer', 'update'], status: 'pending' },
    select: (mutation) => mutation.state.variables,
  });

  if (pending.length === 0) return null;
  return <span>Đang lưu {pending.length} thay đổi…</span>;
}

Bảng tra useMutationState(options)

FieldKiểuÝ nghĩa
filters.mutationKeyunknown[]Lọc theo loại thao tác (so khớp prefix, giống query filter).
filters.status'idle' | 'pending' | 'success' | 'error'Chỉ lấy mutation ở trạng thái này.
filters.exactbooleantrue = khớp key chính xác thay vì prefix.
filters.predicate(mutation) => booleanLọc tuỳ ý (vd theo meta, theo variables).
select(mutation) => TTrích phần cần — trả T[] thay vì cả đối tượng mutation. Luôn dùng để giảm re-render.

Cơ chế. useMutationState subscribe vào MutationCache. Mỗi khi cache đổi (mutation mới chạy, đổi status…), nó chạy lại filter + select, rồi so sánh kết quả bằng structural sharing — chỉ re-render khi mảng kết quả thật sự đổi. Vì nó trả về mảng (nhiều mutation có thể khớp), luôn xử lý length === 0.

Dùng được cho: badge “đang lưu”, hiển thị optimistic item trước khi nó vào cache (lấy variables của mutation pending — xem mục 7), hoặc disable một nút toàn cục khi có thao tác nặng đang chạy.


3. useIsMutating — đếm nhanh số mutation đang chạy

Khi bạn chỉ cần một con số (có thao tác ghi nào đang chạy không?), useIsMutating gọn hơn useMutationState. Nó trả về số nguyên = số mutation pending khớp filter:

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

function GlobalSpinner() {
  const isMutating = useIsMutating(); // tổng số mutation đang chạy toàn app
  const savingCustomer = useIsMutating({ mutationKey: ['customer'] }); // chỉ loại 'customer'

  return isMutating > 0 ? <Spinner label={`${isMutating} thao tác…`} /> : null;
}

useIsMutating vs useMutationState

Tiêu chíuseIsMutatinguseMutationState
Trả vềnumber (đếm pending)T[] (mảng đã select)
Dùng khiChỉ cần biết có/bao nhiêuCần dữ liệu (variables, error…)
Re-renderKhi con số đổiKhi mảng kết quả đổi
Ví dụGlobal spinner, disable nútOptimistic row, danh sách lỗi

Mẹo. Có một useIsFetching song song cho query (Phần 16). Cặp useIsMutating + useIsFetching ở layout gốc cho bạn một “thanh trạng thái mạng” toàn cục chỉ với hai dòng.


4. scope — chạy tuần tự thay vì song song

Mặc định mutation chạy song song. Nhưng đôi khi điều đó sai: gửi nhiều cập nhật cùng một bản ghi cùng lúc → race, thứ tự ghi không xác định, response về lộn xộn. scope.id bắt các mutation cùng scope xếp hàng, chạy lần lượt:

useMutation({
  mutationFn: reorderItem,
  // Mọi mutation cùng scope.id chạy TUẦN TỰ, không chồng chéo.
  scope: { id: `list-${listId}` },
});

Song song (mặc định) vs tuần tự (scope)

Bấm "Lưu" 3 lần nhanh:

SONG SONG (mặc định):                 TUẦN TỰ (scope: { id }):
  m1 ├────────────┤ (xong thứ 3?!)      m1 ├──────┤
  m2   ├──────┤   (xong thứ 1)          m2        ├──────┤  (chờ m1)
  m3     ├────┤   (xong thứ 2)          m3               ├──────┤ (chờ m2)
  → thứ tự ghi server KHÔNG xác định    → server nhận đúng m1 → m2 → m3

Khi user bấm “Lưu” liên tục, mutation thứ hai chờ thứ nhất xong (settled) mới chạy — giữ thứ tự ghi đúng. Dưới mui xe, mỗi scope.id là một hàng đợi: mutation mới được đẩy vào cuối, runner lấy ra từng cái khi cái trước settled.

Khi nào cần scope

Tình huốngCần scope?Vì sao
Kéo-thả sắp xếp lại danh sáchMỗi lần thả gửi thứ tự mới; chạy song song → server lưu thứ tự sai.
Nhiều lần lưu cùng một tài liệuRace ghi → mất chữ; tuần tự giữ “last write” đúng.
Tăng/giảm tồn kho cùng một SKUCộng dồn phải tuần tự, không thì mất cập nhật.
Tạo nhiều order khác nhauKhôngĐộc lập, chạy song song nhanh hơn.
Mutation offline xếp hàng cùng dataKhi online lại, tránh chúng đua nhau patch cache (mục 5).

Phân biệt với debounce: debounce bỏ các lần gọi giữa chừng và chỉ gửi lần cuối; scope giữ tất cả và chạy tuần tự. Dùng scope khi mỗi thao tác đều phải tới server (vd log từng bước reorder); dùng debounce khi chỉ trạng thái cuối là quan trọng (vd search-as-you-type).


5. Chia sẻ định nghĩa mutation với setMutationDefaults

Phần 13 đã dùng setMutationDefaults để mutation offline sống sót reload. Đây là cơ chế tổng quát hơn: đăng ký mutationFn + options theo mutationKey, để mọi useMutation chỉ cần khai mutationKey là kế thừa đầy đủ:

// Đăng ký một LẦN, gần nơi tạo QueryClient.
queryClient.setMutationDefaults(['customer', 'update'], {
  mutationFn: updateCustomer,
  retry: 3, // idempotent (PUT theo id) nên retry an toàn — xem mục 8
  onSuccess: () => queryClient.invalidateQueries({ queryKey: customerKeys.lists() }),
});
// Bất kỳ component nào — KHÔNG lặp lại mutationFn/onSuccess.
function EditButton({ id }: { id: string }) {
  const mutation = useMutation({ mutationKey: ['customer', 'update'] });
  return <button onClick={() => mutation.mutate({ id, name: 'An' })}>Lưu</button>;
}

Vì sao cần thiết cho offline (nối Phần 13). 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 không serialize được nên không sống sót. setMutationDefaults đăng ký lại mutationFn theo mutationKey, để resumePausedMutations() biết gọi hàm nào cho mỗi mutation đã xếp hàng. Không có nó, mutation resume sẽ không có hàm để chạy.

Reload khi offline:

  storage ──restore──▶ hàng đợi: [{ key:['customer','update'], variables:{...} }]
                                    │ (chỉ có DATA, không có hàm)
   setMutationDefaults(key, { mutationFn }) ──ghép──┘

   online lại ──▶ resumePausedMutations() ──▶ gọi mutationFn(variables) cho từng cái

Khi nhiều mutation offline xếp hàng đụng cùng data, kết hợp scope (mục 4) để chúng chạy tuần tự khi online lại — tránh chúng đua nhau patch cache.


6. Optimistic update trải nhiều query (cache-based)

Phần 6 optimistic một query. Thực tế một mutation thường đụng nhiều: thêm một order vừa cập nhật orders.list, vừa tăng customer.detail.orderCount, vừa đụng stats. Snapshot và rollback tất cả:

useMutation({
  mutationFn: createOrder,
  onMutate: async (newOrder) => {
    // Huỷ refetch đang bay cho MỌI key bị ảnh hưởng.
    await Promise.all([
      queryClient.cancelQueries({ queryKey: orderKeys.lists() }),
      queryClient.cancelQueries({ queryKey: customerKeys.detail(newOrder.customerId) }),
    ]);

    // Snapshot để rollback.
    const prevOrders = queryClient.getQueryData<Order[]>(orderKeys.lists());
    const prevCustomer = queryClient.getQueryData<Customer>(
      customerKeys.detail(newOrder.customerId),
    );

    // Patch optimistic cả hai.
    queryClient.setQueryData<Order[]>(orderKeys.lists(), (old) => [
      ...(old ?? []),
      { ...newOrder, id: `temp-${Date.now()}` },
    ]);
    queryClient.setQueryData<Customer>(customerKeys.detail(newOrder.customerId), (old) =>
      old ? { ...old, orderCount: old.orderCount + 1 } : old,
    );

    return { prevOrders, prevCustomer };
  },
  onError: (_err, newOrder, ctx) => {
    // Rollback TẤT CẢ về snapshot.
    if (ctx?.prevOrders) queryClient.setQueryData(orderKeys.lists(), ctx.prevOrders);
    if (ctx?.prevCustomer) {
      queryClient.setQueryData(customerKeys.detail(newOrder.customerId), ctx.prevCustomer);
    }
  },
  onSettled: (_data, _err, newOrder) => {
    // Chốt sự thật từ server cho mọi key đã đụng.
    queryClient.invalidateQueries({ queryKey: orderKeys.lists() });
    queryClient.invalidateQueries({ queryKey: customerKeys.detail(newOrder.customerId) });
  },
});

Khuôn mẫu bất biến: cancel → snapshot → patch trong onMutate, rollback trong onError, invalidate trong onSettled. Khi nhiều query, làm đủ bộ cho từng query.


7. Optimistic qua variables — render pending không đụng cache

Cách ở mục 6 ghi thẳng cache (setQueryData) rồi rollback nếu lỗi — mạnh nhưng nhiều bước và rủi ro nếu rollback sót. Có một cách nhẹ hơn cho trường hợp “chỉ cần hiển thị item đang gửi”: đọc variables của mutation pending rồi render nó như một dòng tạm, không hề chạm vào cache. Khi mutation xong, cache invalidate và dòng tạm biến mất tự nhiên.

function TodoList() {
  const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

  const addTodo = useMutation({
    mutationFn: createTodo,
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
  });

  return (
    <ul>
      {todos?.map((t) => <li key={t.id}>{t.title}</li>)}

      {/* Dòng tạm: chỉ tồn tại khi đang gửi, KHÔNG nằm trong cache. */}
      {addTodo.isPending && (
        <li style={{ opacity: 0.5 }}>{addTodo.variables.title} (đang gửi…)</li>
      )}
    </ul>
  );
}

Muốn hiển thị optimistic item ở component khác nơi gọi mutation? Lấy variables qua useMutationState (mục 2):

function PendingTodos() {
  const pendingTitles = useMutationState({
    filters: { mutationKey: ['todos', 'add'], status: 'pending' },
    select: (m) => (m.state.variables as CreateTodoInput).title,
  });
  return pendingTitles.map((title) => <li key={title}>{title} (đang gửi…)</li>);
}

setQueryData (cache-based) vs variables (render-based)

Tiêu chísetQueryData trong onMutateRender từ variables
Chạm cacheCó (patch + rollback)Không
Cần snapshot/rollbackCó (onError)Không — lỗi thì dòng tạm tự mất
Hiển thị ở component khácTự nhiên (cache global)Cần useMutationState
Hợp vớiUpdate field, đếm, dữ liệu phức tạpThêm item vào danh sách
Rủi roRollback sót → cache lệchGần như không
Hiện lỗi từng itemKhóDễ (đọc m.state.status === 'error')

Khi nào chọn cái nào? Thêm một dòng vào danh sách → variables (đơn giản, an toàn). Tăng orderCount, cập nhật nhiều field, đụng nhiều query → setQueryData (mục 6). Nhiều đội dùng variables làm mặc định và chỉ rơi xuống setQueryData khi cần patch dữ liệu phức tạp.


8. Retry & idempotency cho mutation

Mutation mặc định retry: 0, còn query là 3. Lý do: đọc lại an toàn (idempotent), ghi lại thì không. Retry một POST lỗi-giữa-chừng có thể tạo 2 bản ghi. Chỉ bật retry cho thao tác idempotent:

Thao tácIdempotent?retry an toàn?
POST /orders (tạo mới)KhôngKhông — dễ tạo trùng
PUT /customers/:id (ghi đè theo id)
DELETE /customers/:idCó (xoá lại = vẫn xoá)
PATCH cộng dồn (vd count += 1)KhôngKhông — cộng nhiều lần
POST có idempotency-keyCó (server khử trùng)
// An toàn: PUT theo id, server ghi đè — retry không tạo trùng.
useMutation({ mutationFn: updateCustomer, retry: 3 });

// Muốn retry POST tạo mới? Gửi kèm idempotency key để server khử trùng.
function createOrder(input: CreateOrderInput) {
  return apiFetch('/orders', orderSchema, {
    method: 'POST',
    headers: { 'Idempotency-Key': input.clientId }, // server bỏ qua nếu đã thấy
    body: JSON.stringify(input),
  });
}

Quy tắc. Đừng bật retry cho mutation chỉ vì “cho chắc”. Hỏi: gửi hàm này hai lần có hại không? Nếu có → để retry: 0 (hoặc thêm idempotency-key trước khi bật).


9. onMutate trả context & chuỗi mutation phụ thuộc

onMutate trả về gì thì giá trị đó thành context cho onError/onSettled — đây là kênh truyền snapshot để rollback (mục 6). Kiểu context được suy ra tự động, không cần any:

useMutation({
  mutationFn: updateTodo,
  onMutate: async (vars) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData<Todo[]>(['todos']);
    queryClient.setQueryData<Todo[]>(['todos'], (old) => patch(old, vars));
    return { previous }; // ← context, TS suy ra { previous: Todo[] | undefined }
  },
  onError: (_err, _vars, context) => {
    if (context?.previous) queryClient.setQueryData(['todos'], context.previous);
  },
});

Với chuỗi mutation phụ thuộc (B cần kết quả của A), dùng mutateAsyncawait tuần tự — đừng gọi song song rồi hy vọng đúng thứ tự:

async function checkout(cartId: string) {
  const order = await createOrder.mutateAsync({ cartId });   // A
  await capturePayment.mutateAsync({ orderId: order.id });   // B cần order.id
  await sendReceipt.mutateAsync({ orderId: order.id });      // C
}

Phân biệt với scope. scope chỉ tuần-tự-hoá các mutation cùng loại (cùng hàng đợi) và không truyền dữ liệu giữa chúng. Chuỗi phụ thuộc ở đây là các mutation khác loại truyền kết quả cho nhau — phải dùng await thủ công.


10. Xử lý toàn cục ở MutationCache

Lặp onError: toast(...) ở mọi mutation rất mệt. Đẩy lên MutationCache (Phần 9) và để mutation chỉ lo phần đặc thù:

const mutationCache = new MutationCache({
  onError: (error, _vars, _ctx, mutation) => {
    // Bỏ qua nếu mutation tự khai báo xử lý lỗi riêng.
    if (mutation.meta?.skipGlobalError) return;
    toast.error(error instanceof Error ? error.message : 'Thao tác thất bại');
  },
  onSuccess: (_data, _vars, _ctx, mutation) => {
    if (mutation.meta?.successMessage) toast.success(mutation.meta.successMessage as string);
  },
});

// Dùng meta để tuỳ biến từng mutation mà không lặp logic toast:
useMutation({
  mutationFn: deleteCustomer,
  meta: { successMessage: 'Đã xoá khách hàng' },
});

Thứ tự chạy: MutationCache.onError chạy trước useMutation’s onError. Global lo việc chung (toast, log, đăng xuất 401); local lo rollback đặc thù.


11. mutateAsync vs mutate — khi nào cần Promise

mutate là “bắn rồi quên” (callback onSuccess/onError). mutateAsync trả Promise — dùng khi bạn cần await kết quả để làm việc tiếp:

// Cần chờ tạo xong để điều hướng tới trang mới
async function handleSubmit() {
  try {
    const created = await createOrder.mutateAsync(form);
    navigate(`/orders/${created.id}`);
  } catch {
    // Lỗi đã được MutationCache toast; chỉ cần không điều hướng.
  }
}

Bẫy mutateAsync: nếu dùng nó, bạn phải bắt lỗi (try/catch), nếu không sẽ tạo unhandled promise rejection. Với mutation thường (không cần chờ), dùng mutate để Query lo lỗi qua callback — sạch hơn.


12. Gotchas thường gặp

Triệu chứngNguyên nhânCách sửa
Nhiều lần “Lưu” ghi đè nhau, thứ tự saiMutation cùng bản ghi chạy song songThêm scope: { id } để xếp hàng tuần tự (mục 4)
useMutationState không thấy mutation nàoMutation thiếu mutationKey nên filter không khớpĐặt mutationKey cho mutation cần theo dõi (mục 1)
Retry tạo bản ghi trùngBật retry cho thao tác non-idempotent (POST tạo mới)Để retry: 0, hoặc thêm Idempotency-Key (mục 8)
Optimistic item nhân đôi khi mutation xongVừa render từ variables vừa setQueryData cùng itemChọn một cách; nếu dùng variables thì đừng patch cache (mục 7)
Mutation offline resume nhưng “không có hàm chạy”mutationFn chỉ trong component, không serialize đượcĐăng ký qua setMutationDefaults(key, { mutationFn }) (mục 5)
Rollback sót → cache lệch vĩnh viễnonMutate patch nhiều query nhưng onError rollback thiếuSnapshot & rollback đủ bộ từng query, hoặc dùng variables (mục 6, 7)
unhandledrejection trong consoleDùng mutateAsync mà không try/catchBọc try/catch, hoặc đổi sang mutate (mục 11)
Chuỗi mutation phụ thuộc chạy sai thứ tựGọi song song rồi mong đúng thứ tựawait mutateAsync tuần tự (mục 9)

13. Recipes

Recipe 1 — Thanh trạng thái lưu toàn cục

Một component duy nhất ở layout gốc, phản ánh mọi thao tác ghi đang chạy:

function GlobalSaveStatus() {
  const saving = useIsMutating();
  const errored = useMutationState({
    filters: { status: 'error' },
    select: (m) => m.state.error,
  });

  if (saving > 0) return <Banner tone="info">Đang lưu {saving} thay đổi…</Banner>;
  if (errored.length > 0) return <Banner tone="error">Một số thay đổi lưu lỗi</Banner>;
  return <Banner tone="ok">Đã lưu tất cả</Banner>;
}

Recipe 2 — Reorder tuần tự (không race)

Kéo-thả sắp xếp: mỗi lần thả gửi thứ tự mới, scope đảm bảo server nhận đúng tuần tự:

const reorder = useMutation({
  mutationFn: (next: ItemOrder) => apiFetch(`/lists/${listId}/order`, orderSchema, {
    method: 'PUT',
    body: JSON.stringify(next),
  }),
  scope: { id: `reorder-${listId}` }, // mọi lần thả vào CÙNG hàng đợi
  retry: 3,                            // PUT idempotent → retry an toàn
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['lists', listId] }),
});

Recipe 3 — Danh sách optimistic qua variables

Thêm todo hiện ngay, không snapshot/rollback, lỗi thì tự biến mất:

const addTodo = useMutation({
  mutationKey: ['todos', 'add'],
  mutationFn: createTodo,
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});

// Trong render: ghép data thật + (các) variables đang gửi
const pending = useMutationState({
  filters: { mutationKey: ['todos', 'add'], status: 'pending' },
  select: (m) => m.state.variables as CreateTodoInput,
});
// <li> cho từng todos?.map(...) rồi nối thêm pending.map((v) => <li className="opacity-50">…)

14. Bài tập

1. useMutationState giải quyết vấn đề gì mà useMutation đơn lẻ không làm được?

Lời giải

useMutation trả trạng thái tại nơi gọi. useMutationState đọc trạng thái của mutation khớp filter từ bất kỳ đâu trong cây — cho phép hiện “đang lưu” ở header, đếm số thao tác pending, hay render optimistic item từ variables của mutation đang chạy, mà không cần truyền prop xuyên cây.

2. Khi nào dùng scope thay vì để mutation chạy song song, và nó khác debounce thế nào?

Lời giải

Dùng scope khi thứ tự ghi quan trọng và mọi thao tác đều phải tới server (vd cùng sửa một tài liệu) — các mutation cùng scope.id chạy tuần tự, không race. Debounce thì bỏ các lần gọi giữa chừng và chỉ gửi lần cuối; scope giữ tất cả và xếp hàng.

3. Vì sao dùng mutateAsync thì bắt buộc phải try/catch?

Lời giải

mutateAsync trả Promise reject khi mutation lỗi. Không bắt sẽ tạo unhandled promise rejection (cảnh báo/crash tuỳ môi trường). Chỉ dùng mutateAsync khi cần await để làm tiếp (vd điều hướng sau khi tạo); nếu không cần, mutate + callback onError an toàn hơn.

4. Khi nào nên render optimistic item từ variables thay vì setQueryData? Đánh đổi là gì?

Lời giải

Dùng variables khi chỉ cần thêm một dòng đang gửi vào danh sách: không chạm cache, không cần snapshot/rollback, lỗi thì dòng tạm tự biến mất. Đánh đổi: muốn hiển thị ở component khác phải qua useMutationState, và nó không hợp khi cần cập nhật nhiều field/nhiều query (vd tăng orderCount) — lúc đó setQueryData + cancel→snapshot→patch→rollback phù hợp hơn. Cảnh báo: đừng dùng cả hai cho cùng item → bị nhân đôi.

5. Vì sao bật retry cho một POST /orders (tạo mới) lại nguy hiểm, còn PUT /customers/:id thì an toàn? Làm sao retry an toàn cái POST?

Lời giải

POST tạo mới không idempotent: nếu request đầu lỗi sau khi server đã ghi (vd timeout response), retry tạo bản ghi thứ hai. PUT theo id ghi đè cùng bản ghi → gửi lại nhiều lần cho cùng kết quả (idempotent), retry an toàn. Để retry POST an toàn, gửi kèm Idempotency-Key (vd clientId sinh ở client) để server khử trùng các request lặp.

6. Một paused mutation offline được khôi phục sau reload nhưng không gửi đi. Thiếu gì?

Lời giải

Thiếu setMutationDefaults(key, { mutationFn }). Hàng đợi persist chỉ lưu dữ liệu (key + variables); closure mutationFn không serialize được nên mất sau reload. setMutationDefaults đăng ký lại mutationFn theo mutationKey để resumePausedMutations() biết gọi hàm nào. Component cũng phải useMutation({ mutationKey }) khớp đúng key.

Nâng cao: Dựng danh sách todo với: (a) GlobalSaveStatus ở header (Recipe 1), (b) thêm todo optimistic qua variables (Recipe 3), (c) reorder có scope (Recipe 2). Mở DevTools → Network → Offline, thêm 2 todo (thấy dòng tạm), bật mạng lại — xác nhận chúng gửi tuần tựGlobalSaveStatus đếm đúng.


Tóm tắt

  • mutationKey là cái móc mở khoá: theo dõi từ xa (useMutationState, useIsMutating), cấu hình từ xa (setMutationDefaults), và là điều kiện để resume offline.
  • useMutationState trả mảng trạng thái mutation khớp filter (luôn select + xử lý rỗng); useIsMutating trả số — chọn theo nhu cầu “dữ liệu” vs “đếm”.
  • scope: { id } cho mutation cùng scope chạy tuần tự, tránh race ghi (reorder, cùng tài liệu); khác debounce (giữ tất cả vs bỏ giữa chừng).
  • setMutationDefaults(key, { mutationFn }) chia sẻ định nghĩa mutation và là điều kiện sống còn để mutation offline resume sau reload (closure không serialize được).
  • Hai kiểu optimistic: cache-based (setQueryData + cancel→snapshot→patch→rollback, hợp đa query/nhiều field) và render-based (variables, hợp thêm item, không chạm cache).
  • Mutation mặc định retry: 0: chỉ bật cho thao tác idempotent (PUT/DELETE theo id) hoặc POST có Idempotency-Key.
  • onMutate trả context để rollback; chuỗi mutation phụ thuộc thì await mutateAsync tuần tự.
  • MutationCache.onError/onSuccess + meta xử lý toàn cục; mutateAsync khi cần await (phải try/catch), mutate cho phần còn lại.

Phần tiếp theo

Phần 16 — Hiệu năng render sâu: vì sao v5 mặc định “tracked queries”, tinh chỉnh notifyOnChangeProps, dùng useQueries cho danh sách query động, cơ chế structuralSharing bên trong và cách viết structuralSharing tuỳ chỉnh, cùng các kỹ thuật cô lập subscription để chặn re-render thừa.