jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 đủ

OptionKiểuKhi 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().
mutationKeyunknown[]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> | ctxChạ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 | voidChỉ khi mutationFn resolve. Nơi invalidateQueries / setQueryData / toast / điều hướng.
onError(error, vars, ctx) => Promise | voidChỉ khi mutationFn reject. Nơi rollback (dùng ctx) / báo lỗi.
onSettled(data, error, vars, ctx) => Promise | voidLuô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ả.
retrynumber | boolean | (failureCount, error) => booleanSố lần thử lại khi lỗi. Mặc định 0 (mutation không tự retry, khác query mặc định 3).
retryDelaynumber | (attempt, error) => numberKhoảng chờ giữa các lần thử lại. Mặc định backoff lũy thừa.
gcTimenumberBao 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.
metaRecord<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: 0 cò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ườngKiểuÝ nghĩa
mutate(vars, opts?) => voidKí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.
isIdlebooleanChưa gọi lần nào (hoặc vừa reset()).
isPendingbooleanĐang chạy mutationFn. Dùng để disabled nút.
isSuccess / isErrorbooleanĐã xong / đã lỗi.
dataTData | undefinedKết quả mutationFn trả về (chỉ khi success).
errorTError | nullLỗi (chỉ khi error).
variablesTVariables | undefinedTham số của lần gọi gần nhất — hữu ích để hiện optimistic row.
failureCountnumberSố lần mutationFn đã thất bại trong lượt hiện tại.
failureReasonTError | nullLý do thất bại gần nhất (khác error: tồn tại cả khi đang retry).
submittedAtnumberTimestamp lúc gọi mutate gần nhất.
reset() => voidXoá data/error, đưa status về idle.
isPausedbooleantrue 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
CallbackChạy khiTham sốDùng để
onMutatetrước mutationFn(vars)optimistic patch + snapshot, trả context
onSuccessmutationFn resolve(data, vars, ctx)invalidate / setQueryData / toast / điều hướng
onErrormutationFn reject(error, vars, ctx)rollback bằng ctx / báo lỗi
onSettledthà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 trong mutate(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 ở mutate tại nơi gọi. Lưu ý thứ tự: callback ở useMutation chạ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/onErrorawait + try/catch
Lỗi khi không bắtReact Query nuốt lỗi an toànUnhandled promise rejection
Chạy tuần tự nhiều mutationkhó (callback lồng nhau)dễ (await nối tiếp)
Khuyến nghịMặc địnhchỉ 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ên try/catch. Khi mutationFn reject, promise không được xử lý → cảnh báo/đổ vỡ. Nếu không có lý do await rõ ràng, luôn dùng mutate.


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:

  1. Đặt isStale = true ngay lập tức cho mọi query khớp.
  2. 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. invalidateQueries không xoá data — nó chỉ hạ cờ stale rồi nhờ cơ chế refetch của QueryCache lấ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

FilterKhớp vớiVí 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 }
predicatehàm (query) => boolean tuỳ ý{ predicate: q => q.queryKey[0] === 'customers' && q.state.data != null }
typelọc theo trạng thái: 'active' | 'inactive' | 'all'{ queryKey: ['customers'], type: 'active' }
stalechỉ query đang stale (hoặc fresh){ stale: true }
refetchTypeđiều khiển refetch nào chạy sau khi đánh dấuxem bảng dưới

refetchType quyết định query nào được refetch (không ảnh hưởng việc đánh dấu stale):

refetchTypeRefetch query…Khi nào dùng
'active' (mặc định)đang activehành vi thường ngày
'all'active inactivemuốn data nền cũng tươi ngay (vd tab khác)
'none'không refetch query nàochỉ đá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

invalidateQueriesrefetchQueries
Đánh dấu stale✅ có❌ không
Refetchchỉ query active (mặc định)mọi query khớp, bất kể stale
Tôn trọng staleTimecó (query fresh vẫn được đánh dấu stale rồi refetch)bỏ qua — luôn gọi mạng
Dùng khimặc định sau mutationcầ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 sang refetchQueries khi 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ốngNên dùngLý do
Tạo mới, server trả id sinh rainvalidate 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 listkhô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 serverinvalidatean 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 (vd updatedAt, field tính toán), setQueryData bằng response của mutationFn là an toàn; setQueryData bằ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(...)() => { qc.invalidateQueries(...); }một cặp ngoặc nhọn. Bản có { } mà thiếu return sẽ 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ặc await tường minh.


8. Gotchas thường gặp

Triệu chứngNguyên nhânCách xử lý
Nút “Lưu” hết loading nhưng danh sách chưa cập nhậtonSuccess không return promise của invalidateQueriestrả về promise (arrow rút gọn) hoặc await
mutateAsync gây “unhandled rejection”gọi mutateAsync mà thiếu try/catchbọc try/catch, hoặc đổi sang mutate + onError
Invalidate nhưng query ẩn không cập nhậtmặc định refetchType: 'active' bỏ qua query inactivethêm refetchType: 'all' nếu cần
invalidateQueries(['customers'], { exact: true }) không khớp list conexact: 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 refetchawait invalidate trong useMutation.onSuccess rồi mới đóng
setQueryData không đổi UIsửa data tại chỗ (mutate object cũ) thay vì trả object mớiluô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ùngbật retry cho POST không idempotentđể retry: 0 cho create; chỉ retry thao tác idempotent
Hai mutation chạy đè cache lẫn nhauchúng cập nhật cùng key song songdùng scope: { id: 'customers' } để chạy tuần tự

9. Bài tập

1. Khác nhau cơ bản giữa useQueryuseMutation 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/catchmutateAsync 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 useMutation với mutationFn; gọi bằng mutate (mặc định, fire-and-forget) hoặc mutateAsync (khi cần await + try/catch).
  • Trạng thái: idle → pending → success/error, onSettled luôn chạy cuối. Bảng option (onMutate/onSuccess/onError/onSettled, retry: 0 mặ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/onErroronSettled. Logic cache đặt ở useMutation, logic UI đặt ở mutate; callback ở useMutation chạy trước.
  • Sau khi ghi, invalidateQueries đánh dấu stale + refetch (mặc định chỉ query active). Dùng filter exact / predicate / type / refetchType để nhắm đúng. So với refetchQueries: invalidate đánh dấu stale, refetch bỏ qua stale.
  • setQueryData ghi 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ừ onSuccess nếu muốn giữ isPending tới khi data mới về. Tránh các gotcha: thiếu return, quên try/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.