REACT STATE-MANAGEMENT JAVASCRIPT FRONTEND WEB-DEVELOPMENT REACT-HOOKS CLEAN-CODE BEST-PRACTICES PERFORMANCE SCALABILITY

Membangun Global State Management di React Tanpa Library Eksternal: Memanfaatkan Context API dan useReducer

⏱️ 17 menit baca
👨‍💻

Membangun Global State Management di React Tanpa Library Eksternal: Memanfaatkan Context API dan useReducer

1. Pendahuluan

Dalam pengembangan aplikasi React yang kompleks, mengelola state bisa menjadi tantangan tersendiri. Seiring bertambahnya fitur dan komponen, Anda mungkin mulai menemukan masalah “prop drilling”—situasi di mana Anda harus meneruskan props dari komponen induk ke beberapa level komponen anak, meskipun komponen di tengah tidak memerlukannya. Hal ini membuat kode sulit dibaca, dirawat, dan di-debug.

Di sinilah Global State Management berperan. Banyak developer langsung beralih ke library eksternal populer seperti Redux, Zustand, atau Jotai untuk mengatasi masalah ini. Namun, tahukah Anda bahwa React sendiri menyediakan semua alat yang Anda butuhkan untuk membangun solusi state management global yang kuat dan efisien, hanya dengan menggunakan Context API dan hook useReducer?

Artikel ini akan memandu Anda langkah demi langkah untuk membangun sistem state management global yang bersih, skalabel, dan mudah dirawat, tanpa ketergantungan pada library pihak ketiga. Pendekatan ini sangat cocok untuk proyek-proyek yang ingin meminimalkan ukuran bundle, atau bagi Anda yang ingin memahami lebih dalam cara kerja React sebelum beralih ke solusi yang lebih kompleks.

Mari kita selami!

2. Mengapa Global State Penting? (Dan Kapan Tidak?)

Sebelum kita mulai coding, penting untuk memahami kapan global state menjadi solusi yang tepat dan kapan tidak.

✅ Kapan Global State Berguna:

❌ Kapan Global State Mungkin Berlebihan:

🎯 Intinya: Gunakan global state ketika data tersebut benar-benar “global” dan dibutuhkan oleh banyak bagian aplikasi, bukan hanya karena “terlihat keren.”

3. Memahami Context API: Fondasi State Global

Context API adalah cara React untuk menyediakan data ke pohon komponen tanpa harus meneruskan props secara manual di setiap level. Ini adalah fondasi utama kita untuk state global.

Konsep Dasar Context API:

  1. createContext: Fungsi ini membuat objek Context. Saat membuat Context, Anda bisa memberikan nilai default yang akan digunakan jika komponen mengonsumsi Context tanpa Provider.
  2. Provider: Setiap objek Context memiliki komponen Provider. Komponen ini “menyediakan” nilai ke semua komponen turunannya. Nilai yang diberikan ke prop value pada Provider akan menjadi nilai yang dapat diakses oleh komponen yang mengonsumsi Context.
  3. useContext: Hook ini digunakan oleh komponen anak untuk “mengonsumsi” nilai dari Context yang paling dekat di atasnya dalam pohon komponen.

Mari kita lihat contoh sederhana untuk tema aplikasi:

// 1. Buat Context
import React, { createContext, useContext, useState } from 'react';

const ThemeContext = createContext('light'); // Nilai default

// 2. Buat Provider
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
  };

  const contextValue = { theme, toggleTheme };

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
}

// 3. Buat Custom Hook untuk mengonsumsi Context (opsional tapi disarankan)
function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

// Komponen yang mengonsumsi tema
function MyComponent() {
  const { theme, toggleTheme } = useTheme();

  return (
    <div style={{ background: theme === 'dark' ? '#333' : '#fff', color: theme === 'dark' ? '#fff' : '#333' }}>
      <h1>Current Theme: {theme}</h1>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
}

// Komponen Root Aplikasi
function App() {
  return (
    <ThemeProvider>
      <MyComponent />
    </ThemeProvider>
  );
}

export default App;

💡 Tips: Selalu bungkus logic useContext dalam custom hook (useTheme di atas). Ini membuat penggunaan Context lebih bersih, mudah di-test, dan memberikan pesan error yang jelas jika Context tidak tersedia.

4. Memanfaatkan useReducer: Logika State yang Lebih Kompleks

Context API bagus untuk mendistribusikan nilai, tetapi tidak menyediakan mekanisme untuk mengelola logika pembaruan state yang kompleks secara terpusat. Di sinilah useReducer masuk. useReducer adalah alternatif useState untuk state yang lebih kompleks, di mana transisi state melibatkan beberapa nilai atau logika yang rumit.

Konsep Dasar useReducer:

useReducer menerima dua argumen:

  1. reducer function: Sebuah fungsi murni yang menerima state saat ini dan sebuah action, lalu mengembalikan state yang baru. Ini mirip dengan reducer di Redux.
  2. initialState: Nilai awal dari state.

useReducer mengembalikan pasangan nilai:

  1. state: Nilai state saat ini.
  2. dispatch function: Sebuah fungsi yang Anda panggil dengan sebuah action untuk memicu pembaruan state.

Mari kita bayangkan state keranjang belanja:

import React, { useReducer } from 'react';

// 1. Initial State
const initialCartState = {
  items: [],
  totalItems: 0,
  totalPrice: 0,
};

// 2. Reducer Function
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      const existingItem = state.items.find(item => item.id === action.payload.id);
      let updatedItems;
      let updatedTotalItems = state.totalItems + 1;
      let updatedTotalPrice = state.totalPrice + action.payload.price;

      if (existingItem) {
        updatedItems = state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      } else {
        updatedItems = [...state.items, { ...action.payload, quantity: 1 }];
      }

      return {
        ...state,
        items: updatedItems,
        totalItems: updatedTotalItems,
        totalPrice: updatedTotalPrice,
      };

    case 'REMOVE_ITEM':
      const itemToRemove = state.items.find(item => item.id === action.payload.id);
      if (!itemToRemove) return state; // Item not found

      let newItems;
      let newTotalItems = state.totalItems - 1;
      let newTotalPrice = state.totalPrice - itemToRemove.price;

      if (itemToRemove.quantity === 1) {
        newItems = state.items.filter(item => item.id !== action.payload.id);
      } else {
        newItems = state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: item.quantity - 1 }
            : item
        );
      }

      return {
        ...state,
        items: newItems,
        totalItems: newTotalItems,
        totalPrice: newTotalPrice,
      };

    case 'CLEAR_CART':
      return initialCartState;

    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
}

// Komponen Keranjang Belanja
function Cart() {
  const [cartState, dispatch] = useReducer(cartReducer, initialCartState);

  const addItemHandler = (item) => {
    dispatch({ type: 'ADD_ITEM', payload: item });
  };

  const removeItemHandler = (id) => {
    dispatch({ type: 'REMOVE_ITEM', payload: { id } });
  };

  const clearCartHandler = () => {
    dispatch({ type: 'CLEAR_CART' });
  };

  return (
    <div>
      <h2>Shopping Cart</h2>
      <p>Total Items: {cartState.totalItems}</p>
      <p>Total Price: ${cartState.totalPrice.toFixed(2)}</p>
      <ul>
        {cartState.items.map(item => (
          <li key={item.id}>
            {item.name} (x{item.quantity}) - ${item.price.toFixed(2)}
            <button onClick={() => removeItemHandler(item.id)}>Remove One</button>
          </li>
        ))}
      </ul>
      <button onClick={() => addItemHandler({ id: 'p1', name: 'Laptop', price: 1200 })}>Add Laptop</button>
      <button onClick={() => addItemHandler({ id: 'p2', name: 'Mouse', price: 25 })}>Add Mouse</button>
      <button onClick={clearCartHandler}>Clear Cart</button>
    </div>
  );
}

export default Cart;

📌 Penting: Fungsi reducer haruslah pure function. Artinya, ia tidak boleh memiliki side effects (seperti memanggil API atau mengubah data di luar scope-nya) dan harus selalu mengembalikan state baru, bukan memodifikasi state yang sudah ada.

5. Menggabungkan Context dan useReducer: Pola State Global yang Kuat

Sekarang, mari kita gabungkan kekuatan Context API dan useReducer untuk membangun solusi state management global yang lengkap. Kita akan mengambil contoh keranjang belanja dari sebelumnya dan membuatnya dapat diakses dari mana saja di aplikasi.

Langkah-langkahnya:

  1. Buat file terpisah untuk Context dan Reducer (misalnya, CartContext.js).
  2. Definisikan initialState dan cartReducer di sana.
  3. Buat Context menggunakan createContext.
  4. Buat CartProvider yang akan menggunakan useReducer dan menyediakan cartState serta dispatch melalui Context.
  5. Buat custom hook (useCart) untuk memudahkan konsumsi Context.
// src/store/CartContext.js
import React, { createContext, useReducer, useContext } from 'react';

// 1. Initial State
const initialCartState = {
  items: [],
  totalItems: 0,
  totalPrice: 0,
};

// 2. Reducer Function
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      // ... (logika ADD_ITEM seperti di contoh useReducer sebelumnya)
      const existingItemIndex = state.items.findIndex(item => item.id === action.payload.id);
      const existingItem = state.items[existingItemIndex];
      let updatedItems;
      let updatedTotalItems = state.totalItems + 1;
      let updatedTotalPrice = state.totalPrice + action.payload.price;

      if (existingItem) {
        const updatedItem = {
          ...existingItem,
          quantity: existingItem.quantity + 1,
        };
        updatedItems = [...state.items];
        updatedItems[existingItemIndex] = updatedItem;
      } else {
        updatedItems = state.items.concat({ ...action.payload, quantity: 1 });
      }

      return {
        items: updatedItems,
        totalItems: updatedTotalItems,
        totalPrice: updatedTotalPrice,
      };

    case 'REMOVE_ITEM':
      // ... (logika REMOVE_ITEM seperti di contoh useReducer sebelumnya)
      const itemToRemoveIndex = state.items.findIndex(item => item.id === action.payload.id);
      const itemToRemove = state.items[itemToRemoveIndex];
      if (!itemToRemove) return state;

      let newItems;
      let newTotalItems = state.totalItems - 1;
      let newTotalPrice = state.totalPrice - itemToRemove.price;

      if (itemToRemove.quantity === 1) {
        newItems = state.items.filter(item => item.id !== action.payload.id);
      } else {
        const updatedItem = { ...itemToRemove, quantity: itemToRemove.quantity - 1 };
        newItems = [...state.items];
        newItems[itemToRemoveIndex] = updatedItem;
      }

      return {
        items: newItems,
        totalItems: newTotalItems,
        totalPrice: newTotalPrice,
      };

    case 'CLEAR_CART':
      return initialCartState;

    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
}

// 3. Buat Context
const CartContext = createContext({
  items: [],
  totalItems: 0,
  totalPrice: 0,
  addItem: (item) => {},
  removeItem: (id) => {},
  clearCart: () => {},
});

// 4. Buat CartProvider
export function CartProvider({ children }) {
  const [cartState, dispatch] = useReducer(cartReducer, initialCartState);

  const addItemHandler = (item) => {
    dispatch({ type: 'ADD_ITEM', payload: item });
  };

  const removeItemHandler = (id) => {
    dispatch({ type: 'REMOVE_ITEM', payload: { id } });
  };

  const clearCartHandler = () => {
    dispatch({ type: 'CLEAR_CART' });
  };

  const contextValue = {
    items: cartState.items,
    totalItems: cartState.totalItems,
    totalPrice: cartState.totalPrice,
    addItem: addItemHandler,
    removeItem: removeItemHandler,
    clearCart: clearCartHandler,
  };

  return (
    <CartContext.Provider value={contextValue}>
      {children}
    </CartContext.Provider>
  );
}

// 5. Buat Custom Hook untuk mengonsumsi Context
export function useCart() {
  const context = useContext(CartContext);
  if (context === undefined) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
}

Sekarang, komponen apa pun di bawah CartProvider dapat mengakses state keranjang belanja dan fungsi untuk memodifikasinya:

// src/App.js
import React from 'react';
import { CartProvider } from './store/CartContext';
import ProductList from './components/ProductList';
import CartSummary from './components/CartSummary';

function App() {
  return (
    <CartProvider>
      <div style={{ display: 'flex', gap: '20px', padding: '20px' }}>
        <ProductList />
        <CartSummary />
      </div>
    </CartProvider>
  );
}

export default App;
// src/components/ProductList.js
import React from 'react';
import { useCart } from '../store/CartContext';

function ProductList() {
  const { addItem } = useCart();

  const products = [
    { id: 'p1', name: 'Laptop', price: 1200 },
    { id: 'p2', name: 'Mouse', price: 25 },
    { id: 'p3', name: 'Keyboard', price: 75 },
  ];

  return (
    <div>
      <h3>Available Products</h3>
      <ul>
        {products.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price.toFixed(2)}
            <button onClick={() => addItem(product)} style={{ marginLeft: '10px' }}>Add to Cart</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default ProductList;
// src/components/CartSummary.js
import React from 'react';
import { useCart } from '../store/CartContext';

function CartSummary() {
  const { items, totalItems, totalPrice, removeItem, clearCart } = useCart();

  return (
    <div>
      <h3>Your Cart ({totalItems} items)</h3>
      {totalItems === 0 ? (
        <p>Cart is empty.</p>
      ) : (
        <>
          <ul>
            {items.map(item => (
              <li key={item.id}>
                {item.name} (x{item.quantity}) - ${item.price.toFixed(2)}
                <button onClick={() => removeItem(item.id)} style={{ marginLeft: '10px' }}>Remove One</button>
              </li>
            ))}
          </ul>
          <p><strong>Total: ${totalPrice.toFixed(2)}</strong></p>
          <button onClick={clearCart}>Clear Cart</button>
        </>
      )}
    </div>
  );
}

export default CartSummary;

Dengan pola ini, ProductList dapat menambahkan item ke keranjang, dan CartSummary dapat menampilkan serta memodifikasi keranjang, tanpa ada prop yang diteruskan di antara mereka!

6. Optimasi Performa: Kapan Harus Hati-hati?

Meskipun kuat, penggunaan Context API dan useReducer juga memiliki potensi masalah performa jika tidak dioptimalkan.

⚠️ Masalah Potensial: Setiap kali nilai value pada Context.Provider berubah, semua komponen yang mengonsumsi Context tersebut akan re-render, terlepas dari apakah bagian state yang mereka gunakan benar-benar berubah atau tidak. Ini bisa menjadi bottleneck performa di aplikasi besar.

✅ Strategi Optimasi:

  1. Pisahkan Context: Jika Anda memiliki banyak bagian state global yang tidak saling terkait (misalnya, state autentikasi dan state keranjang belanja), pisahkan menjadi Context yang berbeda. Ini memastikan bahwa perubahan di satu bagian state tidak memicu re-render yang tidak perlu di komponen yang hanya bergantung pada bagian state lainnya.

    // App.js
    <AuthProvider>
      <CartProvider>
        <AppContent />
      </CartProvider>
    </AuthProvider>
  2. Memoize value Provider: Pastikan objek value yang Anda berikan ke Provider tidak berubah pada setiap render kecuali state di dalamnya memang benar-benar berubah. Gunakan useMemo untuk memoize objek value.

    // Di dalam CartProvider
    const contextValue = useMemo(() => ({
      items: cartState.items,
      totalItems: cartState.totalItems,
      totalPrice: cartState.totalPrice,
      addItem: addItemHandler,
      removeItem: removeItemHandler,
      clearCart: clearCartHandler,
    }), [cartState.items, cartState.totalItems, cartState.totalPrice, addItemHandler, removeItemHandler, clearCartHandler]); // Pastikan dependencies benar!
    
    return (
      <CartContext.Provider value={contextValue}>
        {children}
      </CartContext.Provider>
    );

    Catatan: Untuk fungsi addItemHandler, removeItemHandler, clearCartHandler, jika mereka dibuat ulang pada setiap render, useMemo untuk contextValue akan tetap berubah. Pastikan fungsi-fungsi ini juga di-memoize dengan useCallback jika mereka tidak bergantung pada state yang berubah. Dalam kasus useReducer, dispatch function itu sendiri stabil dan tidak akan berubah, jadi Anda bisa langsung menggunakannya.

  3. Gunakan React.memo pada Komponen Anak: Untuk komponen yang mengonsumsi Context tetapi tidak perlu re-render jika props yang mereka terima tidak berubah (meskipun Context berubah), bungkus dengan React.memo.

    // src/components/CartSummary.js
    import React from 'react';
    // ...
    function CartSummary() { /* ... */ }
    export default React.memo(CartSummary);
  4. Pisahkan State dan Dispatch: Jika suatu komponen hanya membutuhkan fungsi dispatch (untuk memodifikasi state) tetapi tidak membutuhkan state itu sendiri, Anda bisa memisahkan Context menjadi dua: satu untuk state dan satu untuk dispatch.

    // src/store/CartContext.js
    const CartStateContext = createContext(initialCartState);
    const CartDispatchContext = createContext(() => {});
    
    export function CartProvider({ children }) {
      const [cartState, dispatch] = useReducer(cartReducer, initialCartState);
      return (
        <CartStateContext.Provider