kodingup.camp

Beranda/Various Library Mastery

Strategi Mengubah dan Menyegarkan Data

Modul 3 dari 135 menit bacaData Update & Refresh Strategies

Isi modul

    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:

    1. onMutate: batalkan refetch yang sedang berjalan (cancelQueries), simpan snapshot data lama (getQueryData), tulis data baru ke cache (setQueryData), lalu return { snapshot }.
    2. onError: kembalikan cache ke snapshot. Nilai yang di-return onMutate diterima sebagai argumen ketiga (disebut onMutateResult di dokumentasi v5).
    3. onSettled: invalidateQueries supaya 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)

    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

    Latihan

    1. Mudah, Buat tombol "Tambah tugas contoh" yang memakai useMutation untuk POST /todos dan menampilkan mutation.isPending sebagai teks "Menyimpan...".
    2. Sedang, Setelah POST sukses, tambahkan tugas baru ke cache ["todos"] memakai setQueryData (bukan invalidate, karena jsonplaceholder tidak menyimpan). Perhatikan: jsonplaceholder selalu mengembalikan id: 201, jadi buat id unik sendiri.
    3. 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

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.