Bab 5: Memindahkan Logika State ke Reducer
⏱ 4 menit bacaPendahuluan: Ketika useState Mulai Berantakan
Bayangin kamu punya warung makan kecil. Awalnya, kamu sendiri yang terima pesanan, masak, dan antar ke meja. Simpel. Tapi seiring warung makin rame, kamu mulai kewalahan. Pesanan tercampur, ada yang kelewat, ada yang salah.
Solusinya? Kamu hire seorang kasir yang khusus mencatat dan mengatur pesanan. Kamu (si koki) tinggal terima "tiket pesanan" yang sudah rapi dari kasir.
Di React, useState itu kayak kamu ngurus semuanya sendiri. Cocok buat yang simpel. Tapi kalau state logic udah kompleks (banyak aksi, banyak kondisi), saatnya pakai reducer. Reducer itu si "kasir" yang mengatur semua perubahan state di satu tempat yang rapi.
Apa Itu Reducer?
Di restoran yang terorganisir:
- Pelayan (event handler) terima permintaan dari pelanggan
- Pelayan nulis di bon pesanan (action object): "Meja 5 mau tambah Nasi Goreng"
- Bon dikirim ke kasir/dapur (reducer)
- Kasir proses bon dan update papan pesanan (state)
Pelayan gak perlu tau gimana cara update papan pesanan. Dia cuma perlu tau cara nulis bon yang benar.
Dalam Kode: useState vs useReducer
Dengan useState (ngurus sendiri):
function TodoApp() {
const [todos, setTodos] = useState([]);
// Setiap aksi, kamu langsung manipulasi state
function handleTambah(teks) {
setTodos([...todos, { id: Date.now(), teks, selesai: false }]);
}
function handleToggle(id) {
setTodos(todos.map(t => t.id === id ? { ...t, selesai: !t.selesai } : t));
}
function handleHapus(id) {
setTodos(todos.filter(t => t.id !== id));
}
function handleEdit(id, teksBaru) {
setTodos(todos.map(t => t.id === id ? { ...t, teks: teksBaru } : t));
}
// ... dan seterusnya
}Masalahnya: logika update state tersebar di banyak fungsi. Kalau mau tau "apa aja yang bisa terjadi pada todos?", kamu harus baca SEMUA handler satu per satu.
Dengan useReducer (pakai kasir):
function TodoApp() {
const [todos, dispatch] = useReducer(todosReducer, []);
// Handler cuma "kirim bon" (dispatch action)
function handleTambah(teks) {
dispatch({ type: 'tambah', teks });
}
function handleToggle(id) {
dispatch({ type: 'toggle', id });
}
function handleHapus(id) {
dispatch({ type: 'hapus', id });
}
function handleEdit(id, teksBaru) {
dispatch({ type: 'edit', id, teks: teksBaru });
}
}
// Semua logika update di SATU tempat
function todosReducer(state, action) {
switch (action.type) {
case 'tambah':
return [...state, { id: Date.now(), teks: action.teks, selesai: false }];
case 'toggle':
return state.map(t => t.id === action.id ? { ...t, selesai: !t.selesai } : t);
case 'hapus':
return state.filter(t => t.id !== action.id);
case 'edit':
return state.map(t => t.id === action.id ? { ...t, teks: action.teks } : t);
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}Anatomy of useReducer
const [state, dispatch] = useReducer(reducerFunction, initialState);| Bagian | Penjelasan | Analogi |
|---|---|---|
state | Data saat ini | Papan pesanan saat ini |
dispatch | Fungsi untuk kirim action | Pelayan yang kirim bon |
reducerFunction | Fungsi yang proses action jadi state baru | Kasir yang proses bon |
initialState | Nilai awal state | Papan pesanan kosong di awal hari |
Dispatch dan Action Objects
Apa Itu Action?
Action itu objek yang mendeskripsikan "apa yang terjadi". Minimal punya property type.
// Action sederhana
dispatch({ type: 'increment' });
// Action dengan data tambahan (payload)
dispatch({ type: 'tambah_todo', teks: 'Belajar reducer' });
// Action dengan banyak data
dispatch({
type: 'update_profil',
nama: 'Budi',
email: 'budi@email.com',
kota: 'Jakarta',
});Bon pesanan di restoran punya format:
- Jenis aksi (type): "Tambah", "Batalkan", "Ubah"
- Detail (payload): "Nasi Goreng, meja 5, pedas level 3"
// Bon: "Tambah Nasi Goreng untuk meja 5"
dispatch({
type: 'tambah_pesanan',
menu: 'Nasi Goreng',
meja: 5,
catatan: 'Pedas level 3',
});
// Bon: "Batalkan pesanan nomor 42"
dispatch({
type: 'batalkan_pesanan',
nomorPesanan: 42,
});Menulis Reducer Function
Aturan Reducer
- Pure function: Hasil HANYA bergantung pada input (state + action), gak ada side effect
- Return state baru: JANGAN mutasi state lama, buat objek/array baru
- Handle semua action type: Pakai switch/case atau if/else
// Struktur dasar reducer
function myReducer(state, action) {
switch (action.type) {
case 'aksi_1':
// Return state BARU (jangan mutasi yang lama!)
return { ...state, field: nilaiBaru };
case 'aksi_2':
return { ...state, field: nilaiLain };
default:
// Throw error untuk action yang gak dikenal (bantu debugging)
throw new Error('Action tidak dikenal: ' + action.type);
}
}Contoh Lengkap: Reducer untuk Keranjang Belanja
function keranjangReducer(state, action) {
switch (action.type) {
case 'tambah_item': {
// Cek apakah item sudah ada di keranjang
const existing = state.find(item => item.id === action.item.id);
if (existing) {
// Kalau sudah ada, tambah jumlahnya
return state.map(item =>
item.id === action.item.id
? { ...item, jumlah: item.jumlah + 1 }
: item
);
} else {
// Kalau belum ada, tambah item baru
return [...state, { ...action.item, jumlah: 1 }];
}
}
case 'kurangi_item': {
const item = state.find(i => i.id === action.id);
if (item.jumlah === 1) {
// Kalau tinggal 1, hapus dari keranjang
return state.filter(i => i.id !== action.id);
} else {
// Kalau lebih dari 1, kurangi jumlah
return state.map(i =>
i.id === action.id
? { ...i, jumlah: i.jumlah - 1 }
: i
);
}
}
case 'hapus_item':
return state.filter(item => item.id !== action.id);
case 'kosongkan':
return [];
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}Menggunakan Reducer di Komponen
import { useReducer } from 'react';
function Keranjang() {
const [items, dispatch] = useReducer(keranjangReducer, []);
// Derived values
const totalItem = items.reduce((sum, i) => sum + i.jumlah, 0);
const totalHarga = items.reduce((sum, i) => sum + i.harga * i.jumlah, 0);
return (
<div>
<h2>🛒 Keranjang ({totalItem} item)</h2>
{items.map(item => (
<div key={item.id} style={{ marginBottom: '10px' }}>
<span>{item.nama} x{item.jumlah}</span>
<span> = Rp {(item.harga * item.jumlah).toLocaleString()}</span>
<button onClick={() => dispatch({ type: 'tambah_item', item })}>+</button>
<button onClick={() => dispatch({ type: 'kurangi_item', id: item.id })}>-</button>
<button onClick={() => dispatch({ type: 'hapus_item', id: item.id })}>🗑️</button>
</div>
))}
{items.length > 0 && (
<>
<hr />
<p><strong>Total: Rp {totalHarga.toLocaleString()}</strong></p>
<button onClick={() => dispatch({ type: 'kosongkan' })}>
🗑️ Kosongkan Keranjang
</button>
</>
)}
{/* Tombol tambah produk (simulasi) */}
<div style={{ marginTop: '20px' }}>
<h3>Produk:</h3>
<button onClick={() => dispatch({
type: 'tambah_item',
item: { id: 1, nama: 'Nasi Goreng', harga: 25000 }
})}>
+ Nasi Goreng (25rb)
</button>
<button onClick={() => dispatch({
type: 'tambah_item',
item: { id: 2, nama: 'Es Teh', harga: 5000 }
})}>
+ Es Teh (5rb)
</button>
</div>
</div>
);
}Membandingkan useState vs useReducer
Kapan Pakai useState?
- State sederhana (string, number, boolean)
- Sedikit aksi (1-3 cara update)
- Logika update simpel (langsung set nilai baru)
- Komponen kecil
// useState cocok untuk ini:
const [nama, setNama] = useState('');
const [terbuka, setTerbuka] = useState(false);
const [count, setCount] = useState(0);Kapan Pakai useReducer?
- State kompleks (objek/array dengan banyak field)
- Banyak aksi (4+ cara update)
- Logika update rumit (kondisional, bergantung pada state sebelumnya)
- Mau pisahkan logika dari UI
- Mau testing lebih mudah
// useReducer cocok untuk ini:
const [formState, dispatch] = useReducer(formReducer, {
values: { nama: '', email: '', alamat: '' },
errors: {},
status: 'idle',
touched: {},
});Tabel Perbandingan
| Aspek | useState | useReducer |
|---|---|---|
| Kompleksitas state | Rendah | Tinggi |
| Jumlah aksi | Sedikit (1-3) | Banyak (4+) |
| Logika update | Simpel | Kompleks/kondisional |
| Testability | Susah di-test terpisah | Mudah di-test (pure function) |
| Debugging | Cek setiap handler | Cek satu reducer |
| Boilerplate | Minimal | Lebih banyak (action types, switch) |
| Learning curve | Rendah | Sedang |
Contoh Perbandingan: Form Multi-Step
Versi useState (mulai berantakan):
function FormMultiStep() {
const [langkah, setLangkah] = useState(1);
const [data, setData] = useState({ nama: '', email: '', alamat: '' });
const [errors, setErrors] = useState({});
const [status, setStatus] = useState('mengisi');
function handleMaju() {
const errorBaru = validasi(langkah, data);
if (Object.keys(errorBaru).length > 0) {
setErrors(errorBaru);
return;
}
setErrors({});
setLangkah(langkah + 1);
}
function handleMundur() {
setErrors({});
setLangkah(langkah - 1);
}
function handleInputBerubah(field, nilai) {
setData({ ...data, [field]: nilai });
if (errors[field]) {
setErrors({ ...errors, [field]: undefined });
}
}
async function handleSubmit() {
setStatus('mengirim');
try {
await kirimData(data);
setStatus('sukses');
} catch {
setStatus('error');
}
}
// ... banyak logika tersebar
}Versi useReducer (terorganisir):
const initialState = {
langkah: 1,
data: { nama: '', email: '', alamat: '' },
errors: {},
status: 'mengisi', // 'mengisi' | 'mengirim' | 'sukses' | 'error'
};
function formReducer(state, action) {
switch (action.type) {
case 'maju': {
const errors = validasi(state.langkah, state.data);
if (Object.keys(errors).length > 0) {
return { ...state, errors };
}
return { ...state, langkah: state.langkah + 1, errors: {} };
}
case 'mundur':
return { ...state, langkah: state.langkah - 1, errors: {} };
case 'input_berubah': {
const newErrors = { ...state.errors };
delete newErrors[action.field];
return {
...state,
data: { ...state.data, [action.field]: action.nilai },
errors: newErrors,
};
}
case 'submit_mulai':
return { ...state, status: 'mengirim' };
case 'submit_sukses':
return { ...state, status: 'sukses' };
case 'submit_gagal':
return { ...state, status: 'error' };
case 'reset':
return initialState;
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
function FormMultiStep() {
const [state, dispatch] = useReducer(formReducer, initialState);
// Handler jadi super simpel!
function handleMaju() {
dispatch({ type: 'maju' });
}
function handleMundur() {
dispatch({ type: 'mundur' });
}
function handleInputBerubah(field, nilai) {
dispatch({ type: 'input_berubah', field, nilai });
}
async function handleSubmit() {
dispatch({ type: 'submit_mulai' });
try {
await kirimData(state.data);
dispatch({ type: 'submit_sukses' });
} catch {
dispatch({ type: 'submit_gagal' });
}
}
// Render berdasarkan state
if (state.status === 'sukses') {
return <h2>🎉 Berhasil!</h2>;
}
return (
<div>
<h2>Langkah {state.langkah}/3</h2>
{state.langkah === 1 && (
<div>
<input
placeholder="Nama"
value={state.data.nama}
onChange={(e) => handleInputBerubah('nama', e.target.value)}
/>
{state.errors.nama && <p style={{ color: 'red' }}>{state.errors.nama}</p>}
</div>
)}
{state.langkah === 2 && (
<div>
<input
placeholder="Email"
value={state.data.email}
onChange={(e) => handleInputBerubah('email', e.target.value)}
/>
{state.errors.email && <p style={{ color: 'red' }}>{state.errors.email}</p>}
</div>
)}
{state.langkah === 3 && (
<div>
<input
placeholder="Alamat"
value={state.data.alamat}
onChange={(e) => handleInputBerubah('alamat', e.target.value)}
/>
{state.errors.alamat && <p style={{ color: 'red' }}>{state.errors.alamat}</p>}
</div>
)}
<div style={{ marginTop: '15px' }}>
{state.langkah > 1 && <button onClick={handleMundur}>← Kembali</button>}
{state.langkah < 3 && <button onClick={handleMaju}>Lanjut →</button>}
{state.langkah === 3 && (
<button onClick={handleSubmit} disabled={state.status === 'mengirim'}>
{state.status === 'mengirim' ? '⏳...' : '✅ Kirim'}
</button>
)}
</div>
</div>
);
}
// Fungsi validasi (bisa di-test terpisah!)
function validasi(langkah, data) {
const errors = {};
if (langkah === 1 && !data.nama.trim()) errors.nama = 'Nama wajib diisi';
if (langkah === 2 && !data.email.includes('@')) errors.email = 'Email tidak valid';
if (langkah === 3 && !data.alamat.trim()) errors.alamat = 'Alamat wajib diisi';
return errors;
}Menggunakan Immer dengan Reducer
Masalah: Update Nested State Itu Ribet
// Tanpa Immer: harus spread di setiap level 😩
function reducer(state, action) {
switch (action.type) {
case 'update_alamat_kota':
return {
...state,
profil: {
...state.profil,
alamat: {
...state.profil.alamat,
kota: action.kota,
}
}
};
}
}Solusi: Immer Bikin Kamu Bisa "Mutasi" dengan Aman
Immer adalah library yang memungkinkan kamu menulis kode yang TERLIHAT seperti mutasi, tapi sebenarnya menghasilkan objek baru.
import { useImmerReducer } from 'use-immer';
function reducer(draft, action) {
// draft itu "copy" dari state yang boleh dimutasi!
switch (action.type) {
case 'update_alamat_kota':
// Langsung mutasi! Immer yang handle pembuatan objek baru
draft.profil.alamat.kota = action.kota;
break;
case 'tambah_todo':
draft.todos.push({ id: Date.now(), teks: action.teks, selesai: false });
break;
case 'hapus_todo':
const index = draft.todos.findIndex(t => t.id === action.id);
draft.todos.splice(index, 1);
break;
case 'toggle_todo':
const todo = draft.todos.find(t => t.id === action.id);
todo.selesai = !todo.selesai;
break;
}
}
function App() {
const [state, dispatch] = useImmerReducer(reducer, {
profil: {
nama: 'Budi',
alamat: { jalan: 'Jl. Merdeka', kota: 'Jakarta' }
},
todos: [],
});
// Pakai dispatch seperti biasa
dispatch({ type: 'update_alamat_kota', kota: 'Bandung' });
}Perbandingan: Dengan vs Tanpa Immer
// TANPA Immer: harus hati-hati bikin objek baru
function reducerTanpaImmer(state, action) {
switch (action.type) {
case 'toggle_todo':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id
? { ...todo, selesai: !todo.selesai }
: todo
),
};
}
}
// DENGAN Immer: langsung "mutasi" draft
function reducerDenganImmer(draft, action) {
switch (action.type) {
case 'toggle_todo':
const todo = draft.todos.find(t => t.id === action.id);
todo.selesai = !todo.selesai;
break;
}
}Immer sangat berguna kalau state kamu nested atau operasi update-nya kompleks.
Contoh Lengkap: Aplikasi Catatan dengan Reducer
import { useReducer } from 'react';
// Initial state
const initialState = {
catatan: [
{ id: 1, judul: 'Belanja', isi: 'Beli beras dan telur', warna: '#fff9c4' },
{ id: 2, judul: 'Meeting', isi: 'Zoom jam 3 sore', warna: '#c8e6c9' },
],
idBerikutnya: 3,
idSedangEdit: null,
};
// Reducer: semua logika di satu tempat
function catatanReducer(state, action) {
switch (action.type) {
case 'tambah': {
const catatanBaru = {
id: state.idBerikutnya,
judul: action.judul,
isi: action.isi,
warna: action.warna || '#ffffff',
};
return {
...state,
catatan: [...state.catatan, catatanBaru],
idBerikutnya: state.idBerikutnya + 1,
};
}
case 'hapus':
return {
...state,
catatan: state.catatan.filter(c => c.id !== action.id),
// Kalau yang dihapus lagi di-edit, reset edit mode
idSedangEdit: state.idSedangEdit === action.id ? null : state.idSedangEdit,
};
case 'mulai_edit':
return { ...state, idSedangEdit: action.id };
case 'batal_edit':
return { ...state, idSedangEdit: null };
case 'simpan_edit':
return {
...state,
catatan: state.catatan.map(c =>
c.id === action.id
? { ...c, judul: action.judul, isi: action.isi }
: c
),
idSedangEdit: null,
};
case 'ganti_warna':
return {
...state,
catatan: state.catatan.map(c =>
c.id === action.id ? { ...c, warna: action.warna } : c
),
};
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
// Komponen utama
function AplikasiCatatan() {
const [state, dispatch] = useReducer(catatanReducer, initialState);
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h1>📝 Catatan Saya</h1>
{/* Form tambah catatan baru */}
<FormTambah onTambah={(judul, isi, warna) =>
dispatch({ type: 'tambah', judul, isi, warna })
} />
{/* Daftar catatan */}
<div style={{ marginTop: '20px' }}>
{state.catatan.map(catatan => (
<KartuCatatan
key={catatan.id}
catatan={catatan}
sedangEdit={state.idSedangEdit === catatan.id}
dispatch={dispatch}
/>
))}
</div>
{state.catatan.length === 0 && (
<p style={{ color: '#999', textAlign: 'center' }}>
Belum ada catatan. Tambah yang pertama!
</p>
)}
</div>
);
}
function FormTambah({ onTambah }) {
const [judul, setJudul] = useState('');
const [isi, setIsi] = useState('');
const [warna, setWarna] = useState('#fff9c4');
function handleSubmit(e) {
e.preventDefault();
if (!judul.trim()) return;
onTambah(judul, isi, warna);
setJudul('');
setIsi('');
}
const warnaOpsi = ['#fff9c4', '#c8e6c9', '#bbdefb', '#f8bbd0', '#ffffff'];
return (
<form onSubmit={handleSubmit} style={{ background: '#f5f5f5', padding: '15px', borderRadius: '8px' }}>
<input
placeholder="Judul catatan"
value={judul}
onChange={(e) => setJudul(e.target.value)}
style={{ width: '100%', padding: '8px', marginBottom: '8px' }}
/>
<textarea
placeholder="Isi catatan..."
value={isi}
onChange={(e) => setIsi(e.target.value)}
rows={3}
style={{ width: '100%', padding: '8px', marginBottom: '8px' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{warnaOpsi.map(w => (
<div
key={w}
onClick={() => setWarna(w)}
style={{
width: '24px',
height: '24px',
background: w,
border: warna === w ? '3px solid #333' : '1px solid #ccc',
borderRadius: '50%',
cursor: 'pointer',
}}
/>
))}
<button type="submit" style={{ marginLeft: 'auto' }}>+ Tambah</button>
</div>
</form>
);
}
function KartuCatatan({ catatan, sedangEdit, dispatch }) {
const [draftJudul, setDraftJudul] = useState(catatan.judul);
const [draftIsi, setDraftIsi] = useState(catatan.isi);
if (sedangEdit) {
return (
<div style={{ background: catatan.warna, padding: '15px', borderRadius: '8px', marginBottom: '10px' }}>
<input
value={draftJudul}
onChange={(e) => setDraftJudul(e.target.value)}
style={{ width: '100%', padding: '5px', marginBottom: '5px', fontWeight: 'bold' }}
/>
<textarea
value={draftIsi}
onChange={(e) => setDraftIsi(e.target.value)}
rows={3}
style={{ width: '100%', padding: '5px' }}
/>
<div style={{ marginTop: '8px' }}>
<button onClick={() => dispatch({
type: 'simpan_edit', id: catatan.id, judul: draftJudul, isi: draftIsi
})}>💾 Simpan</button>
<button onClick={() => dispatch({ type: 'batal_edit' })}>❌ Batal</button>
</div>
</div>
);
}
return (
<div style={{ background: catatan.warna, padding: '15px', borderRadius: '8px', marginBottom: '10px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h3 style={{ margin: 0 }}>{catatan.judul}</h3>
<div>
<button onClick={() => dispatch({ type: 'mulai_edit', id: catatan.id })}>✏️</button>
<button onClick={() => dispatch({ type: 'hapus', id: catatan.id })}>🗑️</button>
</div>
</div>
<p style={{ margin: '8px 0 0' }}>{catatan.isi}</p>
</div>
);
}Tips Menulis Reducer yang Baik
1. Nama Action yang Deskriptif
// ❌ Terlalu generik
dispatch({ type: 'SET' });
dispatch({ type: 'UPDATE' });
// ✅ Deskriptif: menjelaskan APA YANG TERJADI
dispatch({ type: 'user_mengetik_nama' });
dispatch({ type: 'form_disubmit' });
dispatch({ type: 'server_merespons_sukses' });2. Satu Action Bisa Update Banyak Field
// ❌ Terlalu banyak action untuk satu "kejadian"
dispatch({ type: 'set_loading', value: true });
dispatch({ type: 'clear_error' });
dispatch({ type: 'clear_data' });
// ✅ Satu action untuk satu "kejadian"
dispatch({ type: 'mulai_fetch' });
// Di reducer:
case 'mulai_fetch':
return { ...state, loading: true, error: null, data: null };3. Reducer Harus Pure (Tanpa Side Effect)
// ❌ JANGAN lakukan side effect di reducer
function reducer(state, action) {
switch (action.type) {
case 'simpan':
localStorage.setItem('data', JSON.stringify(state)); // SIDE EFFECT!
fetch('/api/save', { body: JSON.stringify(state) }); // SIDE EFFECT!
return state;
}
}
// ✅ Side effect di event handler atau useEffect
function handleSimpan() {
dispatch({ type: 'simpan' });
localStorage.setItem('data', JSON.stringify(state)); // Di sini OK
}⚠️ Jebakan
Jebakan 1: Mutasi State di Reducer
// ❌ MUTASI! Ini mengubah state lama langsung
function reducer(state, action) {
switch (action.type) {
case 'tambah':
state.items.push(action.item); // MUTASI!
return state; // Mengembalikan objek yang SAMA
}
}
// ✅ Buat objek/array BARU
function reducer(state, action) {
switch (action.type) {
case 'tambah':
return {
...state,
items: [...state.items, action.item], // Array BARU
};
}
}Jebakan 2: Lupa Return di Setiap Case
// ❌ Lupa return → state jadi undefined!
function reducer(state, action) {
switch (action.type) {
case 'increment':
// Lupa return!
{ ...state, count: state.count + 1 };
break; // break tanpa return = masalah
}
}
// ✅ Selalu return
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
default:
return state; // PENTING: return state untuk action yang gak dikenal
}
}Jebakan 3: Terlalu Banyak Logic di Event Handler
// ❌ Logic di handler, reducer cuma "setter"
function handleCheckout() {
const total = items.reduce((s, i) => s + i.harga * i.jumlah, 0);
const diskon = total > 100000 ? total * 0.1 : 0;
const ongkir = kota === 'Jakarta' ? 0 : 15000;
dispatch({
type: 'set_checkout',
total,
diskon,
ongkir,
grandTotal: total - diskon + ongkir,
});
}
// ✅ Logic di reducer, handler cuma dispatch event
function handleCheckout() {
dispatch({ type: 'checkout' });
}
// Di reducer:
case 'checkout': {
const total = state.items.reduce((s, i) => s + i.harga * i.jumlah, 0);
const diskon = total > 100000 ? total * 0.1 : 0;
const ongkir = state.kota === 'Jakarta' ? 0 : 15000;
return {
...state,
checkout: { total, diskon, ongkir, grandTotal: total - diskon + ongkir },
};
}Jebakan 4: Gak Handle Default Case
// ❌ Typo di action type → silent bug
dispatch({ type: 'tmbah' }); // Typo! Harusnya 'tambah'
// Reducer gak match case manapun, return undefined → CRASH
// ✅ Throw error di default case
default:
throw new Error(`Action tidak dikenal: ${action.type}`);
// Sekarang typo langsung ketahuan!🏋️ Challenge
Challenge 1: Counter dengan Undo/Redo
Bikin counter yang punya fitur undo dan redo. Setiap perubahan disimpan di history.
Hint: State-nya berupa { past: [], present: 0, future: [] }. Undo = pindahkan present ke future, ambil dari past. Redo = sebaliknya.
Lihat Solusi
import { useReducer } from 'react';
const initialState = {
past: [], // Riwayat sebelumnya
present: 0, // Nilai saat ini
future: [], // Riwayat yang di-undo (untuk redo)
};
function counterReducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: present + 1,
future: [], // Reset future saat ada aksi baru
};
case 'decrement':
return {
past: [...past, present],
present: present - 1,
future: [],
};
case 'set':
return {
past: [...past, present],
present: action.nilai,
future: [],
};
case 'undo': {
if (past.length === 0) return state; // Gak bisa undo
const nilaiSebelumnya = past[past.length - 1];
return {
past: past.slice(0, -1),
present: nilaiSebelumnya,
future: [present, ...future],
};
}
case 'redo': {
if (future.length === 0) return state; // Gak bisa redo
const nilaiBerikutnya = future[0];
return {
past: [...past, present],
present: nilaiBerikutnya,
future: future.slice(1),
};
}
case 'reset':
return initialState;
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
function CounterUndoRedo() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<h2>🔢 Counter dengan Undo/Redo</h2>
<p style={{ fontSize: '48px', fontWeight: 'bold' }}>{state.present}</p>
<div style={{ marginBottom: '15px' }}>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'set', nilai: state.present * 2 })}>x2</button>
</div>
<div style={{ marginBottom: '15px' }}>
<button
onClick={() => dispatch({ type: 'undo' })}
disabled={state.past.length === 0}
>
↩️ Undo ({state.past.length})
</button>
<button
onClick={() => dispatch({ type: 'redo' })}
disabled={state.future.length === 0}
>
↪️ Redo ({state.future.length})
</button>
<button onClick={() => dispatch({ type: 'reset' })}>🔄 Reset</button>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
<p>Past: [{state.past.join(', ')}]</p>
<p>Present: {state.present}</p>
<p>Future: [{state.future.join(', ')}]</p>
</div>
</div>
);
}Challenge 2: Kanban Board Sederhana
Bikin kanban board dengan 3 kolom: "Todo", "In Progress", "Done". User bisa:
- Tambah task baru (masuk ke "Todo")
- Pindahkan task antar kolom (maju atau mundur)
- Hapus task
Hint: State berupa array tasks dengan field status. Reducer handle tambah, pindah_maju, pindah_mundur, hapus.
Lihat Solusi
import { useReducer, useState } from 'react';
const KOLOM = ['todo', 'in_progress', 'done'];
const LABEL_KOLOM = { todo: '📋 Todo', in_progress: '🔨 In Progress', done: '✅ Done' };
const initialState = {
tasks: [
{ id: 1, teks: 'Desain UI', status: 'todo' },
{ id: 2, teks: 'Setup project', status: 'in_progress' },
{ id: 3, teks: 'Buat wireframe', status: 'done' },
],
nextId: 4,
};
function kanbanReducer(state, action) {
switch (action.type) {
case 'tambah':
return {
...state,
tasks: [...state.tasks, { id: state.nextId, teks: action.teks, status: 'todo' }],
nextId: state.nextId + 1,
};
case 'pindah_maju': {
const task = state.tasks.find(t => t.id === action.id);
const indexSekarang = KOLOM.indexOf(task.status);
if (indexSekarang >= KOLOM.length - 1) return state; // Sudah di kolom terakhir
return {
...state,
tasks: state.tasks.map(t =>
t.id === action.id ? { ...t, status: KOLOM[indexSekarang + 1] } : t
),
};
}
case 'pindah_mundur': {
const task = state.tasks.find(t => t.id === action.id);
const indexSekarang = KOLOM.indexOf(task.status);
if (indexSekarang <= 0) return state; // Sudah di kolom pertama
return {
...state,
tasks: state.tasks.map(t =>
t.id === action.id ? { ...t, status: KOLOM[indexSekarang - 1] } : t
),
};
}
case 'hapus':
return {
...state,
tasks: state.tasks.filter(t => t.id !== action.id),
};
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
function KanbanBoard() {
const [state, dispatch] = useReducer(kanbanReducer, initialState);
const [inputTeks, setInputTeks] = useState('');
function handleTambah(e) {
e.preventDefault();
if (!inputTeks.trim()) return;
dispatch({ type: 'tambah', teks: inputTeks });
setInputTeks('');
}
return (
<div>
<h2>📌 Kanban Board</h2>
{/* Form tambah task */}
<form onSubmit={handleTambah} style={{ marginBottom: '20px' }}>
<input
value={inputTeks}
onChange={(e) => setInputTeks(e.target.value)}
placeholder="Task baru..."
style={{ padding: '8px', marginRight: '8px' }}
/>
<button type="submit">+ Tambah</button>
</form>
{/* Kolom-kolom */}
<div style={{ display: 'flex', gap: '15px' }}>
{KOLOM.map(kolom => {
const tasksInKolom = state.tasks.filter(t => t.status === kolom);
const indexKolom = KOLOM.indexOf(kolom);
return (
<div key={kolom} style={{
flex: 1,
background: '#f5f5f5',
padding: '15px',
borderRadius: '8px',
minHeight: '200px',
}}>
<h3>{LABEL_KOLOM[kolom]} ({tasksInKolom.length})</h3>
{tasksInKolom.map(task => (
<div key={task.id} style={{
background: 'white',
padding: '10px',
marginBottom: '8px',
borderRadius: '4px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}>
<p style={{ margin: '0 0 8px' }}>{task.teks}</p>
<div style={{ display: 'flex', gap: '4px' }}>
{indexKolom > 0 && (
<button onClick={() => dispatch({ type: 'pindah_mundur', id: task.id })}>
←
</button>
)}
{indexKolom < KOLOM.length - 1 && (
<button onClick={() => dispatch({ type: 'pindah_maju', id: task.id })}>
→
</button>
)}
<button
onClick={() => dispatch({ type: 'hapus', id: task.id })}
style={{ marginLeft: 'auto', color: 'red' }}
>
×
</button>
</div>
</div>
))}
</div>
);
})}
</div>
</div>
);
}Challenge 3: Stopwatch dengan Lap Times
Bikin stopwatch yang bisa record lap times. Fitur: Start, Stop, Lap, Reset. Pakai useReducer.
Hint: State: { waktu, berjalan, laps }. Actions: start, stop, lap, reset, tick.
Lihat Solusi
import { useReducer, useEffect, useRef } from 'react';
const initialState = {
waktu: 0, // dalam milidetik
berjalan: false,
laps: [], // array of { nomor, waktu, selisih }
};
function stopwatchReducer(state, action) {
switch (action.type) {
case 'start':
return { ...state, berjalan: true };
case 'stop':
return { ...state, berjalan: false };
case 'tick':
return { ...state, waktu: state.waktu + 10 };
case 'lap': {
const waktuLapSebelumnya = state.laps.length > 0
? state.laps[state.laps.length - 1].waktu
: 0;
return {
...state,
laps: [
...state.laps,
{
nomor: state.laps.length + 1,
waktu: state.waktu,
selisih: state.waktu - waktuLapSebelumnya,
}
],
};
}
case 'reset':
return initialState;
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
function formatWaktu(ms) {
const menit = Math.floor(ms / 60000);
const detik = Math.floor((ms % 60000) / 1000);
const milidetik = Math.floor((ms % 1000) / 10);
return `${menit.toString().padStart(2, '0')}:${detik.toString().padStart(2, '0')}.${milidetik.toString().padStart(2, '0')}`;
}
function StopwatchLap() {
const [state, dispatch] = useReducer(stopwatchReducer, initialState);
const intervalRef = useRef(null);
useEffect(() => {
if (state.berjalan) {
intervalRef.current = setInterval(() => {
dispatch({ type: 'tick' });
}, 10);
} else {
clearInterval(intervalRef.current);
}
return () => clearInterval(intervalRef.current);
}, [state.berjalan]);
return (
<div style={{ textAlign: 'center', padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
<h2>⏱️ Stopwatch</h2>
<p style={{ fontSize: '48px', fontFamily: 'monospace', margin: '20px 0' }}>
{formatWaktu(state.waktu)}
</p>
<div style={{ marginBottom: '20px' }}>
{!state.berjalan ? (
<button onClick={() => dispatch({ type: 'start' })}>
▶️ {state.waktu > 0 ? 'Lanjut' : 'Start'}
</button>
) : (
<button onClick={() => dispatch({ type: 'stop' })}>⏸️ Stop</button>
)}
{state.berjalan && (
<button onClick={() => dispatch({ type: 'lap' })}>🏁 Lap</button>
)}
{!state.berjalan && state.waktu > 0 && (
<button onClick={() => dispatch({ type: 'reset' })}>🔄 Reset</button>
)}
</div>
{/* Daftar Lap */}
{state.laps.length > 0 && (
<div style={{ textAlign: 'left' }}>
<h3>Lap Times:</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px' }}>#</th>
<th style={{ padding: '8px' }}>Selisih</th>
<th style={{ padding: '8px' }}>Total</th>
</tr>
</thead>
<tbody>
{state.laps.map(lap => (
<tr key={lap.nomor} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px' }}>Lap {lap.nomor}</td>
<td style={{ padding: '8px', fontFamily: 'monospace' }}>
{formatWaktu(lap.selisih)}
</td>
<td style={{ padding: '8px', fontFamily: 'monospace' }}>
{formatWaktu(lap.waktu)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}Kesimpulan
Reducer itu bukan pengganti useState. Keduanya punya tempat masing-masing. Tapi kalau kamu mulai merasa:
- "Handler-handler ini kok mirip-mirip ya..."
- "Logika update state-nya ribet dan tersebar di mana-mana..."
- "Susah banget nge-debug kenapa state berubah jadi gini..."
...itu saatnya pertimbangkan useReducer.
Keuntungan utama reducer:
- Semua logika update di SATU tempat (mudah dibaca dan di-debug)
- Handler jadi simpel (cuma dispatch action)
- Mudah di-test (reducer itu pure function, tinggal test input → output)
- Scalable (tambah fitur = tambah case di reducer)
- Bisa dikombinasikan dengan Context untuk state management global (Bab 7!)
Sudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.