kodingup.camp

Beranda/React Mastery: Dasar, Rendering & Hooks

Arsitektur State Berbasis Reducer

Modul 12 dari 124 menit bacaReducer-Based State Architecture

Isi modul

    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);
    
    1. Reducer: fungsi murni (state, action) => stateBaru. Ditulis di luar komponen.
    2. Action: objek yang menjelaskan apa yang terjadi. Konvensinya punya properti type (string) plus data tambahan, misalnya { type: 'tambah', produk }.
    3. 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

    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

    1. Mutasi state di reducer. state.items.push(x); return state; membuat React tidak melihat perubahan. Kembalikan objek/array baru.
    2. Efek samping di reducer. Fetch, Math.random(), atau crypto.randomUUID() di dalam reducer membuatnya tidak murni (dan di StrictMode reducer dipanggil dua kali). Buat id/nilai acak di handler, lalu kirim lewat action.
    3. Lupa return di salah satu case. Reducer mengembalikan undefined dan state hilang. Pastikan setiap cabang mengembalikan state, dan default melempar error.
    4. Lupa menyalin field lain. return { items: [...] } menghapus kupon. Gunakan { ...state, items: [...] }.
    5. Memakai useReducer untuk state yang sangat sederhana. Satu boolean toggle tidak butuh reducer; useState lebih jelas.

    Latihan

    1. Mudah, Ubah counter (+1, −1, reset) menjadi versi useReducer dengan action 'naik', 'turun', dan 'reset'.
    2. Sedang, Buat reducer untuk form pendaftaran dengan state { nama, email, setuju, terkirim } dan action 'field_diubah' (membawa field dan value), 'dikirim', dan 'direset'. Tombol kirim hanya aktif jika semua field terisi dan setuju true.
    3. 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

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.