Selama bertahun-tahun, komunitas pengembang React terjebak dalam kebiasaan menempatkan seluruh data aplikasi ke dalam satu wadah global terpusat (single global store) menggunakan Redux. Di masa lalu, respons dari REST API, status autentikasi pengguna, status modal pop-up, teks input formulir pencarian, hingga daftar notifikasi dijejalkan ke dalam struktur reducer yang rumit dengan ratusan baris boilerplate action creators dan middleware saga/thunk.
Pendekatan monolitik ini menimbulkan beban perawatan teknis yang berat. Tim pengembang terpaksa menulis logika manual untuk melacak status pemuatan data (isLoading), status galat (error), penanda waktu kadaluarsa data (isStale), serta mekanisme pembatalan request. Lebih buruk lagi, perubahan kecil pada satu bagian state sering kali memicu gelombang re-render sia-sia di seluruh pohon komponen React.
Solusi arsitektur modern yang jauh lebih bersih dan terukur adalah memisahkan penanganan state secara tegas menjadi dua domain yang berbeda: Server Cache (Server State) yang dikelola oleh TanStack Query, dan Client State murni yang dikelola oleh Zustand.
Pemisahan Tanggung Jawab State pada Aplikasi Web Modern:
┌───────────────────────────────────────────────────────────────┐
│ Aplikasi React / Next.js │
└──────────────┬────────────────────────────────┬───────────────┘
│ │
(Server Data Asinkron) (Antarmuka Klien Sinkron)
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ TanStack Query (React Query) │ │ Zustand Store │
│ - Data Produk & Stok │ │ - Status Sidebar Terbuka │
│ - Riwayat Pesanan │ │ - Tab Form Multi-Langkah │
│ - Revalidasi On Window Focus │ │ - Filter Kategori Aktif │
│ - Deduplikasi HTTP Request │ │ - Modal Konfirmasi Dialog │
│ - Optimistic UI Updates │ │ - Preferensi Tema Gelap │
└──────────────┬───────────────┘ └──────────────┬───────────────┘
│ │
▼ ▼
[Database / REST API] [Memori RAM Browser]
Memahami Perbedaan Karakteristik Server State vs Client State #
Kekeliruan terbesar dalam perancangan frontend adalah memperlakukan data yang berasal dari server sama seperti data lokal browser. Keduanya memiliki siklus hidup yang bertolak belakang:
1. Karakteristik Server State #
Data server pada hakikatnya bukan milik browser. Browser hanyalah peminjam snapshot data dari database jarak jauh:
- Dimiliki dan dikendalikan oleh backend database.
- Bersifat asinkron dan membutuhkan waktu transmisi jaringan.
- Dapat diubah oleh pengguna lain kapan saja tanpa sepengetahuan tab browser saat ini (stale data).
- Membutuhkan strategi caching, penghapusan memori tak terpakai (garbage collection), serta sinkronisasi ulang berkala.
2. Karakteristik Client State #
Client state adalah informasi sementara yang murni mengatur kondisi visual antarmuka:
- Dimiliki sepenuhnya oleh proses memori tab browser pengguna saat ini.
- Bersifat sinkron dan dapat diubah seketika tanpa latensi I/O jaringan.
- Tidak pernah diubah oleh pengguna eksternal lain.
- Hilang begitu tab browser ditutup atau di-refresh (kecuali dipersistensikan ke localStorage).
Mengelola Server Data dengan TanStack Query #
Dengan TanStack Query, logika pengambilan data, caching, deduplikasi, dan refetching ditangani secara deklaratif. Pengembang tidak perlu lagi menyimpan array data hasil fetch ke dalam Redux atau Zustand.
// hooks/useProductInventory.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
interface ProductItem {
id: string;
name: string;
stock: number;
price: number;
}
export function useProductInventory(productId: string) {
return useQuery({
queryKey: ['products', productId],
queryFn: async (): Promise<ProductItem> => {
const response = await axios.get(`/api/v1/products/${productId}`);
return response.data;
},
// Data dianggap segar selama 2 menit, mencegah request berulang
staleTime: 1000 * 60 * 2,
// Data dipertahankan di memori cache selama 10 menit
gcTime: 1000 * 60 * 10,
// Refetch otomatis saat pengguna kembali fokus ke tab browser
refetchOnWindowFocus: true,
});
}
Untuk operasi mutasi data seperti pembaruan stok dengan umpan balik instan (optimistic updates):
export function useUpdateStock(productId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newStock: number) => axios.patch(`/api/v1/products/${productId}`, { stock: newStock }),
onMutate: async (newStock) => {
// 1. Batalkan query yang sedang berjalan untuk menghindari race condition
await queryClient.cancelQueries({ queryKey: ['products', productId] });
// 2. Simpan snapshot data sebelumnya untuk rollback jika request gagal
const previousProduct = queryClient.getQueryData<ProductItem>(['products', productId]);
// 3. Perbarui cache secara optimis di UI tanpa menunggu respons server
if (previousProduct) {
queryClient.setQueryData<ProductItem>(['products', productId], {
...previousProduct,
stock: newStock,
});
}
return { previousProduct };
},
onError: (err, newStock, context) => {
// Kembalikan ke state asli jika backend mengembalikan error
if (context?.previousProduct) {
queryClient.setQueryData(['products', productId], context.previousProduct);
}
},
onSettled: () => {
// Sinkronkan kembali dengan data riil database
queryClient.invalidateQueries({ queryKey: ['products', productId] });
},
});
}
Mengelola Client State dengan Zustand #
Untuk state antarmuka yang murni beroperasi di sisi klien (seperti keranjang belanja lokal sebelum checkout atau status panel filter), Zustand memberikan kesederhanaan maksimal dengan footprint ukuran bundle di bawah 2 KB.
Berbeda dengan React Context API yang memaksa render ulang seluruh child components ketika nilai context berubah, Zustand memanfaatkan sistem selective subscription. Komponen hanya akan me-render ulang jika properti spesifik yang dipilihnya mengalami perubahan referensial.
// store/useCartStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
quantity: number;
}
interface CartState {
items: CartItem[];
isDrawerOpen: boolean;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
toggleDrawer: () => void;
resetCart: () => void;
}
export const useCartStore = create<CartState>()(
devtools(
persist(
(set) => ({
items: [],
isDrawerOpen: false,
addItem: (newItem) =>
set((state) => {
const existingIndex = state.items.findIndex((i) => i.id === newItem.id);
if (existingIndex > -1) {
const updated = [...state.items];
updated[existingIndex].quantity += newItem.quantity;
return { items: updated };
}
return { items: [...state.items, newItem] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
toggleDrawer: () => set((state) => ({ isDrawerOpen: !state.isDrawerOpen })),
resetCart: () => set({ items: [] }),
}),
{
name: 'puluhlabs_cart_storage',
// Hanya simpan item keranjang ke localStorage, status drawer tetap di memori
partialize: (state) => ({ items: state.items }),
}
)
)
);
Penggunaan selektor atomik di dalam komponen mencegah re-render yang tidak diinginkan:
// components/CartBadge.tsx
import { useCartStore } from '@/store/useCartStore';
export function CartBadge() {
// Komponen ini HANYA me-render ulang ketika jumlah total item berubah,
// dan mengabaikan perubahan properti isDrawerOpen!
const itemCount = useCartStore((state) =>
state.items.reduce((total, item) => total + item.quantity, 0)
);
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800">
{itemCount}
</span>
);
}
Panduan Pengambilan Keputusan Arsitektur #
Ketika merancang fitur baru pada aplikasi frontend skala korporat, gunakan aturan praktis berikut untuk menentukan tempat penyimpanan state:
- Gunakan TanStack Query jika: Data berasal dari panggilan HTTP/GraphQL, data harus dibagi antar rute halaman berbeda, data membutuhkan indikator loading/error, atau data perlu di-refresh secara berkala.
- Gunakan Zustand jika: State mengatur kontrol UI (modal, panel drawer, dropdown, step wizard), state perlu diakses lintas pohon komponen tanpa prop-drilling, atau data perlu disimpan sementara di browser storage pengguna.
- Gunakan React
useStatelokal jika: Nilai state bersifat terisolasi di dalam satu komponen saja (misalnya teks yang sedang diketik pada satu input formulir sederhana) dan tidak dibutuhkan oleh komponen lain di luar hierarki tersebut.
Dengan disiplin memisahkan kedua domain ini, kode aplikasi frontend menjadi jauh lebih mudah diuji (unit testing), bebas dari belitan boilerplate reducer, dan memiliki performa rendering yang sangat optimal.
