Menggali useReducer di React: Mengelola State Kompleks dengan Pola Reducer
Dalam pengembangan aplikasi React, mengelola state adalah salah satu tantangan utama. Untuk state yang sederhana, useState adalah pilihan yang fantastis. Namun, bagaimana jika state Anda mulai tumbuh menjadi objek kompleks dengan banyak properti, atau ketika transisi state melibatkan logika yang rumit dan saling bergantung? Di sinilah useReducer datang sebagai pahlawan.
Artikel ini akan membawa Anda menyelami useReducer, sebuah hook fundamental di React yang seringkali diremehkan, namun sangat powerful untuk mengelola state kompleks. Kita akan belajar mengapa useReducer menjadi pilihan yang lebih baik daripada useState dalam beberapa skenario, bagaimana menggunakannya dari dasar, hingga pola-pola lanjutan yang akan membuat kode Anda lebih bersih, prediktif, dan mudah di-maintain.
1. Pendahuluan: Kapan useState Tidak Cukup?
Bayangkan Anda sedang membangun aplikasi e-commerce sederhana. Anda memiliki state untuk keranjang belanja yang berisi daftar item, total harga, status diskon, dan mungkin informasi pengiriman. Setiap kali pengguna menambah, mengurangi, atau menghapus item, Anda perlu memperbarui beberapa bagian dari state ini secara bersamaan.
Jika Anda menggunakan useState untuk setiap properti:
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [discountApplied, setDiscountApplied] = useState(false);
// ...dan seterusnya
Maka logika untuk memperbarui keranjang akan terlihat seperti ini:
const addItem = (newItem) => {
setItems((prevItems) => [...prevItems, newItem]);
setTotal((prevTotal) => prevTotal + newItem.price);
// Mungkin juga logika untuk discountApplied
};
Ini bisa menjadi masalah:
- Logika Tersebar: Logika transisi state yang terkait dengan satu entitas (keranjang belanja) tersebar di banyak fungsi
set*. - Ketergantungan State: Perubahan pada satu bagian state mungkin memerlukan perubahan pada bagian lain, meningkatkan risiko bug jika ada yang terlewat.
- Sulit Didebug: Melacak mengapa state berubah menjadi sulit karena banyak titik modifikasi.
- Testing yang Rumit: Menulis unit test untuk setiap fungsi
set*dan interaksinya bisa menjadi membingungkan.
useReducer menawarkan solusi elegan dengan mengonsolidasikan semua logika transisi state ke dalam satu fungsi reducer, mirip dengan cara kerja Redux. Ini memisahkan “apa yang terjadi” (action) dari “bagaimana state berubah” (reducer), menghasilkan state yang lebih prediktif dan kode yang lebih mudah dipahami.
2. Memahami Konsep Dasar Reducer
Inti dari useReducer adalah konsep reducer function. Reducer adalah sebuah fungsi murni (pure function) yang menerima dua argumen: currentState dan action, lalu mengembalikan nextState.
📌 Pure Function: Artinya, reducer tidak memiliki efek samping (side effects), selalu mengembalikan output yang sama untuk input yang sama, dan tidak memodifikasi argumen aslinya.
Struktur dasar reducer:
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
throw new Error();
}
}
Dan cara menggunakannya dengan useReducer:
import React, { useReducer } from 'react';
// 1. Definisikan reducer function
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: action.payload }; // Contoh action dengan payload
default:
// Penting untuk melempar error jika action tidak dikenali
throw new Error(`Unhandled action type: ${action.type}`);
}
}
function Counter() {
// 2. Inisialisasi useReducer
// useReducer(reducer, initialState, initFunction_opsional)
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
<button onClick={() => dispatch({ type: 'RESET', payload: 0 })}>Reset</button>
</div>
);
}
export default Counter;
✅ Poin Penting:
useReducermengembalikan sepasang nilai:[state, dispatch].stateadalah state saat ini.dispatchadalah fungsi yang Anda panggil untuk “mengirim” sebuahaction.actionbiasanya adalah objek dengan propertitype(string yang mendeskripsikan apa yang terjadi) dan opsionalpayload(data yang diperlukan untuk perubahan state).
3. Mengelola State Keranjang Belanja dengan useReducer
Mari kita terapkan useReducer pada contoh keranjang belanja kita.
import React, { useReducer } from 'react';
const initialCartState = {
items: [],
total: 0,
discountApplied: false,
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const newItem = action.payload;
const existingItemIndex = state.items.findIndex(item => item.id === newItem.id);
let updatedItems;
if (existingItemIndex > -1) {
// Jika item sudah ada, update kuantitas
updatedItems = state.items.map((item, index) =>
index === existingItemIndex
? { ...item, quantity: item.quantity + newItem.quantity }
: item
);
} else {
// Jika item baru, tambahkan
updatedItems = [...state.items, newItem];
}
const newTotal = updatedItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return {
...state,
items: updatedItems,
total: newTotal,
};
}
case 'REMOVE_ITEM': {
const itemIdToRemove = action.payload.id;
const updatedItems = state.items.filter(item => item.id !== itemIdToRemove);
const newTotal = updatedItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return {
...state,
items: updatedItems,
total: newTotal,
};
}
case 'APPLY_DISCOUNT': {
const discountPercentage = action.payload.percentage;
if (state.discountApplied) {
return state; // Hindari menerapkan diskon ganda
}
const discountedTotal = state.total * (1 - discountPercentage / 100);
return {
...state,
total: discountedTotal,
discountApplied: true,
};
}
case 'CLEAR_CART':
return initialCartState;
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
}
function ShoppingCart() {
const [cartState, dispatch] = useReducer(cartReducer, initialCartState);
const products = [
{ id: 'p1', name: 'Laptop', price: 1200, quantity: 1 },
{ id: 'p2', name: 'Mouse', price: 25, quantity: 1 },
];
return (
<div>
<h2>Keranjang Belanja</h2>
<div>
{products.map(product => (
<button
key={product.id}
onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}
style={{ margin: '5px' }}
>
Tambah {product.name}
</button>
))}
</div>
<h3>Item di Keranjang:</h3>
{cartState.items.length === 0 ? (
<p>Keranjang kosong.</p>
) : (
<ul>
{cartState.items.map(item => (
<li key={item.id}>
{item.name} (x{item.quantity}) - ${item.price * item.quantity}
<button
onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: { id: item.id } })}
style={{ marginLeft: '10px' }}
>
Hapus
</button>
</li>
))}
</ul>
)}
<p>Total: ${cartState.total.toFixed(2)}</p>
{cartState.discountApplied && <p>Diskon diterapkan!</p>}
<button
onClick={() => dispatch({ type: 'APPLY_DISCOUNT', payload: { percentage: 10 } })}
disabled={cartState.discountApplied}
>
Terapkan Diskon 10%
</button>
<button onClick={() => dispatch({ type: 'CLEAR_CART' })} style={{ marginLeft: '10px' }}>
Bersihkan Keranjang
</button>
</div>
);
}
export default ShoppingCart;
💡 Keuntungan dengan useReducer di sini:
- Semua logika untuk memodifikasi keranjang terkonsolidasi dalam satu fungsi
cartReducer. - Setiap
actiondengan jelas mendeskripsikan apa yang terjadi, bukan bagaimana state berubah. - State transisi menjadi lebih mudah diprediksi dan diuji.
4. Pola Lanjutan: Inisialisasi Lazy dan Action Creator
Inisialisasi Lazy (init function)
Untuk state awal yang kompleks atau memerlukan komputasi berat, Anda bisa menggunakan argumen ketiga useReducer, yaitu fungsi init. Fungsi ini akan dipanggil sekali saat komponen pertama kali dirender dan hasilnya akan menjadi state awal.
✅ Manfaat: Jika state awal bergantung pada props atau melakukan perhitungan mahal, memindahkannya ke fungsi init memastikan perhitungan tersebut hanya terjadi sekali, bukan setiap re-render.
// Fungsi inisialisasi
function init(initialCount) {
return { count: initialCount };
}
function counterReducer(state, action) { /* ... sama seperti sebelumnya ... */ }
function CounterWithLazyInit({ initialCount = 0 }) {
// useReducer(reducer, initialArg, init)
const [state, dispatch] = useReducer(counterReducer, initialCount, init);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
<button onClick={() => dispatch({ type: 'RESET', payload: initialCount })}>Reset</button>
</div>
);
}
Action Creators
Seiring bertambahnya kompleksitas aplikasi, action.type string bisa menjadi rawan typo. Action creators adalah fungsi yang membuat dan mengembalikan objek action. Ini meningkatkan konsistensi dan mengurangi kesalahan.
// constants.js
export const ADD_ITEM = 'ADD_ITEM';
export const REMOVE_ITEM = 'REMOVE_ITEM';
export const APPLY_DISCOUNT = 'APPLY_DISCOUNT';
export const CLEAR_CART = 'CLEAR_CART';
// actions.js
import * as types from './constants';
export const addItem = (item) => ({
type: types.ADD_ITEM,
payload: item,
});
export const removeItem = (id) => ({
type: types.REMOVE_ITEM,
payload: { id },
});
export const applyDiscount = (percentage) => ({
type: types.APPLY_DISCOUNT,
payload: { percentage },
});
export const clearCart = () => ({
type: types.CLEAR_CART,
});
Kemudian di komponen Anda:
import { useReducer } from 'react';
import { cartReducer, initialCartState } from './cartReducer'; // Misal reducer dan state di file terpisah
import { addItem, removeItem, applyDiscount, clearCart } from './actions'; // Action creators
function ShoppingCartWithActions() {
const [cartState, dispatch] = useReducer(cartReducer, initialCartState);
// ... (Sama seperti sebelumnya)
return (
<div>
{/* ... */}
<button onClick={() => dispatch(addItem({ id: 'p1', name: 'Laptop', price: 1200, quantity: 1 }))}>
Tambah Laptop
</button>
<button onClick={() => dispatch(removeItem('p1'))}>Hapus Laptop</button>
<button onClick={() => dispatch(applyDiscount(15))}>Terapkan Diskon 15%</button>
<button onClick={() => dispatch(clearCart())}>Bersihkan Keranjang</button>
</div>
);
}
Action creators membuat dispatching action lebih ekspresif dan mengurangi risiko typo pada action.type.
5. Menggabungkan useReducer dengan useContext
Untuk state yang perlu diakses oleh banyak komponen di pohon komponen, useReducer bisa digabungkan dengan useContext. Ini adalah pola umum untuk state management lokal yang mirip dengan Redux, tanpa perlu library eksternal.
import React, { createContext, useReducer, useContext } from 'react';
// 1. Definisikan Context
const CartContext = createContext();
const initialCartState = { /* ... sama seperti sebelumnya ... */ };
function cartReducer(state, action) { /* ... sama seperti sebelumnya ... */ }
// 2. Buat Provider Component
function CartProvider({ children }) {
const [cartState, dispatch] = useReducer(cartReducer, initialCartState);
return (
<CartContext.Provider value={{ cartState, dispatch }}>
{children}
</CartContext.Provider>
);
}
// 3. Buat Custom Hook untuk Konsumsi
function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
// Komponen yang menggunakan state keranjang
function CartDisplay() {
const { cartState, dispatch } = useCart();
return (
<div>
<h3>Keranjang Anda:</h3>
{cartState.items.length === 0 ? (
<p>Keranjang kosong.</p>
) : (
<ul>
{cartState.items.map(item => (
<li key={item.id}>
{item.name} (x{item.quantity}) - ${item.price * item.quantity}
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: { id: item.id } })}>Hapus</button>
</li>
))}
</ul>
)}
<p>Total: ${cartState.total.toFixed(2)}</p>
</div>
);
}
function ProductList() {
const { dispatch } = useCart();
const products = [
{ id: 'p3', name: 'Keyboard', price: 75, quantity: 1 },
{ id: 'p4', name: 'Monitor', price: 300, quantity: 1 },
];
return (
<div>
<h3>Produk Tersedia:</h3>
{products.map(product => (
<button
key={product.id}
onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}
style={{ margin: '5px' }}
>
Tambah {product.name}
</button>
))}
</div>
);
}
// Aplikasi utama
function App() {
return (
<CartProvider>
<h1>Aplikasi E-commerce Sederhana</h1>
<ProductList />
<CartDisplay />
<button onClick={() => {
const { dispatch } = useCart(); // Perlu panggil useCart lagi jika di luar komponen yang dibungkus
dispatch({ type: 'APPLY_DISCOUNT', payload: { percentage: 5 } });
}}>
Terapkan Diskon Global
</button>
</CartProvider>
);
}
export default App;
⚠️ Penting: Dalam contoh App di atas, pemanggilan useCart() di luar ProductList atau CartDisplay (yang berada di dalam CartProvider) akan menyebabkan error. Pastikan useCart hanya dipanggil di dalam komponen yang merupakan children dari CartProvider. Untuk App ini, Anda bisa menambahkan tombol diskon di dalam CartDisplay atau ProductList atau komponen lain yang dibungkus CartProvider.
6. Tips dan Best Practices useReducer
-
Pilih
useReduceruntuk:- State yang kompleks (objek atau array bersarang).
- Transisi state yang rumit dan saling bergantung.
- Logika update state yang perlu diuji secara terpisah.
- Ketika Anda ingin memisahkan logika dari komponen UI.
- Ketika Anda perlu mengoptimalkan re-render karena
dispatchfunction dijamin stabil (tidak akan berubah antar re-render), tidak sepertisetStateyang bisa berubah jika Anda menggunakannya dengan functional updates.
-
Pilih
useStateuntuk:- State sederhana (boolean, string, number, objek/array datar).
- Transisi state yang tidak kompleks dan independen.
-
Immutability adalah Kunci: Selalu kembalikan objek state baru dari reducer. Jangan memodifikasi state yang ada secara langsung. Gunakan spread operator (
...) untuk membuat salinan state atau bagian dari state yang akan diubah.❌ Hindari:
state.items.push(newItem)✅ Lakukan:return { ...state, items: [...state.items, newItem] } -
Actions yang Deskriptif: Buat
action.typesejelas mungkin tentang apa yang terjadi, bukan bagaimana state berubah. Contoh:ADD_ITEMlebih baik daripadaUPDATE_ITEM_LIST. -
Error Handling: Selalu sertakan
defaultcase di reducer Anda untuk menangani action yang tidak dikenal dan melempar error. Ini sangat membantu debugging. -
Modularisasi: Untuk reducer yang sangat besar, Anda bisa memecahnya menjadi reducer yang lebih kecil dan menggabungkannya menggunakan pola seperti
combineReducers(terinspirasi dari Redux).
Kesimpulan
useReducer adalah alat yang sangat ampuh dalam kotak perkakas developer React untuk mengelola state yang kompleks. Dengan mengadopsi pola reducer, Anda dapat mencapai kode yang lebih bersih, lebih prediktif, lebih mudah di-debug, dan lebih mudah diuji. Memahami kapan dan bagaimana menggunakan useReducer secara efektif akan meningkatkan kualitas aplikasi React Anda secara signifikan.
Meskipun useReducer mungkin terlihat sedikit lebih rumit di awal dibandingkan useState, investasi waktu untuk mempelajarinya akan terbayar lunas dalam jangka panjang, terutama saat Anda berhadapan dengan aplikasi yang terus berkembang dan memiliki logika state yang kaya.
🔗 Baca Juga
- Membangun Global State Management di React Tanpa Library Eksternal: Memanfaatkan Context API dan useReducer
- Menggali Lebih Dalam React Hooks: Panduan Praktis untuk Developer Modern
- Memahami Pola Desain Perangkat Lunak: Fondasi Kode yang Bersih dan Fleksibel
- Optimasi Re-render React: useMemo, useCallback, dan React.memo dalam Praktik