kodingup.camp

Beranda/Various Library Mastery

Middleware dan Penyimpanan Permanen

Modul 6 dari 134 menit bacaMiddleware & Persistent Storage

Isi modul

    Kenapa ini penting

    Coba refresh halaman aplikasi yang memakai Zustand: semua state kembali ke nilai awal. Tema gelap yang dipilih user hilang, filter kembali ke "semua", user harus login ulang. Untuk banyak jenis client state, user berharap pilihannya diingat.

    Zustand menyediakan middleware: fungsi pembungkus yang menambah kemampuan pada store tanpa mengubah cara kamu menulis store. Tiga yang paling sering dipakai: persist (simpan ke localStorage), devtools (lihat perubahan state di Redux DevTools), dan immer (menulis update dengan gaya "mengubah langsung").

    Konsep inti

    Apa itu middleware?

    Analogi: kamu punya kamera (store). Middleware seperti aksesori yang dipasang di kamera, tripod, lampu kilat, lensa tambahan. Kameranya tetap sama, cara memotretnya sama, tapi kemampuannya bertambah. Secara teknis, middleware adalah fungsi yang menerima fungsi pembuat store (set, get) => ({...}) dan mengembalikan fungsi pembuat store yang sudah "ditingkatkan".

    persist

    import { create } from "zustand";
    import { persist, createJSONStorage } from "zustand/middleware";
    
    const useSettings = create(
      persist(
        (set) => ({ theme: "light", setTheme: (theme) => set({ theme }) }),
        {
          name: "pengaturan-app", // nama key di localStorage, harus unik
          storage: createJSONStorage(() => localStorage), // default memang localStorage
          partialize: (state) => ({ theme: state.theme }), // simpan hanya field tertentu
          version: 1,
        }
      )
    );
    

    Opsi penting:

    Opsi Fungsi
    name Wajib. Nama key di storage
    storage Tempat simpan. Default createJSONStorage(() => localStorage); bisa sessionStorage
    partialize Pilih field yang disimpan (action dan data sementara tidak perlu)
    version + migrate Kalau bentuk state berubah di versi baru aplikasi, ubah data lama ke bentuk baru
    onRehydrateStorage Callback sebelum/sesudah data dimuat dari storage

    localStorage hanya menyimpan string, sehingga createJSONStorage mengubah state menjadi JSON. Akibatnya, tipe khusus seperti Date, Map, atau Set tidak kembali utuh, Date akan menjadi string. Simpan tanggal sebagai string ISO atau angka timestamp.

    Rehydration adalah proses memuat kembali data dari storage ke store saat aplikasi dibuka. Dengan localStorage proses ini berjalan sinkron saat store dibuat, jadi di aplikasi Vite biasa data sudah tersedia sejak render pertama.

    Keamanan localStorage

    localStorage bisa dibaca oleh JavaScript apa pun yang berjalan di halamanmu, termasuk skrip jahat kalau situsmu kena XSS. Jangan simpan data sensitif seperti password. Menyimpan token di localStorage memang umum di proyek belajar dan banyak aplikasi, tapi aplikasi dengan keamanan tinggi biasanya memakai cookie httpOnly yang diatur server. Data yang dibaca dari storage juga tidak divalidasi otomatis; data rusak atau versi lama bisa masuk, karena itu version dan migrate penting.

    devtools

    import { devtools } from "zustand/middleware";
    
    const useStore = create(devtools((set) => ({ /* ... */ }), { name: "TodoStore" }));
    

    Pasang ekstensi browser Redux DevTools, lalu kamu bisa melihat setiap perubahan state, nilai sebelum dan sesudah, dan bahkan "time travel" (kembali ke state sebelumnya). Kamu bisa memberi nama aksi dengan argumen ketiga set: set({ theme }, undefined, "settings/setTheme").

    immer

    Menulis update immutable untuk data bersarang bisa melelahkan:

    set((s) => ({ user: { ...s.user, alamat: { ...s.user.alamat, kota: "Bandung" } } }));
    

    Middleware immer (butuh paket immer, versi 11.2.9) membuatmu bisa menulis seolah mengubah langsung, dan Immer akan membuatkan salinan baru di belakang layar:

    import { immer } from "zustand/middleware/immer";
    
    set((s) => {
      s.user.alamat.kota = "Bandung";
    });
    

    Ingat: gaya "mengubah langsung" ini hanya aman di dalam set pada store yang memakai middleware immer.

    Urutan middleware

    Middleware bisa ditumpuk. Dokumentasi Zustand menyarankan devtools diletakkan paling luar, supaya middleware lain tidak mengubah set sebelum devtools membacanya:

    create(devtools(persist(immer((set) => ({ /* ... */ })), { name: "storage-key" }), { name: "NamaStore" }));
    

    Bacanya dari dalam ke luar: store dasar → dibungkus immer → dibungkus persist → dibungkus devtools.

    Kapan memakai persist, dan kapan tidak

    Cocok disimpan Jangan disimpan
    Tema, bahasa, ukuran font Password atau data kartu
    Filter dan urutan terakhir Status loading, modal terbuka
    Isi keranjang sebelum checkout Data dari server (biarkan TanStack Query)
    Draft form yang panjang Data yang harus sama di semua perangkat (simpan di server)

    Contoh nyata: aplikasi catatan sering menyimpan draft yang sedang diketik ke localStorage, sehingga kalau browser tertutup tidak sengaja, tulisan tidak hilang. Tapi daftar catatan yang sudah tersimpan tetap diambil dari server, supaya sama di laptop dan HP.

    Contoh kode

    npm install zustand immer
    
    // src/stores/useSettingsStore.js
    import { create } from "zustand";
    import { createJSONStorage, devtools, persist } from "zustand/middleware";
    import { immer } from "zustand/middleware/immer";
    
    export const useSettingsStore = create(
      devtools(
        persist(
          immer((set) => ({
            theme: "light",
            bahasa: "id",
            filter: { status: "semua", urutkan: "terbaru" },
            modalTerbuka: false, // state sementara, tidak perlu disimpan
    
            toggleTheme: () =>
              set((s) => {
                s.theme = s.theme === "light" ? "dark" : "light"; // aman karena immer
              }),
            setUrutkan: (urutkan) =>
              set((s) => {
                s.filter.urutkan = urutkan; // ubah data bersarang tanpa spread berlapis
              }),
            setModal: (buka) =>
              set((s) => {
                s.modalTerbuka = buka;
              }),
          })),
          {
            name: "kodingup-settings",
            storage: createJSONStorage(() => localStorage),
            partialize: (s) => ({ theme: s.theme, bahasa: s.bahasa, filter: s.filter }),
            version: 1,
          }
        ),
        { name: "SettingsStore" }
      )
    );
    
    // src/components/ThemeToggle.jsx
    import { useEffect } from "react";
    import { useSettingsStore } from "../stores/useSettingsStore";
    
    export function ThemeToggle() {
      const theme = useSettingsStore((s) => s.theme);
      const toggleTheme = useSettingsStore((s) => s.toggleTheme);
    
      // terapkan class "dark" ke <html> supaya varian dark: Tailwind aktif
      useEffect(() => {
        document.documentElement.classList.toggle("dark", theme === "dark");
      }, [theme]);
    
      return (
        <button className="rounded border px-3 py-1" onClick={toggleTheme}>
          {theme === "dark" ? "Mode terang" : "Mode gelap"}
        </button>
      );
    }
    // → Pilih mode gelap, lalu refresh halaman: tetap gelap.
    // → DevTools > Application > Local Storage berisi key "kodingup-settings":
    // {"state":{"theme":"dark","bahasa":"id","filter":{"status":"semua","urutkan":"terbaru"}},"version":1}
    

    Catatan Tailwind v4: varian dark: secara default mengikuti pengaturan sistem operasi (prefers-color-scheme). Supaya mengikuti class dark yang kita pasang manual, tambahkan baris ini di CSS utama:

    /* src/index.css */
    @import "tailwindcss";
    @custom-variant dark (&:where(.dark, .dark *));
    

    Kesalahan umum

    Latihan

    1. Mudah, Tambahkan persist ke useCounterStore dari modul Zustand dengan nama "counter". Buktikan nilainya bertahan setelah refresh.
    2. Sedang, Buat store useAuthStore berisi token, user, login(token, user), dan logout(). Simpan ke localStorage hanya token dan user. Pastikan logout mengosongkan keduanya.
    3. Menantang, Kamu merilis versi baru di mana theme (string) diganti tampilan: { theme, ukuranFont }. Tulis konfigurasi version: 2 dan migrate yang mengubah data versi 1 milik user lama.
    Lihat solusi
    // 1.
    import { create } from "zustand";
    import { persist } from "zustand/middleware";
    
    export const useCounterStore = create(
      persist(
        (set) => ({
          count: 0,
          increment: () => set((s) => ({ count: s.count + 1 })),
        }),
        { name: "counter" } // storage default: localStorage
      )
    );
    // → Klik + tiga kali, refresh: tetap 3.
    
    // 2.
    export const useAuthStore = create(
      persist(
        (set) => ({
          token: null,
          user: null,
          login: (token, user) => set({ token, user }),
          logout: () => set({ token: null, user: null }),
        }),
        {
          name: "auth",
          partialize: (s) => ({ token: s.token, user: s.user }),
        }
      )
    );
    
    // 3. Migrasi versi 1 → 2
    persist(
      (set) => ({
        tampilan: { theme: "light", ukuranFont: "normal" },
        // ...action
      }),
      {
        name: "kodingup-settings",
        version: 2,
        migrate: (dataLama, versiLama) => {
          if (versiLama === 1) {
            const { theme, ...sisa } = dataLama;
            return { ...sisa, tampilan: { theme: theme ?? "light", ukuranFont: "normal" } };
          }
          return dataLama;
        },
      }
    );
    // User lama dengan {"theme":"dark"} → menjadi { tampilan: { theme: "dark", ukuranFont: "normal" } }
    

    Ringkasan

    Kuis pemahaman

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