TanStack Query · Phần 5 — Mutations & Invalidation
Ghi dữ liệu (tạo/sửa/xoá) bằng useMutation, hiểu vòng đời onMutate/onSuccess/onError/onSettled, và sau khi ghi thì invalidate đúng query để UI tự đồng bộ với server.
Bốn phần trước chỉ đọc dữ liệu. Giờ đến phần ghi: tạo, sửa, xoá. Trong React Query, đọc dùng useQuery, còn ghi dùng useMutation. Khác biệt cốt lõi: query chạy tự động và được cache; mutation chỉ chạy khi bạn chủ động gọi và không được cache (vì mỗi lần ghi là một hành động riêng).
Câu hỏi quan trọng sau khi ghi: làm sao danh sách trên màn hình tự cập nhật? Câu trả lời chính trong phần này là invalidation — cách tiếp cận nên dùng làm mặc định — và người anh em của nó là ghi thẳng cache bằng setQueryData.
Bản đồ tư duy.
useMutation= cách gọi hành động ghi + vòng đời callback để phản ứng.invalidateQueries/setQueryData= cách cập nhật cache sau khi ghi. Hai mảnh này ghép lại tạo nên luồng “ghi → UI tự đồng bộ”.
1. useMutation cơ bản
Tài liệu: Mutations.
useMutation cần một mutationFn (hàm async thực hiện việc ghi). Nó trả về mutate (gọi để chạy) và bộ trạng thái riêng:
// src/features/customers/api.ts
import { apiFetch } from '@/lib/api-client';
import { customerSchema } from './schema';
import { z } from 'zod';
// Một schema cho input — dùng chung cho form (Phần 7) và mutation.
export const createCustomerInput = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export type CreateCustomerInput = z.infer<typeof createCustomerInput>;
export function createCustomer(input: CreateCustomerInput) {
return apiFetch('/customers', customerSchema, {
method: 'POST',
body: JSON.stringify(input),
});
}
import { useMutation } from '@tanstack/react-query';
import { createCustomer } from './api';
function NewCustomerButton() {
const mutation = useMutation({
mutationFn: createCustomer,
});
return (
<button
onClick={() => mutation.mutate({ name: 'An', email: 'an@example.com' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Đang lưu…' : 'Thêm khách hàng'}
</button>
);
}
Bảng tra useMutation(options) — đầy đủ
| Option | Kiểu | Khi nào chạy / ý nghĩa |
|---|---|---|
mutationFn | (vars) => Promise<TData> | Bắt buộc. Hàm async thực hiện ghi. Nhận đúng tham số bạn truyền vào mutate(). |
mutationKey | unknown[] | Tuỳ chọn. Cho phép chia sẻ default options qua setMutationDefaults, hoặc theo dõi mutation bằng useMutationState. |
onMutate | (vars) => Promise<ctx> | ctx | Chạy ngay trước mutationFn. Giá trị trả về thành context cho các callback sau — nền tảng optimistic update (Phần 6). |
onSuccess | (data, vars, ctx) => Promise | void | Chỉ khi mutationFn resolve. Nơi invalidateQueries / setQueryData / toast / điều hướng. |
onError | (error, vars, ctx) => Promise | void | Chỉ khi mutationFn reject. Nơi rollback (dùng ctx) / báo lỗi. |
onSettled | (data, error, vars, ctx) => Promise | void | Luôn chạy cuối, dù thành công hay lỗi. Nơi đồng bộ lại không phụ thuộc kết quả. |
retry | number | boolean | (failureCount, error) => boolean | Số lần thử lại khi lỗi. Mặc định 0 (mutation không tự retry, khác query mặc định 3). |
retryDelay | number | (attempt, error) => number | Khoảng chờ giữa các lần thử lại. Mặc định backoff lũy thừa. |
gcTime | number | Bao lâu giữ trạng thái mutation (data/error) trong cache sau khi không còn observer. Mặc định 5 * 60_000. |
networkMode | 'online' | 'always' | 'offlineFirst' | Hành xử khi offline. 'online' (mặc định): treo ở isPaused cho tới khi có mạng. |
meta | Record<string, unknown> | Dữ liệu tuỳ ý gắn vào mutation; đọc lại trong global callback của MutationCache. |
scope | { id: string } | Mutation cùng scope.id chạy tuần tự (queue), không song song — hữu ích cho ghi tranh chấp. |
Tại sao mutation mặc định
retry: 0còn query là3? Đọc lại an toàn để retry (idempotent). Ghi thì không: retry một POST có thể tạo 2 bản ghi. Vì vậy React Query để mutation tự không retry; bạn bật thủ công chỉ cho thao tác idempotent (PUT theo id, DELETE).
Bảng tra giá trị trả về của useMutation
| Trường | Kiểu | Ý nghĩa |
|---|---|---|
mutate | (vars, opts?) => void | Kích hoạt mutation, không trả promise. Dùng mặc định. |
mutateAsync | (vars, opts?) => Promise<TData> | Như mutate nhưng trả promise để await. Cần try/catch. |
status | 'idle' | 'pending' | 'success' | 'error' | Trạng thái thô của mutation. |
isIdle | boolean | Chưa gọi lần nào (hoặc vừa reset()). |
isPending | boolean | Đang chạy mutationFn. Dùng để disabled nút. |
isSuccess / isError | boolean | Đã xong / đã lỗi. |
data | TData | undefined | Kết quả mutationFn trả về (chỉ khi success). |
error | TError | null | Lỗi (chỉ khi error). |
variables | TVariables | undefined | Tham số của lần gọi gần nhất — hữu ích để hiện optimistic row. |
failureCount | number | Số lần mutationFn đã thất bại trong lượt hiện tại. |
failureReason | TError | null | Lý do thất bại gần nhất (khác error: tồn tại cả khi đang retry). |
submittedAt | number | Timestamp lúc gọi mutate gần nhất. |
reset | () => void | Xoá data/error, đưa status về idle. |
isPaused | boolean | true khi mutation bị treo vì offline (networkMode). |
2. Vòng đời mutation & thứ tự callback
Trạng thái mutation đi qua một máy trạng thái đơn giản — và song song đó là chuỗi callback:
reset()
▲
│
┌──────┴──────┐ mutate() ┌───────────┐
│ idle │ ─────────────▶│ pending │
└─────────────┘ └─────┬─────┘
│ mutationFn resolve/reject
┌────────────┴────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ success │ │ error │
└─────────────┘ └─────────────┘
│ │
└──────────┬──────────────┘
▼
(onSettled luôn chạy)
Chuỗi callback theo thứ tự rõ ràng:
gọi mutate(variables)
│
├─ onMutate(variables) ← chạy NGAY trước mutationFn (optimistic — Phần 6)
│ └─ trả về context (snapshot để rollback)
│
├─ mutationFn(variables) ← gọi mạng
│
├─ thành công → onSuccess(data, variables, context)
│ hoặc lỗi → onError(error, variables, context)
│
└─ onSettled(data, error, variables, context) ← LUÔN chạy cuối
| Callback | Chạy khi | Tham số | Dùng để |
|---|---|---|---|
onMutate | trước mutationFn | (vars) | optimistic patch + snapshot, trả context |
onSuccess | mutationFn resolve | (data, vars, ctx) | invalidate / setQueryData / toast / điều hướng |
onError | mutationFn reject | (error, vars, ctx) | rollback bằng ctx / báo lỗi |
onSettled | thành công lẫn lỗi | (data, error, vars, ctx) | đồng bộ lại bất kể kết quả |
Callback ở chỗ nào? Đặt callback trong
useMutation({...})→ chạy cho mọi lần gọi. Đặt trongmutate(vars, {...})→ chỉ lần gọi đó. Quy ước thực dụng: logic cache (invalidate, rollback) đặt ởuseMutationđể luôn đúng; logic UI (toast, điều hướng, đóng modal) đặt ởmutatetại nơi gọi. Lưu ý thứ tự: callback ởuseMutationchạy trước callback cùng tên ởmutate.
3. mutate vs mutateAsync
Cùng kích hoạt một mutation, nhưng cách xử lý kết quả khác nhau hoàn toàn:
| Tiêu chí | mutate(vars, opts?) | mutateAsync(vars, opts?) |
|---|---|---|
| Trả về | void (fire-and-forget) | Promise<TData> |
| Xử lý kết quả | qua callback onSuccess/onError | await + try/catch |
| Lỗi khi không bắt | React Query nuốt lỗi an toàn | Unhandled promise rejection |
| Chạy tuần tự nhiều mutation | khó (callback lồng nhau) | dễ (await nối tiếp) |
| Khuyến nghị | Mặc định | chỉ khi thật sự cần chờ |
// ✅ Mặc định: dùng mutate + callback
mutation.mutate(input, {
onSuccess: (data) => toast.success(`Đã tạo ${data.name}`),
onError: (err) => toast.error(err.message),
});
// ⚠️ Chỉ khi cần await (BẮT BUỘC try/catch)
async function handleSubmit(input: CreateCustomerInput) {
try {
const created = await mutation.mutateAsync(input);
await mutation2.mutateAsync(created.id); // cần kết quả của bước trước
} catch (err) {
// nếu thiếu catch → unhandled rejection làm sập app trong môi trường strict
}
}
Bẫy kinh điển. Người mới hay chọn
mutateAsync“vì nó trả promise nhìn quen”, rồi quêntry/catch. KhimutationFnreject, promise không được xử lý → cảnh báo/đổ vỡ. Nếu không có lý doawaitrõ ràng, luôn dùngmutate.
4. Invalidation — cách đồng bộ UI sau khi ghi
Sau khi tạo một customer, danh sách ['customers'] trên màn hình đã lỗi thời. Cách đơn giản và đáng tin nhất: bảo React Query đánh dấu các query liên quan là stale và refetch — gọi là invalidation.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createCustomer } from './api';
import { customerKeys } from './keys';
export function useCreateCustomer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createCustomer,
onSuccess: () => {
// Đánh dấu mọi danh sách customer là stale → Query refetch để lấy data mới.
queryClient.invalidateQueries({ queryKey: customerKeys.lists() });
},
});
}
invalidateQueries làm hai việc với mọi query khớp filter:
- Đặt
isStale = truengay lập tức cho mọi query khớp. - Refetch ngay những query khớp mà đang active (có ít nhất một component đang quan sát). Query không active chỉ bị đánh dấu stale, sẽ tự refetch lần tới khi được mount.
Cơ chế bên dưới.
invalidateQuerieskhông xoá data — nó chỉ hạ cờstalerồi nhờ cơ chế refetch củaQueryCachelấy data mới. Vì data cũ vẫn còn, UI không nhấp nháy về trạng thái loading; người dùng thấy data cũ cho tới khi data mới về (background refetch). Đây là lý do invalidate “mượt” hơn xoá cache rồi fetch lại.
Nhờ key factory phân cấp từ Phần 3, bạn invalidate đúng phạm vi mong muốn:
// Mọi danh sách (mọi filter/trang) — phổ biến nhất sau khi tạo/xoá
queryClient.invalidateQueries({ queryKey: customerKeys.lists() });
// Một chi tiết cụ thể — sau khi sửa 1 record
queryClient.invalidateQueries({ queryKey: customerKeys.detail(id) });
// Tất cả liên quan customer — khi không chắc, dọn sạch
queryClient.invalidateQueries({ queryKey: customerKeys.all });
Vì key khớp theo tiền tố, customerKeys.lists() (['customers','list']) khớp luôn ['customers','list',{q:'a'}], ['customers','list',{q:'b'}]… — một lệnh dọn mọi biến thể danh sách.
Bảng tra query filters của invalidateQueries
| Filter | Khớp với | Ví dụ |
|---|---|---|
queryKey (tiền tố) | mọi query có key bắt đầu bằng mảng này | { queryKey: ['customers','list'] } → khớp ['customers','list',{q:'a'}] |
exact: true | đúng chính xác key đó, không khớp con | { queryKey: ['customers','list'], exact: true } |
predicate | hàm (query) => boolean tuỳ ý | { predicate: q => q.queryKey[0] === 'customers' && q.state.data != null } |
type | lọc theo trạng thái: 'active' | 'inactive' | 'all' | { queryKey: ['customers'], type: 'active' } |
stale | chỉ query đang stale (hoặc fresh) | { stale: true } |
refetchType | điều khiển refetch nào chạy sau khi đánh dấu | xem bảng dưới |
refetchType quyết định query nào được refetch (không ảnh hưởng việc đánh dấu stale):
refetchType | Refetch query… | Khi nào dùng |
|---|---|---|
'active' (mặc định) | đang active | hành vi thường ngày |
'all' | active và inactive | muốn data nền cũng tươi ngay (vd tab khác) |
'none' | không refetch query nào | chỉ đánh dấu stale, để refetch xảy ra “lazy” khi mount lại |
// Chỉ đánh dấu stale, KHÔNG refetch ngay (refetch khi component mount lại)
queryClient.invalidateQueries({
queryKey: customerKeys.lists(),
refetchType: 'none',
});
// Refetch cả query đang ẩn (inactive) để mọi tab đều tươi
queryClient.invalidateQueries({ queryKey: customerKeys.all, refetchType: 'all' });
invalidateQueries vs refetchQueries
invalidateQueries | refetchQueries | |
|---|---|---|
| Đánh dấu stale | ✅ có | ❌ không |
| Refetch | chỉ query active (mặc định) | mọi query khớp, bất kể stale |
Tôn trọng staleTime | có (query fresh vẫn được đánh dấu stale rồi refetch) | bỏ qua — luôn gọi mạng |
| Dùng khi | mặc định sau mutation | cần ép lấy lại data ngay lập tức, không quan tâm stale |
Quy tắc nhớ. Mặc định dùng
invalidateQueries. Chỉ chuyển sangrefetchQuerieskhi bạn muốn ép một fetch ngay cả khi query vừa fresh (hiếm — vd nút “Làm mới” thủ công).
5. setQueryData — ghi thẳng vào cache
Khi mutationFn trả về chính entity vừa ghi (server phản hồi bản ghi đầy đủ), bạn có thể ghi thẳng kết quả vào cache thay vì tốn thêm một request invalidate:
return useMutation({
mutationFn: updateCustomer, // trả về Customer đã cập nhật
onSuccess: (updated) => {
// Ghi thẳng detail vào cache — không cần refetch.
qc.setQueryData(customerKeys.detail(updated.id), updated);
// Cập nhật phần tử trong danh sách (immutable update).
qc.setQueryData(customerKeys.lists(), (old) =>
Array.isArray(old)
? old.map((c) => (c.id === updated.id ? updated : c))
: old,
);
},
});
setQueryData(key, updater) nhận hoặc một giá trị, hoặc một hàm (old) => new (luôn cập nhật immutable — trả về object/array mới, không sửa tại chỗ). React Query coi data mới là fresh ngay.
Invalidate hay setQueryData? Chọn cái nào
| Tình huống | Nên dùng | Lý do |
|---|---|---|
| Tạo mới, server trả id sinh ra | invalidate danh sách | đơn giản, để server quyết vị trí/sắp xếp/lọc |
| Sửa, server trả entity đầy đủ | setQueryData detail + patch list | không tốn request, cập nhật tức thì |
| Xoá | invalidate (hoặc patch list bỏ phần tử) | danh sách đổi, để server xác nhận |
| Phản hồi tức thì (like, toggle) | optimistic + setQueryData (Phần 6) | UI phải nhúc nhích ngay, không chờ mạng |
| Không chắc data khớp server | invalidate | an toàn — lấy lại sự thật từ server |
Đánh đổi cốt lõi.
invalidate= một request nữa nhưng luôn đúng (lấy sự thật từ server).setQueryData= không request nhưng bạn tự chịu trách nhiệm khớp data với server. Khi data server trả về có thể khác input (vdupdatedAt, field tính toán),setQueryDatabằng response củamutationFnlà an toàn;setQueryDatabằng input của form thì không.
6. Recipe hoàn chỉnh: tạo / sửa / xoá
// src/features/customers/hooks.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createCustomer, updateCustomer, deleteCustomer } from './api';
import { customerKeys } from './keys';
export function useCreateCustomer() {
const qc = useQueryClient();
return useMutation({
mutationFn: createCustomer,
onSuccess: () => qc.invalidateQueries({ queryKey: customerKeys.lists() }),
});
}
export function useUpdateCustomer() {
const qc = useQueryClient();
return useMutation({
mutationFn: updateCustomer,
onSuccess: (updated) => {
// Sửa thì invalidate cả chi tiết lẫn danh sách (record có thể đổi vị trí/lọc).
qc.invalidateQueries({ queryKey: customerKeys.detail(updated.id) });
qc.invalidateQueries({ queryKey: customerKeys.lists() });
},
});
}
export function useDeleteCustomer() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteCustomer,
onSuccess: () => qc.invalidateQueries({ queryKey: customerKeys.lists() }),
});
}
Component gọi hook và chỉ lo UI:
function NewCustomerForm() {
const create = useCreateCustomer();
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
create.mutate(
{ name: String(form.get('name')), email: String(form.get('email')) },
{
onSuccess: () => toast.success('Đã thêm khách hàng'),
onError: (err) => toast.error(err.message),
},
);
}}
>
<input name="name" required />
<input name="email" type="email" required />
<button disabled={create.isPending}>
{create.isPending ? 'Đang lưu…' : 'Lưu'}
</button>
</form>
);
}
7. await invalidation để giữ trạng thái pending
Mặc định invalidateQueries không chặn — onSuccess xong là mutation hết isPending ngay, kể cả khi danh sách còn đang refetch. Nếu muốn nút “Lưu” vẫn pending cho tới khi danh sách mới về, hãy trả về promise của invalidate từ onSuccess:
// ❌ KHÔNG chờ: arrow có block { } nhưng thiếu return → trả về undefined
onSuccess: () => { qc.invalidateQueries({ queryKey: customerKeys.lists() }); }
// ✅ CHỜ refetch xong: arrow rút gọn TỰ ĐỘNG return promise
onSuccess: () => qc.invalidateQueries({ queryKey: customerKeys.lists() })
// ✅ Hoặc await tường minh
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: customerKeys.lists() });
}
onSuccess/onSettled nếu trả về một promise, React Query sẽ chờ promise đó trước khi coi mutation là hoàn tất (isPending mới về false). Hữu ích khi bạn muốn đóng modal sau khi danh sách đã đồng bộ.
Cạm bẫy tinh vi. Khác biệt giữa
() => qc.invalidateQueries(...)và() => { qc.invalidateQueries(...); }là một cặp ngoặc nhọn. Bản có{ }mà thiếureturnsẽ trảundefined→ mutation kết thúc ngay, không chờ refetch. Nếu cần giữ pending, dùng arrow rút gọn hoặcawaittường minh.
8. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Nút “Lưu” hết loading nhưng danh sách chưa cập nhật | onSuccess không return promise của invalidateQueries | trả về promise (arrow rút gọn) hoặc await |
mutateAsync gây “unhandled rejection” | gọi mutateAsync mà thiếu try/catch | bọc try/catch, hoặc đổi sang mutate + onError |
| Invalidate nhưng query ẩn không cập nhật | mặc định refetchType: 'active' bỏ qua query inactive | thêm refetchType: 'all' nếu cần |
invalidateQueries(['customers'], { exact: true }) không khớp list con | exact: true chỉ khớp đúng key, không khớp tiền tố | bỏ exact, hoặc truyền đúng key con |
| Đóng modal trước khi data về, thấy data cũ loé lên | đóng modal trong mutate.onSuccess chạy trước refetch | await invalidate trong useMutation.onSuccess rồi mới đóng |
setQueryData không đổi UI | sửa data tại chỗ (mutate object cũ) thay vì trả object mới | luôn cập nhật immutable: (old) => ({ ...old }) |
| Stale closure: toast hiện tên customer cũ | tham chiếu biến ngoài thay vì dùng tham số callback | đọc từ (data, variables) của callback, đừng đóng biến state |
| Tạo 2 bản ghi trùng | bật retry cho POST không idempotent | để retry: 0 cho create; chỉ retry thao tác idempotent |
| Hai mutation chạy đè cache lẫn nhau | chúng cập nhật cùng key song song | dùng scope: { id: 'customers' } để chạy tuần tự |
9. Bài tập
1. Khác nhau cơ bản giữa useQuery và useMutation là gì?
Lời giải
useQuery chạy tự động (khi mount/khi key đổi) và kết quả được cache — dùng để đọc. useMutation chỉ chạy khi bạn gọi mutate(), không được cache — dùng để ghi (tạo/sửa/xoá). Mutation cũng có vòng đời callback onMutate/onSuccess/onError/onSettled.
2. Sau khi tạo customer mới, vì sao chỉ cần invalidateQueries({ queryKey: customerKeys.lists() }) mà không phải liệt kê từng filter?
Lời giải
Vì query key khớp theo tiền tố. customerKeys.lists() = ['customers','list'] là tiền tố của mọi key danh sách như ['customers','list',{q:'a'}]. Một lệnh invalidate phủ hết mọi biến thể filter/trang của danh sách.
3. Khi nào nên dùng mutateAsync thay vì mutate?
Lời giải
Chỉ khi cần await kết quả — vd chạy nhiều mutation tuần tự hoặc cần giá trị trả về ngay trong luồng async. Khi đó bắt buộc try/catch vì mutateAsync reject khi lỗi. Mặc định nên dùng mutate + callback để tránh unhandled rejection.
4. onSuccess của bạn viết () => { qc.invalidateQueries({ queryKey: k }); } nhưng nút “Lưu” vẫn hết loading trước khi danh sách cập nhật. Vì sao, sửa thế nào?
Lời giải
Arrow có block { } mà thiếu return sẽ trả về undefined, nên React Query không có promise để chờ → mutation kết thúc ngay. Sửa bằng cách trả về promise: dùng arrow rút gọn () => qc.invalidateQueries({ queryKey: k }), hoặc async () => { await qc.invalidateQueries({ queryKey: k }); }. Khi đó isPending chỉ về false sau khi refetch xong.
5. Server trả về entity đầy đủ sau khi updateCustomer. Khi nào nên setQueryData thay vì invalidate, và cần lưu ý gì khi cập nhật danh sách?
Lời giải
Khi mutationFn trả về chính bản ghi đã cập nhật, setQueryData(detail(id), updated) cập nhật tức thì mà không tốn request. Với danh sách, cập nhật immutable: qc.setQueryData(lists(), old => old.map(c => c.id === updated.id ? updated : c)) — trả về mảng mới, không sửa tại chỗ. Chỉ làm vậy khi chắc data từ server (không phải input form) khớp hình dạng cache; nếu không chắc, dùng invalidate cho an toàn.
6. (Nâng cao) Viết đủ useCreateCustomer, useUpdateCustomer, useDeleteCustomer với invalidation đúng phạm vi. Đặt logic cache trong useMutation, logic UI (toast, đóng modal) trong mutate tại nơi gọi.
Lời giải
Xem Recipe hoàn chỉnh: create/delete invalidate lists(); update invalidate cả detail(id) lẫn lists() (vì record có thể đổi vị trí khi lọc/sắp xếp). Trả về promise từ onSuccess nếu muốn giữ pending tới khi danh sách tươi. Component chỉ gọi hook và truyền callback UI vào mutate(vars, { onSuccess, onError }).
Tóm tắt
- Ghi dữ liệu dùng
useMutationvớimutationFn; gọi bằngmutate(mặc định, fire-and-forget) hoặcmutateAsync(khi cầnawait+try/catch). - Trạng thái:
idle → pending → success/error,onSettledluôn chạy cuối. Bảng option (onMutate/onSuccess/onError/onSettled,retry: 0mặc định,gcTime,scope) và bảng trả về (mutate,isPending,data,error,variables,reset,status…) là sổ tra nhanh. - Vòng đời callback:
onMutate→ mutationFn →onSuccess/onError→onSettled. Logic cache đặt ởuseMutation, logic UI đặt ởmutate; callback ởuseMutationchạy trước. - Sau khi ghi,
invalidateQueriesđánh dấu stale + refetch (mặc định chỉ query active). Dùng filterexact/predicate/type/refetchTypeđể nhắm đúng. So vớirefetchQueries: invalidate đánh dấu stale, refetch bỏ qua stale. setQueryDataghi thẳng cache khi server trả entity đầy đủ — nhanh, không tốn request, nhưng phải cập nhật immutable và tự chịu trách nhiệm khớp server. Không chắc thì invalidate.- Trả về promise từ
onSuccessnếu muốn giữisPendingtới khi data mới về. Tránh các gotcha: thiếureturn, quêntry/catch, sửa cache tại chỗ, retry POST không idempotent.
Phần tiếp theo
Phần 6 — Optimistic updates & quản lý cache: cập nhật UI tức thì trước khi server phản hồi bằng onMutate (cancel + snapshot + patch), tự động rollback khi lỗi, dùng setQueryData/getQueryData để chỉnh cache thủ công, và nắm các query filter để thao tác cache chính xác.