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
- Menyimpan seluruh state termasuk state sementara. Tanpa
partialize, hal sepertimodalTerbukaikut tersimpan dan modal bisa terbuka sendiri saat halaman dimuat. Simpan hanya yang perlu diingat. - Memakai nama
nameyang sama untuk dua store. Keduanya akan saling menimpa di localStorage. Beri nama unik. - Menyimpan
Date,Map, atauSetlalu kaget bentuknya berubah. JSON tidak mendukung tipe itu. Simpan sebagai string ISO, angka, atau array biasa. - Mengubah bentuk state tanpa menaikkan
version. User lama masih punya data bentuk lama di browser. Naikkanversiondan tulismigrate. - Memakai gaya mutasi tanpa middleware immer.
s.theme = "dark"di store tanpa immer tidak memicu update. Pilih salah satu gaya secara konsisten.
Latihan
- Mudah, Tambahkan
persistkeuseCounterStoredari modul Zustand dengan nama"counter". Buktikan nilainya bertahan setelah refresh. - Sedang, Buat store
useAuthStoreberisitoken,user,login(token, user), danlogout(). Simpan ke localStorage hanyatokendanuser. Pastikanlogoutmengosongkan keduanya. - Menantang, Kamu merilis versi baru di mana
theme(string) digantitampilan: { theme, ukuranFont }. Tulis konfigurasiversion: 2danmigrateyang 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
- Middleware menambah kemampuan store tanpa mengubah cara menulis store.
persistmenyimpan state ke localStorage (default); pakainameunik,partialize, danversion/migrate.- Data disimpan sebagai JSON: hindari
Date/Map/Setmentah, jangan simpan data sensitif. devtoolsmenghubungkan store ke ekstensi Redux DevTools;immermemungkinkan update gaya mutasi yang tetap immutable.- Urutan yang disarankan:
devtools(persist(immer(...)))dengan devtools paling luar.