Migrasi React Context ke Zustand
React Tutorial Frontend #react #zustand #state-management #performance

Migrasi React Context ke Zustand

A
Abd. Asis
7 min read
Bagikan:

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.

PendekatanKelebihanKekurangan
Single global storeMudah di-debug, satu sumber kebenaranRe-render melebar jika selector tidak ketat
Multiple scoped storesRe-render terisolasi per domainPerlu 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.

Referensi

  1. 1pmndrs/zustand — GitHub Repository (README & API docs)
  2. 2React Context — Dokumentasi Resmi React
  3. 3Zustand and React Context — TkDodo’s Blog
  4. 4Immer — Immutable State with Mutable Syntax (Dokumentasi Resmi)

Tentang Penulis

Abd. Asis

Abd. Asis

Software Developer dan Laravel Programmer dari Madura, Indonesia. Passionate tentang PHP, Laravel, dan teknologi web modern.

Komentar

Artikel Terkait

Artikel lain yang mungkin menarik untuk kamu