Bab 5: Memindahkan Logika State ke Reducer

4 menit baca

Pendahuluan: 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?

💡Info

Di restoran yang terorganisir:

  1. Pelayan (event handler) terima permintaan dari pelanggan
  2. Pelayan nulis di bon pesanan (action object): "Meja 5 mau tambah Nasi Goreng"
  3. Bon dikirim ke kasir/dapur (reducer)
  4. 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):

jsx
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):

jsx
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

jsx
const [state, dispatch] = useReducer(reducerFunction, initialState);
BagianPenjelasanAnalogi
stateData saat iniPapan pesanan saat ini
dispatchFungsi untuk kirim actionPelayan yang kirim bon
reducerFunctionFungsi yang proses action jadi state baruKasir yang proses bon
initialStateNilai awal statePapan pesanan kosong di awal hari

Dispatch dan Action Objects

Apa Itu Action?

Action itu objek yang mendeskripsikan "apa yang terjadi". Minimal punya property type.

jsx
// 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',
});
💡Info

Bon pesanan di restoran punya format:

  • Jenis aksi (type): "Tambah", "Batalkan", "Ubah"
  • Detail (payload): "Nasi Goreng, meja 5, pedas level 3"
jsx
// 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,
});

Aturan Reducer

  1. Pure function: Hasil HANYA bergantung pada input (state + action), gak ada side effect
  2. Return state baru: JANGAN mutasi state lama, buat objek/array baru
  3. Handle semua action type: Pakai switch/case atau if/else
jsx
// 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

jsx
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

jsx
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
jsx
// 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
jsx
// useReducer cocok untuk ini:
const [formState, dispatch] = useReducer(formReducer, {
  values: { nama: '', email: '', alamat: '' },
  errors: {},
  status: 'idle',
  touched: {},
});

Tabel Perbandingan

AspekuseStateuseReducer
Kompleksitas stateRendahTinggi
Jumlah aksiSedikit (1-3)Banyak (4+)
Logika updateSimpelKompleks/kondisional
TestabilitySusah di-test terpisahMudah di-test (pure function)
DebuggingCek setiap handlerCek satu reducer
BoilerplateMinimalLebih banyak (action types, switch)
Learning curveRendahSedang

Contoh Perbandingan: Form Multi-Step

Versi useState (mulai berantakan):

jsx
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):

jsx
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

jsx
// 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.

jsx
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

jsx
// 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

jsx
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

jsx
// ❌ 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

jsx
// ❌ 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)

jsx
// ❌ 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

jsx
// ❌ 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

jsx
// ❌ 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

jsx
// ❌ 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

jsx
// ❌ 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
jsx
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
jsx
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
jsx
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:

  1. Semua logika update di SATU tempat (mudah dibaca dan di-debug)
  2. Handler jadi simpel (cuma dispatch action)
  3. Mudah di-test (reducer itu pure function, tinggal test input → output)
  4. Scalable (tambah fitur = tambah case di reducer)
  5. Bisa dikombinasikan dengan Context untuk state management global (Bab 7!)

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.