Kenapa ini penting
Membaca data baru separuh cerita. Aplikasi nyata juga menambah, mengubah, dan menghapus data: centang tugas selesai, tambah tugas baru, hapus komentar. Setelah perubahan, tampilan harus mencerminkan keadaan terbaru. Kalau tidak, user menambah tugas tapi daftarnya tidak berubah, dan user akan mengira aplikasinya rusak.
TanStack Query menyediakan useMutation untuk operasi yang mengubah data, dan beberapa strategi untuk menyegarkan cache: invalidasi, refetch manual, update cache langsung, dan optimistic update. Memilih strategi yang tepat menentukan apakah aplikasimu terasa lambat atau terasa instan.
Konsep inti
useMutation
Query untuk membaca, mutation untuk mengubah. Bedanya: query berjalan otomatis saat komponen tampil, mutation baru berjalan saat kamu memanggil mutate().
const mutation = useMutation({
mutationFn: (todoBaru) => createTodo(todoBaru),
onSuccess: (dataDariServer, variabel) => { /* ... */ },
onError: (error, variabel) => { /* ... */ },
onSettled: () => { /* selalu jalan, sukses atau gagal */ },
});
mutation.mutate({ title: "Belajar mutation" });
// mutation.isPending, mutation.isError, mutation.error, mutation.isSuccess
Ada juga mutateAsync() yang mengembalikan Promise, berguna kalau kamu ingin await lalu melakukan sesuatu (misalnya menutup dialog).
Strategi 1: invalidateQueries
Setelah mutasi sukses, beri tahu TanStack Query bahwa cache tertentu sudah basi:
queryClient.invalidateQueries({ queryKey: ["todos"] });
Semua query yang key-nya diawali ["todos"] (termasuk ["todos", 1], ["todos", { status: "selesai" }]) ditandai basi dan yang sedang tampil di layar langsung di-refetch. Ini strategi paling aman dan paling sering dipakai: server adalah sumber kebenaran, kita tinggal ambil ulang.
Analogi: kamu menempel tulisan "FOTO INI SUDAH TIDAK BERLAKU" di foto papan pengumuman, dan asistenmu otomatis memotret ulang.
Strategi 2: refetch manual
refetch() dari useQuery mengambil ulang query itu saja. Cocok untuk tombol "Muat ulang". Untuk setelah mutasi, invalidateQueries lebih baik karena bisa menyentuh banyak query sekaligus.
Strategi 3: setQueryData (update cache langsung)
Kalau server mengembalikan data hasil perubahan, kamu bisa langsung menuliskannya ke cache tanpa request ulang:
queryClient.setQueryData(["todos"], (lama) => [...(lama ?? []), todoDariServer]);
Hemat request, tapi kamu harus menulis logika update dengan benar dan immutable (buat array/object baru, jangan mengubah yang lama).
Strategi 4: optimistic update
User mencentang tugas, dan centangnya muncul seketika tanpa menunggu server. Kalau server ternyata gagal, centangnya dikembalikan. Langkahnya:
onMutate: batalkan refetch yang sedang berjalan (cancelQueries), simpan snapshot data lama (getQueryData), tulis data baru ke cache (setQueryData), lalureturn { snapshot }.onError: kembalikan cache ke snapshot. Nilai yang di-returnonMutatediterima sebagai argumen ketiga (disebutonMutateResultdi dokumentasi v5).onSettled:invalidateQueriessupaya akhirnya sinkron dengan server.
Analogi: pelayan restoran langsung menulis pesananmu di papan dapur sebelum kasir selesai memproses. Kalau kartu ditolak, tulisan dihapus.
Pagination dan infinite query (singkat)
- Pagination bernomor: masukkan nomor halaman ke key
["todos", page]. TambahkanplaceholderData: keepPreviousDatasupaya data halaman lama tetap tampil saat halaman baru dimuat (tidak berkedip).isPlaceholderDatatrue selama data yang tampil masih data lama. - Infinite query ("Muat lebih banyak" / scroll tak terbatas):
useInfiniteQuerydenganinitialPageParam(wajib di v5) dangetNextPageParam. Datanya ada didata.pages(array per halaman). PanggilfetchNextPage(); cekhasNextPagedanisFetchingNextPage.
Catatan tentang jsonplaceholder
jsonplaceholder adalah API palsu: POST, PATCH, dan DELETE mengembalikan respons sukses, tapi datanya tidak benar-benar tersimpan. Artinya, kalau kamu invalidateQueries setelah menambah tugas, refetch akan mengembalikan daftar asli tanpa tugas barumu. Untuk latihan dengan jsonplaceholder, gunakan setQueryData dari respons server. Dengan backend sungguhan, invalidateQueries adalah pilihan default.
Memilih strategi: tabel keputusan
| Situasi | Strategi yang disarankan |
|---|---|
| Backend sungguhan, perubahan jarang, tidak perlu terasa instan | invalidateQueries di onSuccess atau onSettled |
| Server mengembalikan objek lengkap hasil perubahan | setQueryData dengan data dari server, lalu boleh invalidate untuk berjaga-jaga |
| Interaksi kecil yang sering (centang, like, ubah urutan) | Optimistic update + rollback + invalidate di onSettled |
| User menekan tombol "Muat ulang" | refetch() dari useQuery |
| Data di layar lain ikut berubah (misalnya statistik) | invalidateQueries dengan key induk, misalnya ["todos"] |
Contoh nyata: di aplikasi media sosial, tombol "suka" hampir selalu memakai optimistic update. User menekan hati, warnanya langsung merah, dan angka bertambah. Kalau server gagal, warna kembali abu-abu dan muncul pesan kecil. Sebaliknya, formulir "ubah alamat pengiriman" di toko online biasanya cukup memakai invalidate, karena user wajar menunggu satu detik dan yang terpenting datanya benar.
Satu prinsip yang perlu kamu pegang: server tetap sumber kebenaran. Optimistic update dan setQueryData hanya membuat tampilan terasa cepat; invalidate di akhir memastikan tampilan kembali cocok dengan apa yang benar-benar tersimpan.
Contoh kode
// src/TodoToggle.jsx, mutation + optimistic update
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const API = "https://jsonplaceholder.typicode.com";
const KEY = ["todos", "user-1"];
async function getTodos() {
const res = await fetch(`${API}/todos?userId=1&_limit=5`);
if (!res.ok) throw new Error("Gagal memuat");
return res.json();
}
async function patchTodo({ id, completed }) {
const res = await fetch(`${API}/todos/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error("Gagal menyimpan");
return res.json();
}
export default function TodoToggle() {
const queryClient = useQueryClient();
const { data: todos = [], isPending } = useQuery({ queryKey: KEY, queryFn: getTodos });
const toggle = useMutation({
mutationFn: patchTodo,
onMutate: async (perubahan) => {
// 1) hentikan refetch yang sedang jalan supaya tidak menimpa update kita
await queryClient.cancelQueries({ queryKey: KEY });
// 2) simpan snapshot untuk rollback
const sebelumnya = queryClient.getQueryData(KEY);
// 3) tulis perubahan ke cache secara immutable
queryClient.setQueryData(KEY, (lama = []) =>
lama.map((t) => (t.id === perubahan.id ? { ...t, completed: perubahan.completed } : t))
);
return { sebelumnya };
},
onError: (_err, _perubahan, hasilOnMutate) => {
// 4) gagal? kembalikan ke snapshot
queryClient.setQueryData(KEY, hasilOnMutate?.sebelumnya);
alert("Gagal menyimpan, perubahan dibatalkan.");
},
// Dengan backend sungguhan tambahkan:
// onSettled: () => queryClient.invalidateQueries({ queryKey: KEY }),
});
if (isPending) return <p>Memuat...</p>;
return (
<ul className="space-y-2 p-6">
{todos.map((t) => (
<li key={t.id}>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={t.completed}
onChange={() => toggle.mutate({ id: t.id, completed: !t.completed })}
/>
<span className={t.completed ? "line-through text-slate-400" : ""}>{t.title}</span>
</label>
</li>
))}
</ul>
);
}
// → Klik checkbox: coretan muncul seketika (optimistic), request PATCH jalan di belakang.
// src/PagedTodos.jsx, pagination dengan keepPreviousData
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
export default function PagedTodos() {
const [page, setPage] = useState(1);
const { data = [], isPlaceholderData, isFetching } = useQuery({
queryKey: ["todos", "page", page],
queryFn: async () => {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=5`);
if (!res.ok) throw new Error("Gagal memuat");
return res.json();
},
placeholderData: keepPreviousData, // halaman lama tetap tampil saat memuat
});
return (
<div className="p-6">
<ul className={isPlaceholderData ? "opacity-50" : ""}>
{data.map((t) => (
<li key={t.id}>#{t.id} {t.title}</li>
))}
</ul>
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>Sebelumnya</button>
<span className="mx-2">Halaman {page}</span>
<button disabled={isPlaceholderData} onClick={() => setPage((p) => p + 1)}>Berikutnya</button>
{isFetching && <span> memuat...</span>}
</div>
);
}
// → Halaman 2 menampilkan tugas #6 sampai #10.
// src/InfiniteTodos.jsx, infinite query singkat
import { useInfiniteQuery } from "@tanstack/react-query";
export default function InfiniteTodos() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({
queryKey: ["todos", "infinite"],
queryFn: async ({ pageParam }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos?_page=${pageParam}&_limit=10`);
if (!res.ok) throw new Error("Gagal memuat");
return res.json();
},
initialPageParam: 1, // wajib di v5
// kalau halaman terakhir kurang dari 10 item, berarti sudah habis
getNextPageParam: (lastPage, allPages) => (lastPage.length < 10 ? undefined : allPages.length + 1),
});
if (isPending) return <p>Memuat...</p>;
return (
<div className="p-6">
{data.pages.flat().map((t) => (
<p key={t.id}>{t.title}</p>
))}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? "Memuat..." : hasNextPage ? "Muat lebih banyak" : "Sudah semua"}
</button>
</div>
);
}
// → Setiap klik menambah 10 tugas; total 200 tugas, setelah itu tombol "Sudah semua".
Kesalahan umum
- Mengubah cache secara langsung (mutate array lama).
lama.push(x)tidak memicu re-render dengan benar. Selalu kembalikan array/object baru:[...lama, x]ataulama.map(...). - Lupa
cancelQueriesdi optimistic update. Refetch yang sedang berjalan bisa selesai belakangan dan menimpa update optimistis dengan data lama. - Tidak menyimpan snapshot untuk rollback. Kalau server gagal, tampilan jadi bohong: user melihat tugas tercentang padahal di server tidak.
- Invalidate key yang salah.
invalidateQueries({ queryKey: ["todo"] })tidak menyentuh["todos"]. Pencocokan berdasarkan awalan array, jadi ejaan harus sama persis. - Lupa
initialPageParamdiuseInfiniteQuery. Di v5 opsi ini wajib.
Latihan
- Mudah, Buat tombol "Tambah tugas contoh" yang memakai
useMutationuntukPOST /todosdan menampilkanmutation.isPendingsebagai teks "Menyimpan...". - Sedang, Setelah POST sukses, tambahkan tugas baru ke cache
["todos"]memakaisetQueryData(bukan invalidate, karena jsonplaceholder tidak menyimpan). Perhatikan: jsonplaceholder selalu mengembalikanid: 201, jadi buat id unik sendiri. - Menantang, Buat mutation hapus tugas dengan optimistic update: tugas hilang seketika, dan muncul lagi kalau request gagal. Uji rollback dengan sengaja memakai URL salah.
Lihat solusi
// 1 & 2. Tambah tugas + update cache
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function AddSample() {
const queryClient = useQueryClient();
const add = useMutation({
mutationFn: async (todo) => {
const res = await fetch("https://jsonplaceholder.typicode.com/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(todo),
});
if (!res.ok) throw new Error("Gagal menambah");
return res.json(); // → { title, completed, userId, id: 201 }
},
onSuccess: (dariServer) => {
const todoBaru = { ...dariServer, id: Date.now() }; // id unik karena server selalu 201
queryClient.setQueryData(["todos"], (lama = []) => [todoBaru, ...lama]);
},
});
return (
<button
disabled={add.isPending}
onClick={() => add.mutate({ title: "Tugas contoh", completed: false, userId: 1 })}
>
{add.isPending ? "Menyimpan..." : "Tambah tugas contoh"}
</button>
);
}
// 3. Hapus dengan optimistic update + rollback
const remove = useMutation({
mutationFn: async (id) => {
// ganti ke URL salah (mis. /todoss/) untuk menguji rollback
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Gagal menghapus");
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const sebelumnya = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (lama = []) => lama.filter((t) => t.id !== id));
return { sebelumnya };
},
onError: (_e, _id, hasil) => {
queryClient.setQueryData(["todos"], hasil?.sebelumnya);
},
});
// → URL benar: tugas hilang dan tetap hilang.
// → URL salah (404): tugas hilang sebentar lalu muncul kembali.
Ringkasan
useMutationuntuk operasi yang mengubah data; jalankan denganmutate()ataumutateAsync().invalidateQueries({ queryKey })= cara paling aman menyegarkan data setelah mutasi (cocok berdasarkan awalan key).setQueryDatamenulis langsung ke cache; selalu immutable.- Optimistic update:
cancelQueries→ snapshot →setQueryData→ rollback dionError→ invalidate dionSettled. - Pagination: key berisi nomor halaman +
placeholderData: keepPreviousData; infinite:useInfiniteQuerydenganinitialPageParamdangetNextPageParam.