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:
- Data yang Dibutuhkan oleh Banyak Komponen: Misalnya, informasi autentikasi pengguna, tema aplikasi, atau pengaturan preferensi.
- Menghindari Prop Drilling: Ketika data perlu diakses oleh komponen yang terletak jauh di pohon komponen, global state sangat membantu.
- State yang Kompleks: Untuk state yang memiliki banyak transisi atau logika pembaruan yang kompleks,
useReducersangat cocok untuk mengelola logika tersebut di satu tempat. - Konsistensi Data: Memastikan bahwa data yang sama terlihat sama di seluruh bagian aplikasi.
❌ Kapan Global State Mungkin Berlebihan:
- State Lokal Komponen: Jika state hanya memengaruhi satu komponen atau turunannya yang langsung, gunakan
useStatedi komponen tersebut. Misalnya, state input form yang belum disubmit, atau state toggle untuk modal. - State yang Jarang Berubah: Untuk data statis atau yang jarang berubah, meneruskannya sebagai prop mungkin lebih sederhana.
- Kinerja: Terlalu banyak state global atau pembaruan yang tidak dioptimalkan dapat memicu re-render yang tidak perlu di banyak komponen. Namun, dengan penggunaan yang bijak dan optimasi, masalah ini bisa diminimalisir.
🎯 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:
createContext: Fungsi ini membuat objek Context. Saat membuat Context, Anda bisa memberikan nilai default yang akan digunakan jika komponen mengonsumsi Context tanpa Provider.Provider: Setiap objek Context memiliki komponenProvider. Komponen ini “menyediakan” nilai ke semua komponen turunannya. Nilai yang diberikan ke propvaluepadaProviderakan menjadi nilai yang dapat diakses oleh komponen yang mengonsumsi Context.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:
reducerfunction: Sebuah fungsi murni yang menerimastatesaat ini dan sebuahaction, lalu mengembalikanstateyang baru. Ini mirip dengan reducer di Redux.initialState: Nilai awal dari state.
useReducer mengembalikan pasangan nilai:
state: Nilai state saat ini.dispatchfunction: Sebuah fungsi yang Anda panggil dengan sebuahactionuntuk 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:
- Buat file terpisah untuk Context dan Reducer (misalnya,
CartContext.js). - Definisikan
initialStatedancartReducerdi sana. - Buat Context menggunakan
createContext. - Buat
CartProvideryang akan menggunakanuseReducerdan menyediakancartStatesertadispatchmelalui Context. - 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:
-
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> -
Memoize
valueProvider: Pastikan objekvalueyang Anda berikan keProvidertidak berubah pada setiap render kecuali state di dalamnya memang benar-benar berubah. GunakanuseMemountuk memoize objekvalue.// 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,useMemountukcontextValueakan tetap berubah. Pastikan fungsi-fungsi ini juga di-memoize denganuseCallbackjika mereka tidak bergantung pada state yang berubah. Dalam kasususeReducer,dispatchfunction itu sendiri stabil dan tidak akan berubah, jadi Anda bisa langsung menggunakannya. -
Gunakan
React.memopada Komponen Anak: Untuk komponen yang mengonsumsi Context tetapi tidak perlu re-render jika props yang mereka terima tidak berubah (meskipun Context berubah), bungkus denganReact.memo.// src/components/CartSummary.js import React from 'react'; // ... function CartSummary() { /* ... */ } export default React.memo(CartSummary); -
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