Kenapa ini penting
Seiring aplikasi bertumbuh, logika state bisa tersebar di banyak event handler: tambahItem, hapusItem, ubahJumlah, kosongkan, pakaiKupon, masing-masing memanggil beberapa setter. Membaca alurnya jadi melelahkan dan bug mudah bersembunyi. useReducer memusatkan semua aturan perubahan state di satu fungsi bernama reducer. Handler cukup "melaporkan apa yang terjadi" lewat action, dan reducer yang memutuskan bagaimana state berubah. Pola ini juga fondasi library state populer seperti Redux, jadi memahaminya sekarang akan sangat membantu nanti.
Konsep inti
Tiga bagian useReducer
const [state, dispatch] = useReducer(reducer, stateAwal);
- Reducer: fungsi murni
(state, action) => stateBaru. Ditulis di luar komponen. - Action: objek yang menjelaskan apa yang terjadi. Konvensinya punya properti
type(string) plus data tambahan, misalnya{ type: 'tambah', produk }. - dispatch: fungsi untuk "mengirim" action ke reducer. Setelah reducer mengembalikan state baru, React merender ulang.
Analogi kantor pos: komponen tidak mengubah isi gudang sendiri. Ia menulis formulir (action) dan memasukkannya ke kotak surat (dispatch). Petugas gudang (reducer) membaca formulir, lalu memperbarui gudang sesuai aturan baku. Semua aturan ada di satu buku panduan, bukan di kepala setiap orang.
Aturan reducer
- Murni: tidak boleh fetch, memakai timer,
Math.random(), atau mengubah variabel luar. Input sama → output sama. - Immutable: selalu kembalikan objek/array baru, jangan mutasi
state. - Biasanya memakai
switch (action.type). Untuk type yang tidak dikenal, lempar error agar salah ketik langsung ketahuan. - Nama action sebaiknya menggambarkan apa yang terjadi dari sudut pengguna (
'item_ditambah','kupon_dipakai'), bukan "set ini, set itu".
dispatch dan snapshot
Seperti setter state, dispatch tidak mengubah state di render yang sedang berjalan. Nilai baru muncul di render berikutnya. React juga melakukan batching dispatch dalam satu event.
Kapan memilih useReducer vs useState
| Pertimbangan | useState |
useReducer |
|---|---|---|
| Bentuk state | Nilai sederhana (angka, string, boolean) | Objek/array dengan banyak cara berubah |
| Jumlah cara update | Sedikit | Banyak, dan beberapa mengubah beberapa field sekaligus |
| Lokasi logika | Tersebar di handler | Terpusat di reducer |
| Kemudahan uji | Perlu render komponen | Reducer bisa diuji sebagai fungsi biasa |
| Panjang kode | Lebih ringkas | Lebih panjang di awal |
| Debug | console.log di banyak tempat |
Satu console.log(action) di reducer melihat semua perubahan |
Pedoman praktis: mulai dengan useState. Pindah ke useReducer saat kamu mulai sering menulis "kalau ini berubah, yang itu juga harus berubah", atau saat jumlah handler yang mengubah state yang sama sudah lebih dari tiga atau empat. Keduanya juga boleh dipakai bersama di satu komponen.
Contoh kode
Keranjang belanja lengkap dengan reducer:
// src/App.jsx, keranjang belanja dengan useReducer
import { useReducer } from 'react';
const PRODUK = [
{ id: 'gayo', nama: 'Kopi Gayo', harga: 85000 },
{ id: 'toraja', nama: 'Kopi Toraja', harga: 90000 },
{ id: 'kintamani', nama: 'Kopi Kintamani', harga: 80000 },
];
const stateAwal = { items: [], kupon: null };
// Reducer: fungsi murni di luar komponen
function keranjangReducer(state, action) {
switch (action.type) {
case 'item_ditambah': {
const ada = state.items.find(i => i.id === action.produk.id);
if (ada) {
return {
...state,
items: state.items.map(i => (i.id === action.produk.id ? { ...i, qty: i.qty + 1 } : i)),
};
}
return { ...state, items: [...state.items, { ...action.produk, qty: 1 }] };
}
case 'qty_diubah': {
if (action.qty <= 0) {
return { ...state, items: state.items.filter(i => i.id !== action.id) };
}
return {
...state,
items: state.items.map(i => (i.id === action.id ? { ...i, qty: action.qty } : i)),
};
}
case 'item_dihapus':
return { ...state, items: state.items.filter(i => i.id !== action.id) };
case 'kupon_dipakai':
return { ...state, kupon: action.kode === 'NGOPI10' ? { kode: action.kode, diskon: 0.1 } : null };
case 'dikosongkan':
return stateAwal;
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
export default function Toko() {
const [state, dispatch] = useReducer(keranjangReducer, stateAwal);
// Data turunan dihitung saat render
const subtotal = state.items.reduce((s, i) => s + i.harga * i.qty, 0);
const diskon = state.kupon ? subtotal * state.kupon.diskon : 0;
const total = subtotal - diskon;
const rp = n => 'Rp ' + n.toLocaleString('id-ID');
return (
<main>
<h2>Produk</h2>
{PRODUK.map(p => (
<div key={p.id}>
{p.nama} - {rp(p.harga)}{' '}
<button onClick={() => dispatch({ type: 'item_ditambah', produk: p })}>Tambah</button>
</div>
))}
<h2>Keranjang</h2>
{state.items.length === 0 ? (
<p>Keranjang masih kosong.</p>
) : (
<ul>
{state.items.map(i => (
<li key={i.id}>
{i.nama}{' '}
<button onClick={() => dispatch({ type: 'qty_diubah', id: i.id, qty: i.qty - 1 })}>−</button>
{' '}{i.qty}{' '}
<button onClick={() => dispatch({ type: 'qty_diubah', id: i.id, qty: i.qty + 1 })}>+</button>
{' '}= {rp(i.harga * i.qty)}{' '}
<button onClick={() => dispatch({ type: 'item_dihapus', id: i.id })}>Hapus</button>
</li>
))}
</ul>
)}
<input
placeholder="Kode kupon (coba NGOPI10)"
onBlur={e => dispatch({ type: 'kupon_dipakai', kode: e.target.value.trim().toUpperCase() })}
/>
<p>Subtotal: {rp(subtotal)}</p>
{state.kupon && <p>Diskon {state.kupon.kode}: −{rp(diskon)}</p>}
<p><strong>Total: {rp(total)}</strong></p>
<button onClick={() => dispatch({ type: 'dikosongkan' })}>Kosongkan</button>
</main>
);
}
// Tambah Gayo 2x + Toraja 1x → Subtotal Rp 260.000
// Kupon NGOPI10 → Diskon −Rp 26.000, Total Rp 234.000
Reducer bisa diuji tanpa React sama sekali:
// Karena reducer fungsi murni, cukup panggil langsung
const s1 = keranjangReducer(stateAwal, { type: 'item_ditambah', produk: { id: 'gayo', nama: 'Kopi Gayo', harga: 85000 } });
const s2 = keranjangReducer(s1, { type: 'item_ditambah', produk: { id: 'gayo', nama: 'Kopi Gayo', harga: 85000 } });
console.log(s2.items[0].qty); // → 2
console.log(stateAwal.items.length); // → 0 (state awal tidak termutasi)
Todo list dengan reducer, membandingkan dengan versi useState di modul sebelumnya:
import { useReducer, useState } from 'react';
function todoReducer(todos, action) {
switch (action.type) {
case 'ditambah':
return [...todos, { id: action.id, teks: action.teks, selesai: false }];
case 'dicentang':
return todos.map(t => (t.id === action.id ? { ...t, selesai: !t.selesai } : t));
case 'dihapus':
return todos.filter(t => t.id !== action.id);
case 'selesai_dibersihkan':
return todos.filter(t => !t.selesai);
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
export function TodoReducer() {
const [todos, dispatch] = useReducer(todoReducer, []);
const [teks, setTeks] = useState(''); // state input tetap useState: sederhana
function handleSubmit(e) {
e.preventDefault();
if (!teks.trim()) return;
// id dibuat di handler, BUKAN di reducer (reducer harus murni)
dispatch({ type: 'ditambah', id: crypto.randomUUID(), teks: teks.trim() });
setTeks('');
}
return (
<>
<form onSubmit={handleSubmit}>
<input value={teks} onChange={e => setTeks(e.target.value)} placeholder="Tugas baru" />
<button>Tambah</button>
</form>
<ul>
{todos.map(t => (
<li key={t.id}>
<input type="checkbox" checked={t.selesai} onChange={() => dispatch({ type: 'dicentang', id: t.id })} />
{t.teks}
<button onClick={() => dispatch({ type: 'dihapus', id: t.id })}>×</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'selesai_dibersihkan' })}>Bersihkan yang selesai</button>
</>
);
}
Kesalahan umum
- Mutasi state di reducer.
state.items.push(x); return state;membuat React tidak melihat perubahan. Kembalikan objek/array baru. - Efek samping di reducer. Fetch,
Math.random(), ataucrypto.randomUUID()di dalam reducer membuatnya tidak murni (dan di StrictMode reducer dipanggil dua kali). Buat id/nilai acak di handler, lalu kirim lewat action. - Lupa
returndi salah satucase. Reducer mengembalikanundefineddan state hilang. Pastikan setiap cabang mengembalikan state, dandefaultmelempar error. - Lupa menyalin field lain.
return { items: [...] }menghapuskupon. Gunakan{ ...state, items: [...] }. - Memakai useReducer untuk state yang sangat sederhana. Satu boolean toggle tidak butuh reducer;
useStatelebih jelas.
Latihan
- Mudah, Ubah counter (
+1,−1,reset) menjadi versiuseReducerdengan action'naik','turun', dan'reset'. - Sedang, Buat reducer untuk form pendaftaran dengan state
{ nama, email, setuju, terkirim }dan action'field_diubah'(membawafielddanvalue),'dikirim', dan'direset'. Tombol kirim hanya aktif jika semua field terisi dansetujutrue. - Menantang, Tambahkan fitur "Undo" ke reducer keranjang: simpan state sebelumnya dalam
riwayat(array) di dalam state, dan action'dibatalkan'mengembalikan keranjang ke kondisi sebelum action terakhir.
Lihat solusi
1.
import { useReducer } from 'react';
function counterReducer(state, action) {
switch (action.type) {
case 'naik':
return { jumlah: state.jumlah + 1 };
case 'turun':
return { jumlah: state.jumlah - 1 };
case 'reset':
return { jumlah: 0 };
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
export default function Counter() {
const [state, dispatch] = useReducer(counterReducer, { jumlah: 0 });
return (
<>
<h2>{state.jumlah}</h2>
<button onClick={() => dispatch({ type: 'turun' })}>−1</button>
<button onClick={() => dispatch({ type: 'naik' })}>+1</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</>
);
}
2.
import { useReducer } from 'react';
const awal = { nama: '', email: '', setuju: false, terkirim: false };
function formReducer(state, action) {
switch (action.type) {
case 'field_diubah':
return { ...state, [action.field]: action.value };
case 'dikirim':
return { ...state, terkirim: true };
case 'direset':
return awal;
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
export default function FormDaftar() {
const [form, dispatch] = useReducer(formReducer, awal);
const valid = form.nama && form.email.includes('@') && form.setuju;
if (form.terkirim) {
return (
<>
<p>Terima kasih, {form.nama}! Cek email {form.email}.</p>
<button onClick={() => dispatch({ type: 'direset' })}>Daftar lagi</button>
</>
);
}
return (
<form
onSubmit={e => {
e.preventDefault();
dispatch({ type: 'dikirim' });
}}
>
<input
value={form.nama}
onChange={e => dispatch({ type: 'field_diubah', field: 'nama', value: e.target.value })}
placeholder="Nama"
/>
<input
value={form.email}
onChange={e => dispatch({ type: 'field_diubah', field: 'email', value: e.target.value })}
placeholder="Email"
/>
<label>
<input
type="checkbox"
checked={form.setuju}
onChange={e => dispatch({ type: 'field_diubah', field: 'setuju', value: e.target.checked })}
/>
Saya setuju dengan syarat & ketentuan
</label>
<button disabled={!valid}>Daftar</button>
</form>
);
}
3.
// Bungkus reducer lama: setiap action (kecuali undo) menyimpan snapshot sebelumnya
const stateAwalUndo = { sekarang: { items: [], kupon: null }, riwayat: [] };
function keranjangUndoReducer(state, action) {
if (action.type === 'dibatalkan') {
if (state.riwayat.length === 0) return state;
return {
sekarang: state.riwayat[state.riwayat.length - 1],
riwayat: state.riwayat.slice(0, -1),
};
}
return {
sekarang: keranjangReducer(state.sekarang, action), // pakai reducer dari contoh kode
riwayat: [...state.riwayat, state.sekarang],
};
}
// Di komponen:
// const [state, dispatch] = useReducer(keranjangUndoReducer, stateAwalUndo);
// const items = state.sekarang.items;
// <button onClick={() => dispatch({ type: 'dibatalkan' })} disabled={state.riwayat.length === 0}>Undo</button>
// Tambah Gayo, Tambah Toraja, Undo → keranjang kembali berisi Gayo saja
Karena semua perubahan lewat satu reducer, fitur undo cukup ditambahkan di satu tempat tanpa menyentuh satu pun event handler. Inilah kekuatan utama arsitektur reducer.
Ringkasan
useReducer(reducer, stateAwal)mengembalikan[state, dispatch]; handler mengirim action, reducer menentukan state baru.- Reducer adalah fungsi murni
(state, action) => stateBaruyang immutable, biasanya memakaiswitchdan melempar error untuk action tak dikenal. - Nilai acak, id, dan efek samping dibuat di handler lalu dikirim lewat action, bukan di dalam reducer.
- Pilih
useReducersaat state punya banyak cara berubah atau beberapa field harus berubah bersamaan;useStatetetap terbaik untuk state sederhana. - Reducer mudah diuji sebagai fungsi biasa dan memudahkan fitur lintas-aksi seperti undo dan logging.