Ada titik tertentu dalam pertumbuhan sebuah aplikasi React di mana Context API mulai terasa bukan solusi, tapi sumber masalah. Semua terlihat baik-baik saja di awal — beberapa createContext, beberapa Provider, dan state mengalir ke mana dibutuhkan. Lalu aplikasi tumbuh, form bertambah kompleks, dan tiba-tiba setiap keystroke terasa lambat. Bukan karena logika bisnis yang berat, tapi karena React sedang melakukan terlalu banyak pekerjaan yang sebenarnya tidak perlu.
Tim Affiliate Ads di Trendyol mengalami situasi persis seperti ini. Ads Editor mereka menghasilkan 8.000+ function call per interaksi, dengan UI lag yang terlihat jelas oleh pengguna. Setelah investigasi, akar masalahnya bukan di kalkulasi yang mahal atau network request yang lambat — melainkan di cara React Context menyebarkan perubahan state.
Artikel ini membahas mengapa Context API punya ceiling performa yang keras, bagaimana Zustand menyelesaikan masalah itu secara fundamental, dan bagaimana kita bisa melakukan migrasi secara bertahap tanpa merusak production.
Masalah Struktural Context API
Context API bekerja dengan model broadcast: ketika nilai sebuah context berubah, semua komponen yang mengkonsumsi context tersebut akan di-render ulang — tanpa terkecuali, tanpa peduli apakah bagian state yang berubah relevan untuk komponen itu atau tidak.
Ini bukan bug, ini adalah cara Context dirancang. Dari dokumentasi React sendiri:
“All consumers that are descendants of a Provider will re-render whenever the Provider’s value prop changes.”
Masalahnya terlihat jelas saat kita punya context seperti ini:
// state-context.tsx
interface EditorState {
title: string
description: string
tags: string[]
budget: number
schedule: Date | null
targetAudience: string[]
previewMode: boolean
}
const EditorContext = createContext<EditorState | null>(null)
Ketika pengguna mengetik di field title, React akan me-render ulang setiap komponen yang menggunakan useContext(EditorContext) — termasuk komponen yang hanya menampilkan budget, previewMode, atau bahkan komponen yang sama sekali tidak menampilkan field yang berubah.
Mengapa useMemo Tidak Cukup
Solusi yang sering dicoba adalah membungkus value dengan useMemo:
const EditorProvider = ({ children }: { children: React.ReactNode }) => {
const [state, setState] = useState<EditorState>(initialState)
const value = useMemo(() => ({ ...state, setState }), [state])
return <EditorContext.Provider value={value}>{children}</EditorContext.Provider>
}
Ini tidak menyelesaikan masalah inti. Selama state berubah, value baru akan dibuat, dan semua consumer tetap akan di-render ulang. useMemo hanya membantu mencegah re-render dari parent di luar Provider — bukan dari perubahan state di dalam Provider itu sendiri.
Tidak ada cara native di React untuk mencegah sebuah komponen yang menggunakan useContext dari re-render, bahkan ketika bagian state yang ia konsumsi tidak berubah. Ini adalah batasan desain Context API, bukan sesuatu yang bisa disiasati dengan memo atau useMemo.
Bagaimana Zustand Memecahkan Ini
Zustand menggunakan model yang berbeda secara fundamental: pull-based subscription. Alih-alih Context yang mendorong perubahan ke semua consumer, komponen secara eksplisit menyatakan bagian state mana yang mereka butuhkan melalui selector.
// store/editor-store.ts
import { create } from 'zustand'
interface EditorStore {
title: string
budget: number
previewMode: boolean
setTitle: (title: string) => void
setBudget: (budget: number) => void
togglePreview: () => void
}
export const useEditorStore = create<EditorStore>((set) => ({
title: '',
budget: 0,
previewMode: false,
setTitle: (title) => set({ title }),
setBudget: (budget) => set({ budget }),
togglePreview: () => set((state) => ({ previewMode: !state.previewMode })),
}))
Sekarang setiap komponen hanya subscribe ke potongan state yang mereka butuhkan:
// components/TitleInput.tsx
const TitleInput = () => {
// Hanya re-render ketika `title` berubah
const title = useEditorStore((state) => state.title)
const setTitle = useEditorStore((state) => state.setTitle)
return (
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
)
}
// components/BudgetDisplay.tsx
const BudgetDisplay = () => {
// Sama sekali tidak akan re-render saat title berubah
const budget = useEditorStore((state) => state.budget)
return <span>Budget: {budget}</span>
}
Ketika pengguna mengetik di TitleInput, hanya TitleInput yang di-render ulang. BudgetDisplay tidak bergerak sama sekali — karena ia tidak subscribe ke title.
Strategi Migrasi Bertahap
Migrasi sekaligus dari Context ke Zustand di seluruh codebase adalah resep untuk bug yang sulit dilacak. Pendekatan yang lebih aman adalah migrasi per feature atau per domain.
Langkah 1: Buat Store Baru Secara Paralel
Jangan hapus Context yang lama dulu. Buat store Zustand baru untuk satu domain state yang paling bermasalah:
// store/campaign-store.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface CampaignStore {
name: string
startDate: string
endDate: string
platforms: string[]
updateName: (name: string) => void
addPlatform: (platform: string) => void
removePlatform: (platform: string) => void
}
export const useCampaignStore = create<CampaignStore>()(
immer((set) => ({
name: '',
startDate: '',
endDate: '',
platforms: [],
updateName: (name) =>
set((state) => {
state.name = name
}),
addPlatform: (platform) =>
set((state) => {
state.platforms.push(platform)
}),
removePlatform: (platform) =>
set((state) => {
state.platforms = state.platforms.filter((p) => p !== platform)
}),
}))
)
Middleware immer di sini memungkinkan kita menulis update nested state seolah-olah kita mengubah objek langsung — tanpa spread operator yang verbose.
Langkah 2: Migrasi Komponen Daun Terlebih Dahulu
Mulai dari komponen paling dalam (leaf components) yang tidak punya child. Komponen-komponen ini biasanya yang paling banyak mengonsumsi state dan paling mudah dimigrasi tanpa efek samping:
// Sebelum: membaca dari Context
const CampaignNameInput = () => {
const { campaign, setCampaign } = useCampaignContext()
return (
<input
value={campaign.name}
onChange={(e) => setCampaign({ ...campaign, name: e.target.value })}
/>
)
}
// Sesudah: membaca langsung dari store
const CampaignNameInput = () => {
const name = useCampaignStore((state) => state.name)
const updateName = useCampaignStore((state) => state.updateName)
return (
<input
value={name}
onChange={(e) => updateName(e.target.value)}
/>
)
}
Langkah 3: Hapus Provider Setelah Semua Consumer Dimigrasi
Setelah semua komponen dalam satu domain sudah menggunakan store, kita bisa hapus Provider-nya. Lakukan ini domain per domain — jangan hapus semua Provider sekaligus.
Menggunakan DevTools untuk Debug
Zustand mendukung Redux DevTools secara native. Tambahkan middleware devtools agar kita bisa memantau perubahan state secara real-time:
// store/campaign-store.ts
import { create } from 'zustand'
import { devtools, immer } from 'zustand/middleware'
export const useCampaignStore = create<CampaignStore>()(
devtools(
immer((set) => ({
// ... state dan actions
})),
{ name: 'CampaignStore' }
)
)
Penamaan store dengan { name: 'CampaignStore' } sangat membantu ketika kita punya beberapa store sekaligus — masing-masing akan tampil terpisah di Redux DevTools.
Granular Selector untuk Performa Optimal
Selector adalah kunci performa Zustand. Semakin spesifik selector, semakin sedikit re-render yang terjadi.
Ketika kita perlu mengambil beberapa nilai sekaligus, gunakan useShallow untuk mencegah re-render yang tidak perlu akibat referensi objek yang selalu baru:
import { useShallow } from 'zustand/react/shallow'
// Tanpa useShallow: re-render setiap kali state apapun berubah
const { name, startDate } = useCampaignStore((state) => ({
name: state.name,
startDate: state.startDate,
}))
// Dengan useShallow: re-render hanya jika name atau startDate berubah
const { name, startDate } = useCampaignStore(
useShallow((state) => ({
name: state.name,
startDate: state.startDate,
}))
)
Aturan praktisnya: jika selector mengembalikan nilai primitif (string, number, boolean), tidak perlu useShallow. Jika selector mengembalikan objek atau array baru setiap render, gunakan useShallow untuk perbandingan shallow equality.
Scoped Store vs Single Global Store
Salah satu keputusan arsitektur paling penting saat menggunakan Zustand adalah menentukan granularitas store. Trendyol memilih pendekatan 7 store terpisah per domain untuk satu flow — bukan satu store global yang berisi semuanya.
| Pendekatan | Kelebihan | Kekurangan |
|---|---|---|
| Single global store | Mudah di-debug, satu sumber kebenaran | Re-render melebar jika selector tidak ketat |
| Multiple scoped stores | Re-render terisolasi per domain | Perlu koordinasi antar store jika ada dependency |
Untuk aplikasi dengan flow yang kompleks dan banyak fitur independen, scoped store jauh lebih efektif. Perubahan di CampaignStore tidak akan memicu apapun di BudgetStore, bahkan jika komponen-komponen keduanya ada dalam satu halaman.
Hasil yang Bisa Diharapkan
Berdasarkan migrasi yang dilakukan tim Trendyol, perbaikan performa yang mereka dapatkan mencakup sekitar 20% penurunan latency interaksi (31% untuk skenario paling kompleks), lebih dari 80% penurunan Cumulative Layout Shift, dan 28% lebih sedikit kalkulasi layout di browser.
Yang menarik adalah ini: bottleneck mereka bukan di JavaScript yang lambat, bukan di query database, bukan di network. React sedang melakukan terlalu banyak pekerjaan yang tidak diminta. Mengurangi re-render yang tidak perlu adalah 90% dari solusinya.
Kesimpulan
Context API bukan pilihan yang salah untuk state yang jarang berubah — tema, locale, atau data autentikasi. Masalah mulai muncul ketika ia digunakan untuk state yang berubah dengan frekuensi tinggi, seperti input form, UI interaction state, atau data yang di-update real-time. Di situlah Zustand masuk dan langsung terasa perbedaannya. Jika aplikasi React kita mulai terasa berat padahal logikanya sederhana, periksa dulu berapa banyak komponen yang re-render untuk setiap interaksi — kemungkinan besar jawabannya ada di sana.