Zustand Store Scoping dengan Context
React Frontend State Management #zustand #react #state-management #react-context

Zustand Store Scoping dengan Context

A
Abd. Asis
6 min read
Bagikan:

Ada satu skenario yang sering muncul di aplikasi React skala menengah ke atas: komponen yang sama perlu dirender lebih dari sekali secara bersamaan, dan masing-masing instance harus punya state-nya sendiri yang terisolasi. Bayangkan aplikasi manajemen proyek dengan beberapa board yang terbuka sekaligus — filter aktif, item yang dipilih, dan status loading di Board A tidak boleh bocor ke Board B.

Zustand dengan pendekatan default create() menghasilkan global singleton — satu store untuk seluruh aplikasi. Ini sempurna untuk state global seperti autentikasi atau tema, tapi menjadi masalah ketika state tersebut seharusnya hidup di level komponen. Di sinilah kombinasi createStore dan React Context menjadi solusi yang tepat.

Masalah dengan Global Store untuk State yang Bersifat Lokal

Misalkan ada komponen <TaskBoard> yang punya state sendiri: filter aktif, kolom mana yang sedang di-drag, dan item yang dipilih. Jika state ini disimpan di global store, dua instance <TaskBoard> yang berjalan bersamaan akan berbagi state yang sama — mengklik filter di satu board akan mengubah filter di board lain.

Solusi naif adalah menambahkan boardId ke dalam state dan memfilter berdasarkan itu. Tapi pendekatan ini memaksakan pengelolaan manual: membersihkan state saat komponen unmount, mengirimkan boardId ke setiap selector, dan menjaga konsistensi di seluruh codebase. Semuanya adalah boilerplate yang sebenarnya tidak perlu ada.

Ini berbeda dari masalah “banyak data di store” — masalahnya adalah lifetime dan kepemilikan state. State Board A seharusnya mati bersama Board A, bukan hidup terus di memori global.

Memahami createStore vs create

Zustand menyediakan dua cara membuat store. create() adalah API utama yang paling sering dipakai — menghasilkan hook yang terhubung ke sebuah global store. createStore() dari zustand/vanilla bekerja berbeda: ia hanya membuat objek store tanpa mengikatnya ke mana pun.

// store/task-board.ts
import { createStore } from "zustand/vanilla"

interface BoardFilter {
  status: "all" | "active" | "done"
  assignee: string | null
}

interface TaskBoardState {
  filter: BoardFilter
  selectedTaskId: string | null
  setFilter: (filter: Partial<BoardFilter>) => void
  selectTask: (id: string | null) => void
}

export type TaskBoardStore = ReturnType<typeof createTaskBoardStore>

export function createTaskBoardStore(initialFilter?: Partial<BoardFilter>) {
  return createStore<TaskBoardState>()((set) => ({
    filter: {
      status: "all",
      assignee: null,
      ...initialFilter,
    },
    selectedTaskId: null,
    setFilter: (partial) =>
      set((state) => ({ filter: { ...state.filter, ...partial } })),
    selectTask: (id) => set({ selectedTaskId: id }),
  }))
}

createTaskBoardStore adalah factory function — setiap kali dipanggil, ia menghasilkan store baru yang sepenuhnya independen. Tidak ada state yang dibagi antar instance.

Membungkus Store dalam React Context

Langkah berikutnya adalah menyediakan store ini ke komponen dalam subtree menggunakan React Context. Pola ini meminjam store instance (bukan nilainya) via context — ini perbedaan penting dari penggunaan Context yang umum.

// providers/task-board-provider.tsx
import { createContext, useContext, useRef, type ReactNode } from "react"
import { useStore } from "zustand"
import {
  createTaskBoardStore,
  type TaskBoardStore,
} from "@/store/task-board"
import type { TaskBoardState } from "@/store/task-board"

const TaskBoardContext = createContext<TaskBoardStore | null>(null)

interface TaskBoardProviderProps {
  children: ReactNode
  defaultAssignee?: string
}

export function TaskBoardProvider({
  children,
  defaultAssignee,
}: TaskBoardProviderProps) {
  const storeRef = useRef<TaskBoardStore | null>(null)

  if (storeRef.current === null) {
    storeRef.current = createTaskBoardStore({
      assignee: defaultAssignee ?? null,
    })
  }

  return (
    <TaskBoardContext.Provider value={storeRef.current}>
      {children}
    </TaskBoardContext.Provider>
  )
}

export function useTaskBoardStore<T>(
  selector: (state: TaskBoardState) => T
): T {
  const store = useContext(TaskBoardContext)
  if (store === null) {
    throw new Error("useTaskBoardStore harus digunakan di dalam TaskBoardProvider")
  }
  return useStore(store, selector)
}

Penggunaan useRef di sini intentional — ia memastikan store hanya dibuat sekali per instance provider, bukan setiap kali provider re-render. Ini adalah nuance yang mudah terlewat jika langsung menggunakan useState atau inisialisasi di luar render.

Menggunakan Provider di Komponen

Sekarang setiap <TaskBoard> bisa membungkus dirinya dalam provider. State setiap board terisolasi sepenuhnya karena masing-masing provider memegang instance store yang berbeda.

// components/task-board.tsx
import { TaskBoardProvider, useTaskBoardStore } from "@/providers/task-board-provider"

function BoardContent() {
  const filter = useTaskBoardStore((state) => state.filter)
  const setFilter = useTaskBoardStore((state) => state.setFilter)
  const selectedTaskId = useTaskBoardStore((state) => state.selectedTaskId)

  return (
    <div>
      <div className="flex gap-2 mb-4">
        {(["all", "active", "done"] as const).map((status) => (
          <button
            key={status}
            onClick={() => setFilter({ status })}
            className={filter.status === status ? "font-bold" : ""}
          >
            {status}
          </button>
        ))}
      </div>
      <p>Task terpilih: {selectedTaskId ?? "tidak ada"}</p>
    </div>
  )
}

interface TaskBoardProps {
  boardId: string
  defaultAssignee?: string
}

export function TaskBoard({ boardId, defaultAssignee }: TaskBoardProps) {
  return (
    <TaskBoardProvider defaultAssignee={defaultAssignee}>
      <BoardContent />
    </TaskBoardProvider>
  )
}

Dua instance <TaskBoard> yang berjalan bersamaan kini punya state yang benar-benar terpisah:

// pages/workspace.tsx
export function WorkspacePage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <TaskBoard boardId="sprint-1" defaultAssignee="alice" />
      <TaskBoard boardId="sprint-2" defaultAssignee="bob" />
    </div>
  )
}

Mengubah filter di board pertama tidak akan memengaruhi board kedua sama sekali.

Inisialisasi Store dari Props

Salah satu kelebihan utama pola ini yang tidak bisa dilakukan oleh global store biasa: store bisa diinisialisasi dengan props dari parent. Tidak perlu useEffect untuk mensinkronkan nilai awal, tidak ada double-render.

// providers/task-board-provider.tsx (diperluas)
interface TaskBoardProviderProps {
  children: ReactNode
  defaultAssignee?: string
  initialStatus?: "all" | "active" | "done"
  projectId: string
}

export function TaskBoardProvider({
  children,
  defaultAssignee,
  initialStatus = "all",
  projectId,
}: TaskBoardProviderProps) {
  const storeRef = useRef<TaskBoardStore | null>(null)

  if (storeRef.current === null) {
    storeRef.current = createTaskBoardStore({
      assignee: defaultAssignee ?? null,
      status: initialStatus,
    })
  }

  return (
    <TaskBoardContext.Provider value={storeRef.current}>
      {children}
    </TaskBoardContext.Provider>
  )
}

Inisialisasi terjadi hanya sekali — saat store dibuat pertama kali. Perubahan props setelah itu tidak akan mengubah nilai awal. Jika perlu sinkronisasi props-ke-store secara berkelanjutan, gunakan useEffect secara eksplisit di dalam komponen consumer.

Kapan Pola Ini Tepat Digunakan

Pola ini menambahkan boilerplate dibanding global store biasa. Bukan sesuatu yang perlu diterapkan ke semua store. Berikut panduan kapan pola ini memberikan nilai yang sepadan:

KondisiPendekatan yang Tepat
Satu instance komponen, state sederhanaGlobal store biasa dengan create()
Beberapa instance, state harus terisolasicreateStore + React Context
State yang perlu diinisialisasi dari propscreateStore + React Context
Testing komponen secara terisolasicreateStore + React Context
State global seperti auth atau temaGlobal store biasa dengan create()

Pola context menjadi sangat bernilai saat menulis unit test — store yang scoped ke komponen berarti tidak ada state yang bocor antar test case, tidak perlu manual cleanup, dan setiap test mendapat slate yang bersih secara otomatis.

Hal yang Perlu Diperhatikan

Ada beberapa gotcha yang sering muncul saat pertama kali menerapkan pola ini.

Jangan memanggil createTaskBoardStore langsung di render body. Ini akan membuat store baru di setiap render cycle, membuang state yang ada.

// SALAH — store baru setiap render
export function TaskBoardProvider({ children }: { children: ReactNode }) {
  const store = createTaskBoardStore() // dipanggil langsung, bukan di ref
  return <TaskBoardContext.Provider value={store}>{children}</TaskBoardContext.Provider>
}

// BENAR — store dibuat sekali
export function TaskBoardProvider({ children }: { children: ReactNode }) {
  const storeRef = useRef<TaskBoardStore | null>(null)
  if (storeRef.current === null) {
    storeRef.current = createTaskBoardStore()
  }
  return <TaskBoardContext.Provider value={storeRef.current}>{children}</TaskBoardContext.Provider>
}

Lifetime store mengikuti lifetime provider. Saat provider unmount, store ikut hilang — ini biasanya perilaku yang diinginkan. Tapi jika ada data yang perlu dipertahankan setelah komponen unmount (misalnya untuk di-restore nanti), data tersebut perlu disimpan di luar provider, misalnya di global store atau server.

Kesimpulan

Memisahkan antara state yang memang global dan state yang hanya relevan untuk subtree tertentu adalah keputusan arsitektur yang berdampak besar pada maintainability. Kombinasi createStore dari Zustand dan React Context memberikan cara yang bersih untuk mengelola state per-instance: lifetime mengikuti komponen, inisialisasi dari props bisa dilakukan langsung, dan testing menjadi lebih mudah karena isolasi sudah baked-in. Untuk aplikasi yang sudah menggunakan Zustand, ini adalah pola yang natural untuk ditambahkan ke toolkit tanpa perlu library tambahan.

Referensi

  1. 1Zustand — Initialize State with Props (Official Documentation)
  2. 2TkDodo — Zustand and React Context
  3. 3pmndrs/zustand — GitHub Repository

Tentang Penulis

Abd. Asis

Abd. Asis

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

Komentar