Bab 2: Memilih Struktur State

5 menit baca

Pendahuluan: Arsitektur Itu Penting

Bayangin kamu punya lemari baju. Kalau kamu asal lempar baju ke dalamnya, besok pagi kamu bakal bingung nyari kaos kaki. Tapi kalau kamu atur: laci atas buat kaos, laci tengah buat celana, gantungan buat kemeja... hidup jadi lebih gampang.

State di React juga gitu. Cara kamu menyusun state menentukan seberapa mudah atau sulitnya kamu nge-update dan nge-debug komponen. Struktur state yang buruk = bug yang susah dilacak. Struktur yang bagus = kode yang hampir "menulis dirinya sendiri".

Di bab ini, kamu bakal belajar 5 prinsip emas untuk menyusun state yang bersih dan efisien.


Prinsip 1: Kelompokkan State yang Berhubungan

💡Info

Kalau kamu isi form alamat pengiriman, data yang berhubungan itu: jalan, RT/RW, kelurahan, kecamatan, kota, provinsi, kode pos. Semua ini adalah SATU kesatuan "alamat". Gak masuk akal kalau kamu simpan di 7 amplop terpisah.

Dalam Kode

jsx
// ❌ Terpisah-pisah padahal saling berhubungan
const [x, setX] = useState(0);
const [y, setY] = useState(0);

// Setiap kali mouse gerak, harus update DUA state
function handleMouseMove(e) {
  setX(e.clientX);
  setY(e.clientY);
}
jsx
// ✅ Dikelompokkan jadi satu objek
const [posisi, setPosisi] = useState({ x: 0, y: 0 });

// Satu update, satu objek
function handleMouseMove(e) {
  setPosisi({ x: e.clientX, y: e.clientY });
}

Kapan Harus Dikelompokkan?

Tanya dirimu: "Apakah dua state ini SELALU berubah bersamaan?"

  • Kalau ya → gabungin jadi satu objek
  • Kalau kadang-kadang aja → biarkan terpisah
jsx
// Contoh: form profil
// Nama dan email BISA berubah terpisah (user edit satu-satu)
const [nama, setNama] = useState('');
const [email, setEmail] = useState('');

// Tapi koordinat SELALU berubah bareng (mouse gerak = x DAN y berubah)
const [posisi, setPosisi] = useState({ x: 0, y: 0 });

Contoh Nyata: Data Pengiriman

jsx
function FormPengiriman() {
  // ✅ Dikelompokkan karena ini satu kesatuan "alamat"
  const [alamat, setAlamat] = useState({
    jalan: '',
    kota: '',
    provinsi: '',
    kodePos: '',
  });

  // Update satu field tanpa menghilangkan yang lain
  function updateAlamat(field, nilai) {
    setAlamat({
      ...alamat,       // Salin semua field yang ada
      [field]: nilai,  // Timpa field yang berubah
    });
  }

  return (
    <form>
      <input
        placeholder="Jalan"
        value={alamat.jalan}
        onChange={(e) => updateAlamat('jalan', e.target.value)}
      />
      <input
        placeholder="Kota"
        value={alamat.kota}
        onChange={(e) => updateAlamat('kota', e.target.value)}
      />
      <input
        placeholder="Provinsi"
        value={alamat.provinsi}
        onChange={(e) => updateAlamat('provinsi', e.target.value)}
      />
      <input
        placeholder="Kode Pos"
        value={alamat.kodePos}
        onChange={(e) => updateAlamat('kodePos', e.target.value)}
      />
    </form>
  );
}

Prinsip 2: Hindari Kontradiksi dalam State

💡Info

Di aplikasi ojol, pesanan kamu gak mungkin statusnya "Sedang Diantar" DAN "Dibatalkan" bersamaan. Itu kontradiksi. Kalau sistem memungkinkan itu terjadi, ada yang salah.

Dalam Kode

jsx
// ❌ BAHAYA: Bisa kontradiksi!
const [sedangKirim, setSedangKirim] = useState(false);
const [sudahKirim, setSudahKirim] = useState(false);

async function handleKirim() {
  setSedangKirim(true);
  await kirimData();
  setSudahKirim(true);
  setSedangKirim(false); // Gimana kalau baris ini lupa?
  // Sekarang: sedangKirim=true DAN sudahKirim=true → KONTRADIKSI
}
jsx
// ✅ AMAN: Satu state, gak mungkin kontradiksi
const [status, setStatus] = useState('idle');
// 'idle' | 'mengirim' | 'terkirim'

async function handleKirim() {
  setStatus('mengirim');
  await kirimData();
  setStatus('terkirim'); // Otomatis gak 'mengirim' lagi
}

Aturan Emas

Kalau dua state gak boleh bernilai true bersamaan, itu tandanya mereka harusnya SATU state dengan beberapa kemungkinan nilai.


Prinsip 3: Hindari State yang Redundan (Bisa Dihitung)

💡Info

Di KTP kamu ada tanggal lahir. Umur kamu bisa dihitung dari tanggal lahir. Jadi gak perlu simpan umur secara terpisah, karena umur berubah setiap tahun tapi tanggal lahir tetap.

Kalau kamu simpan keduanya, suatu saat bisa gak sinkron (umur bilang 25, tapi tanggal lahir nunjukin harusnya 26).

Dalam Kode

jsx
// ❌ REDUNDAN: totalItem bisa dihitung dari items
const [items, setItems] = useState(['Nasi Goreng', 'Es Teh', 'Kerupuk']);
const [totalItem, setTotalItem] = useState(3);

// Setiap tambah/hapus item, harus update DUA state
// Kalau lupa update totalItem... BUG!
function tambahItem(item) {
  setItems([...items, item]);
  setTotalItem(totalItem + 1); // Harus ingat update ini juga!
}
jsx
// ✅ DIHITUNG: totalItem adalah derived value (nilai turunan)
const [items, setItems] = useState(['Nasi Goreng', 'Es Teh', 'Kerupuk']);

// Ini BUKAN state, ini PERHITUNGAN dari state yang ada
const totalItem = items.length;

// Cukup update items aja, totalItem otomatis benar
function tambahItem(item) {
  setItems([...items, item]);
  // totalItem otomatis jadi items.length yang baru
}

Contoh Lain: Harga Total Keranjang

jsx
function Keranjang() {
  const [items, setItems] = useState([
    { nama: 'Nasi Goreng', harga: 25000, jumlah: 2 },
    { nama: 'Es Teh', harga: 5000, jumlah: 3 },
    { nama: 'Kerupuk', harga: 3000, jumlah: 1 },
  ]);

  // ❌ JANGAN simpan ini sebagai state
  // const [totalHarga, setTotalHarga] = useState(68000);

  // ✅ HITUNG dari data yang ada
  const totalHarga = items.reduce(
    (total, item) => total + item.harga * item.jumlah,
    0
  );

  const totalBarang = items.reduce(
    (total, item) => total + item.jumlah,
    0
  );

  return (
    <div>
      <h2>🛒 Keranjang Belanja</h2>
      {items.map((item, index) => (
        <div key={index}>
          <span>{item.nama} x{item.jumlah}</span>
          <span> = Rp {(item.harga * item.jumlah).toLocaleString()}</span>
        </div>
      ))}
      <hr />
      <p>Total barang: {totalBarang}</p>
      <p><strong>Total: Rp {totalHarga.toLocaleString()}</strong></p>
    </div>
  );
}

Aturan Praktis

Sebelum bikin state baru, tanya: "Bisa gak nilai ini dihitung dari state yang sudah ada?"

  • Bisa dihitung → jangan bikin state, bikin variabel biasa
  • Gak bisa dihitung → bikin state

Prinsip 4: Hindari Duplikasi dalam State

💡Info

Bayangin kamu punya daftar kontak, dan ada fitur "Kontak Favorit". Cara yang salah: salin seluruh data kontak ke daftar favorit. Cara yang benar: simpan cuma ID-nya, terus ambil datanya dari daftar utama.

Kenapa? Kalau si Budi ganti nomor HP, kamu cuma perlu update di SATU tempat (daftar utama). Kalau kamu duplikasi, harus update di dua tempat.

Dalam Kode

jsx
// ❌ DUPLIKASI: data menu disimpan di dua tempat
const [daftarMenu, setDaftarMenu] = useState([
  { id: 1, nama: 'Nasi Goreng', harga: 25000 },
  { id: 2, nama: 'Mie Ayam', harga: 20000 },
  { id: 3, nama: 'Soto', harga: 18000 },
]);

// Ini DUPLIKASI dari item di daftarMenu!
const [menuTerpilih, setMenuTerpilih] = useState(
  { id: 1, nama: 'Nasi Goreng', harga: 25000 }
);

// Masalah: kalau harga Nasi Goreng berubah di daftarMenu,
// menuTerpilih masih nyimpan harga lama!
jsx
// ✅ TANPA DUPLIKASI: simpan cuma ID-nya
const [daftarMenu, setDaftarMenu] = useState([
  { id: 1, nama: 'Nasi Goreng', harga: 25000 },
  { id: 2, nama: 'Mie Ayam', harga: 20000 },
  { id: 3, nama: 'Soto', harga: 18000 },
]);

// Simpan HANYA id, bukan seluruh objek
const [idMenuTerpilih, setIdMenuTerpilih] = useState(1);

// HITUNG objek lengkapnya dari sumber utama
const menuTerpilih = daftarMenu.find(menu => menu.id === idMenuTerpilih);

Contoh Lengkap: Daftar Teman

jsx
function DaftarTeman() {
  const [teman, setTeman] = useState([
    { id: 1, nama: 'Andi', kota: 'Jakarta' },
    { id: 2, nama: 'Budi', kota: 'Bandung' },
    { id: 3, nama: 'Citra', kota: 'Surabaya' },
  ]);

  // Simpan ID, bukan objek
  const [idTerpilih, setIdTerpilih] = useState(null);

  // Hitung dari data utama
  const temanTerpilih = teman.find(t => t.id === idTerpilih);

  function updateKota(id, kotaBaru) {
    setTeman(teman.map(t =>
      t.id === id ? { ...t, kota: kotaBaru } : t
    ));
    // temanTerpilih otomatis ter-update karena dihitung dari teman!
  }

  return (
    <div>
      <h2>👥 Daftar Teman</h2>
      <ul>
        {teman.map(t => (
          <li
            key={t.id}
            onClick={() => setIdTerpilih(t.id)}
            style={{
              cursor: 'pointer',
              fontWeight: t.id === idTerpilih ? 'bold' : 'normal',
            }}
          >
            {t.nama} ({t.kota})
          </li>
        ))}
      </ul>

      {temanTerpilih && (
        <div>
          <h3>Edit: {temanTerpilih.nama}</h3>
          <input
            value={temanTerpilih.kota}
            onChange={(e) => updateKota(temanTerpilih.id, e.target.value)}
          />
        </div>
      )}
    </div>
  );
}

Prinsip 5: Hindari State yang Terlalu Dalam (Nested)

💡Info

Bayangin kamu mau kirim surat ke teman. Alamatnya:

Indonesia → Jawa Barat → Bandung → Kecamatan Coblong → Kelurahan Dago → Jl. Ir. H. Juanda No. 100

Itu 6 level kedalaman. Kalau kamu mau update nama jalan aja, kamu harus "buka" semua level di atasnya dulu. Ribet!

Dalam Kode: Masalah Nested State

jsx
// ❌ TERLALU DALAM: update jadi nightmare
const [lokasi, setLokasi] = useState({
  negara: 'Indonesia',
  provinsi: {
    nama: 'Jawa Barat',
    kota: {
      nama: 'Bandung',
      kecamatan: {
        nama: 'Coblong',
        kelurahan: {
          nama: 'Dago',
          jalan: 'Jl. Ir. H. Juanda No. 100'
        }
      }
    }
  }
});

// Mau update jalan aja? Lihat betapa ruwetnya:
function updateJalan(jalanBaru) {
  setLokasi({
    ...lokasi,
    provinsi: {
      ...lokasi.provinsi,
      kota: {
        ...lokasi.provinsi.kota,
        kecamatan: {
          ...lokasi.provinsi.kota.kecamatan,
          kelurahan: {
            ...lokasi.provinsi.kota.kecamatan.kelurahan,
            jalan: jalanBaru  // Akhirnya sampai juga... 😩
          }
        }
      }
    }
  });
}

Solusi: Flatten (Ratakan) Strukturnya

jsx
// ✅ FLAT: mudah di-update
const [lokasi, setLokasi] = useState({
  negara: 'Indonesia',
  provinsi: 'Jawa Barat',
  kota: 'Bandung',
  kecamatan: 'Coblong',
  kelurahan: 'Dago',
  jalan: 'Jl. Ir. H. Juanda No. 100',
});

// Update jalan? Gampang!
function updateJalan(jalanBaru) {
  setLokasi({ ...lokasi, jalan: jalanBaru });
}

Contoh Nyata: Normalisasi Data Kategori

Bayangin kamu punya data kategori menu restoran yang nested:

jsx
// ❌ NESTED: susah di-update
const [menu, setMenu] = useState({
  kategori: [
    {
      id: 'makanan',
      nama: 'Makanan',
      items: [
        { id: 'm1', nama: 'Nasi Goreng', harga: 25000 },
        { id: 'm2', nama: 'Mie Ayam', harga: 20000 },
      ]
    },
    {
      id: 'minuman',
      nama: 'Minuman',
      items: [
        { id: 'd1', nama: 'Es Teh', harga: 5000 },
        { id: 'd2', nama: 'Jus Alpukat', harga: 15000 },
      ]
    }
  ]
});

// Mau update harga Nasi Goreng? Harus cari kategorinya dulu, terus cari itemnya...
jsx
// ✅ NORMALIZED (dinormalisasi): data dipisah berdasarkan "tabel"
const [kategori, setKategori] = useState({
  makanan: { id: 'makanan', nama: 'Makanan', itemIds: ['m1', 'm2'] },
  minuman: { id: 'minuman', nama: 'Minuman', itemIds: ['d1', 'd2'] },
});

const [items, setItems] = useState({
  m1: { id: 'm1', nama: 'Nasi Goreng', harga: 25000, kategoriId: 'makanan' },
  m2: { id: 'm2', nama: 'Mie Ayam', harga: 20000, kategoriId: 'makanan' },
  d1: { id: 'd1', nama: 'Es Teh', harga: 5000, kategoriId: 'minuman' },
  d2: { id: 'd2', nama: 'Jus Alpukat', harga: 15000, kategoriId: 'minuman' },
});

// Update harga Nasi Goreng? GAMPANG!
function updateHarga(itemId, hargaBaru) {
  setItems({
    ...items,
    [itemId]: { ...items[itemId], harga: hargaBaru }
  });
}

// Ambil semua item dalam kategori? Juga gampang!
function getItemsDalamKategori(kategoriId) {
  return kategori[kategoriId].itemIds.map(id => items[id]);
}

Ini mirip cara database relasional bekerja. Data disimpan di "tabel" terpisah dan dihubungkan lewat ID.


Menghitung Derived Values (Nilai Turunan)

Ini ekstensi dari Prinsip 3, tapi penting banget untuk dipahami lebih dalam.

💡Info

Di rapor, ada nilai per mata pelajaran (Matematika: 85, Bahasa: 90, IPA: 80). Rata-rata dan ranking DIHITUNG dari nilai-nilai itu. Guru gak nyimpan rata-rata secara terpisah, karena kalau ada koreksi nilai, rata-rata harus ikut berubah.

Pattern: Derived State

jsx
function DaftarBelanja() {
  // Ini STATE (sumber kebenaran)
  const [items, setItems] = useState([
    { nama: 'Beras 5kg', harga: 65000, beli: true },
    { nama: 'Minyak 2L', harga: 35000, beli: true },
    { nama: 'Gula 1kg', harga: 18000, beli: false },
    { nama: 'Telur 1kg', harga: 28000, beli: true },
    { nama: 'Kecap', harga: 12000, beli: false },
  ]);

  const [filter, setFilter] = useState('semua'); // 'semua' | 'beli' | 'belum'

  // Ini DERIVED VALUES (dihitung, bukan disimpan)
  const itemsTerfilter = items.filter(item => {
    if (filter === 'beli') return item.beli;
    if (filter === 'belum') return !item.beli;
    return true; // 'semua'
  });

  const totalBelanja = items
    .filter(item => item.beli)
    .reduce((total, item) => total + item.harga, 0);

  const jumlahDibeli = items.filter(item => item.beli).length;
  const jumlahBelum = items.filter(item => !item.beli).length;

  function toggleBeli(index) {
    setItems(items.map((item, i) =>
      i === index ? { ...item, beli: !item.beli } : item
    ));
    // Semua derived values otomatis ter-update saat re-render!
  }

  return (
    <div>
      <h2>🛍️ Daftar Belanja</h2>

      {/* Filter */}
      <div>
        <button onClick={() => setFilter('semua')}>
          Semua ({items.length})
        </button>
        <button onClick={() => setFilter('beli')}>
          Dibeli ({jumlahDibeli})
        </button>
        <button onClick={() => setFilter('belum')}>
          Belum ({jumlahBelum})
        </button>
      </div>

      {/* Daftar */}
      <ul>
        {itemsTerfilter.map((item, index) => (
          <li key={index}>
            <label>
              <input
                type="checkbox"
                checked={item.beli}
                onChange={() => toggleBeli(items.indexOf(item))}
              />
              {item.nama} - Rp {item.harga.toLocaleString()}
            </label>
          </li>
        ))}
      </ul>

      {/* Total */}
      <p><strong>Total belanja: Rp {totalBelanja.toLocaleString()}</strong></p>
    </div>
  );
}

Perhatiin: itemsTerfilter, totalBelanja, jumlahDibeli, jumlahBelum semuanya BUKAN state. Mereka dihitung ulang setiap kali komponen re-render. Dan itu bagus! Karena mereka selalu sinkron dengan data aslinya.


Contoh Komprehensif: Aplikasi Todo dengan Struktur State yang Baik

jsx
import { useState } from 'react';

// ID generator sederhana
let nextId = 4;

function TodoApp() {
  // STATE: Sumber kebenaran tunggal
  const [todos, setTodos] = useState([
    { id: 1, teks: 'Belajar React', selesai: true },
    { id: 2, teks: 'Bikin project portfolio', selesai: false },
    { id: 3, teks: 'Lamar kerja', selesai: false },
  ]);
  const [inputTeks, setInputTeks] = useState('');
  const [filter, setFilter] = useState('semua'); // 'semua' | 'aktif' | 'selesai'
  const [idSedangEdit, setIdSedangEdit] = useState(null); // ID todo yang lagi di-edit

  // DERIVED VALUES: Dihitung dari state
  const todosTerfilter = todos.filter(todo => {
    if (filter === 'aktif') return !todo.selesai;
    if (filter === 'selesai') return todo.selesai;
    return true;
  });

  const jumlahAktif = todos.filter(t => !t.selesai).length;
  const jumlahSelesai = todos.filter(t => t.selesai).length;
  const todoSedangEdit = todos.find(t => t.id === idSedangEdit); // Derived, bukan duplikasi!

  // ACTIONS
  function tambahTodo() {
    if (inputTeks.trim() === '') return;
    setTodos([
      ...todos,
      { id: nextId++, teks: inputTeks.trim(), selesai: false }
    ]);
    setInputTeks('');
  }

  function toggleSelesai(id) {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, selesai: !todo.selesai } : todo
    ));
  }

  function hapusTodo(id) {
    setTodos(todos.filter(todo => todo.id !== id));
    // Kalau yang dihapus lagi di-edit, reset edit mode
    if (idSedangEdit === id) setIdSedangEdit(null);
  }

  function simpanEdit(id, teksBaru) {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, teks: teksBaru } : todo
    ));
    setIdSedangEdit(null);
  }

  return (
    <div>
      <h2>📋 Todo App</h2>

      {/* Input todo baru */}
      <div>
        <input
          value={inputTeks}
          onChange={(e) => setInputTeks(e.target.value)}
          placeholder="Apa yang mau dikerjain?"
          onKeyDown={(e) => e.key === 'Enter' && tambahTodo()}
        />
        <button onClick={tambahTodo}>Tambah</button>
      </div>

      {/* Filter */}
      <div>
        <button onClick={() => setFilter('semua')}>Semua ({todos.length})</button>
        <button onClick={() => setFilter('aktif')}>Aktif ({jumlahAktif})</button>
        <button onClick={() => setFilter('selesai')}>Selesai ({jumlahSelesai})</button>
      </div>

      {/* Daftar todo */}
      <ul>
        {todosTerfilter.map(todo => (
          <li key={todo.id}>
            {idSedangEdit === todo.id ? (
              // Mode edit
              <EditTodo
                teks={todo.teks}
                onSimpan={(teksBaru) => simpanEdit(todo.id, teksBaru)}
                onBatal={() => setIdSedangEdit(null)}
              />
            ) : (
              // Mode tampil
              <div>
                <input
                  type="checkbox"
                  checked={todo.selesai}
                  onChange={() => toggleSelesai(todo.id)}
                />
                <span style={{
                  textDecoration: todo.selesai ? 'line-through' : 'none'
                }}>
                  {todo.teks}
                </span>
                <button onClick={() => setIdSedangEdit(todo.id)}>✏️</button>
                <button onClick={() => hapusTodo(todo.id)}>🗑️</button>
              </div>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

// Komponen terpisah untuk edit
function EditTodo({ teks, onSimpan, onBatal }) {
  const [draft, setDraft] = useState(teks);

  return (
    <div>
      <input
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') onSimpan(draft);
          if (e.key === 'Escape') onBatal();
        }}
      />
      <button onClick={() => onSimpan(draft)}>💾</button>
      <button onClick={onBatal}>❌</button>
    </div>
  );
}

Kenapa Struktur Ini Bagus?

  1. Gak ada duplikasi: todoSedangEdit dihitung dari todos + idSedangEdit
  2. Gak ada kontradiksi: Satu todo gak bisa selesai: true DAN selesai: false
  3. Gak ada redundansi: jumlahAktif dihitung, bukan disimpan
  4. Flat: Setiap todo adalah objek sederhana, bukan nested
  5. Grouped: Data todo (teks, selesai) dikelompokkan dalam satu objek

Tabel Ringkasan: 5 Prinsip

#PrinsipMasalah yang DicegahContoh
1Kelompokkan yang berhubunganUpdate parsial, state gak sinkron{x, y} bukan x + y terpisah
2Hindari kontradiksiImpossible stateSatu status bukan banyak boolean
3Hindari redundansiData gak sinkronHitung total dari items, jangan simpan
4Hindari duplikasiUpdate di satu tempat, lupa di tempat lainSimpan id, bukan copy seluruh objek
5Hindari nesting dalamUpdate yang ribet dan error-proneFlatten atau normalisasi

⚠️ Jebakan

Jebakan 1: Menyimpan Props di State

jsx
// ❌ JANGAN copy props ke state (kecuali sengaja mau "snapshot")
function Profil({ namaDariProps }) {
  const [nama, setNama] = useState(namaDariProps);
  // Kalau parent kirim nama baru, state ini GAK ikut update!
}

// ✅ Pakai props langsung
function Profil({ nama }) {
  // Langsung pakai `nama` dari props
  return <h1>{nama}</h1>;
}

// ✅ Kalau memang butuh state lokal yang DIMULAI dari props (misal: form edit)
function EditProfil({ namaAwal }) {
  const [nama, setNama] = useState(namaAwal);
  // Ini OK karena kita SENGAJA mau bikin "draft" yang bisa di-edit
  // Tapi kasih nama yang jelas: "namaAwal" bukan "nama"
}

Jebakan 2: Menyimpan Nilai yang Bisa Dihitung

jsx
// ❌ Redundan
const [items, setItems] = useState([...]);
const [totalItems, setTotalItems] = useState(items.length);
// Harus selalu ingat update totalItems setiap items berubah!

// ✅ Hitung aja
const [items, setItems] = useState([...]);
const totalItems = items.length; // Selalu benar, otomatis

Jebakan 3: Duplikasi Objek di State

jsx
// ❌ Objek yang sama disimpan di dua tempat
const [produk, setProduk] = useState([...]);
const [keranjang, setKeranjang] = useState([
  { id: 1, nama: 'Baju', harga: 150000 }, // COPY dari produk!
]);

// Kalau harga berubah di produk, keranjang masih harga lama

// ✅ Simpan referensi (ID + jumlah)
const [produk, setProduk] = useState([...]);
const [keranjang, setKeranjang] = useState([
  { produkId: 1, jumlah: 2 }, // Cuma ID dan jumlah
]);

// Hitung detail dari sumber utama
const detailKeranjang = keranjang.map(item => ({
  ...produk.find(p => p.id === item.produkId),
  jumlah: item.jumlah,
}));

Jebakan 4: State Terlalu Dalam Tanpa Alasan

jsx
// ❌ Nested tanpa alasan
const [user, setUser] = useState({
  profil: {
    personal: {
      nama: {
        depan: 'Budi',
        belakang: 'Santoso'
      }
    }
  }
});

// ✅ Flat dan jelas
const [namaDepan, setNamaDepan] = useState('Budi');
const [namaBelakang, setNamaBelakang] = useState('Santoso');
// ATAU kalau memang satu kesatuan:
const [nama, setNama] = useState({ depan: 'Budi', belakang: 'Santoso' });

Jebakan 5: Array of Objects yang Gak Dinormalisasi

jsx
// ❌ Kalau satu user muncul di banyak tempat, datanya bisa beda
const [pesan, setPesan] = useState([
  { id: 1, teks: 'Halo', pengirim: { id: 'u1', nama: 'Andi', foto: 'andi.jpg' } },
  { id: 2, teks: 'Hey!', pengirim: { id: 'u1', nama: 'Andi', foto: 'andi.jpg' } },
  // Kalau Andi ganti foto, harus update di SEMUA pesan!
]);

// ✅ Normalisasi: pisahkan users dan pesan
const [users, setUsers] = useState({
  u1: { id: 'u1', nama: 'Andi', foto: 'andi.jpg' },
});
const [pesan, setPesan] = useState([
  { id: 1, teks: 'Halo', pengirimId: 'u1' },
  { id: 2, teks: 'Hey!', pengirimId: 'u1' },
]);
// Update foto Andi sekali, semua pesan otomatis pakai foto baru

🏋️ Challenge

Challenge 1: Refactor State yang Buruk

Kode di bawah punya struktur state yang buruk. Refactor supaya mengikuti 5 prinsip!

jsx
// State yang BURUK - refactor ini!
const [namaDepan, setNamaDepan] = useState('');
const [namaBelakang, setNamaBelakang] = useState('');
const [namaLengkap, setNamaLengkap] = useState(''); // redundan!
const [sedangKirim, setSedangKirim] = useState(false);
const [sudahKirim, setSudahKirim] = useState(false); // kontradiksi!
const [error, setError] = useState(false); // kontradiksi!

Hint: namaLengkap bisa dihitung. sedangKirim, sudahKirim, error harusnya satu state.

Lihat Solusi
jsx
import { useState } from 'react';

function FormRefactored() {
  // State yang BERSIH
  const [namaDepan, setNamaDepan] = useState('');
  const [namaBelakang, setNamaBelakang] = useState('');
  const [status, setStatus] = useState('idle'); // 'idle' | 'mengirim' | 'sukses' | 'error'

  // DERIVED VALUE: dihitung, bukan disimpan
  const namaLengkap = `${namaDepan} ${namaBelakang}`.trim();

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('mengirim');

    try {
      await new Promise(resolve => setTimeout(resolve, 1000));
      setStatus('sukses');
    } catch {
      setStatus('error');
    }
  }

  if (status === 'sukses') {
    return <p>Halo, {namaLengkap}! Pendaftaran berhasil.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={namaDepan}
        onChange={(e) => setNamaDepan(e.target.value)}
        placeholder="Nama depan"
        disabled={status === 'mengirim'}
      />
      <input
        value={namaBelakang}
        onChange={(e) => setNamaBelakang(e.target.value)}
        placeholder="Nama belakang"
        disabled={status === 'mengirim'}
      />
      <p>Preview: {namaLengkap}</p>
      {status === 'error' && <p style={{ color: 'red' }}>Gagal! Coba lagi.</p>}
      <button disabled={status === 'mengirim'}>
        {status === 'mengirim' ? 'Mengirim...' : 'Daftar'}
      </button>
    </form>
  );
}

Challenge 2: Normalisasi Data Nested

Data di bawah terlalu nested. Normalisasi supaya mudah di-update!

jsx
// Data NESTED yang susah di-update
const data = {
  kelas: [
    {
      id: 'k1',
      nama: 'Kelas React',
      siswa: [
        { id: 's1', nama: 'Andi', nilai: 85 },
        { id: 's2', nama: 'Budi', nilai: 90 },
      ]
    },
    {
      id: 'k2',
      nama: 'Kelas Node.js',
      siswa: [
        { id: 's3', nama: 'Citra', nilai: 88 },
        { id: 's1', nama: 'Andi', nilai: 92 }, // Andi ikut 2 kelas!
      ]
    }
  ]
};

Hint: Pisahkan jadi 3 "tabel": kelas, siswa, dan enrollment (hubungan kelas-siswa).

Lihat Solusi
jsx
import { useState } from 'react';

function AplikasiKelas() {
  // "Tabel" Kelas
  const [kelas, setKelas] = useState({
    k1: { id: 'k1', nama: 'Kelas React' },
    k2: { id: 'k2', nama: 'Kelas Node.js' },
  });

  // "Tabel" Siswa (satu sumber kebenaran untuk setiap siswa)
  const [siswa, setSiswa] = useState({
    s1: { id: 's1', nama: 'Andi' },
    s2: { id: 's2', nama: 'Budi' },
    s3: { id: 's3', nama: 'Citra' },
  });

  // "Tabel" Enrollment (hubungan + nilai per kelas)
  const [enrollment, setEnrollment] = useState([
    { kelasId: 'k1', siswaId: 's1', nilai: 85 },
    { kelasId: 'k1', siswaId: 's2', nilai: 90 },
    { kelasId: 'k2', siswaId: 's3', nilai: 88 },
    { kelasId: 'k2', siswaId: 's1', nilai: 92 },
  ]);

  // DERIVED: Siswa dalam kelas tertentu
  function getSiswaDalamKelas(kelasId) {
    return enrollment
      .filter(e => e.kelasId === kelasId)
      .map(e => ({
        ...siswa[e.siswaId],
        nilai: e.nilai,
      }));
  }

  // UPDATE: Ganti nama siswa (cukup di satu tempat!)
  function updateNamaSiswa(siswaId, namaBaru) {
    setSiswa({
      ...siswa,
      [siswaId]: { ...siswa[siswaId], nama: namaBaru },
    });
    // Otomatis ter-update di SEMUA kelas yang diikuti siswa ini
  }

  // UPDATE: Ganti nilai siswa di kelas tertentu
  function updateNilai(kelasId, siswaId, nilaiBaru) {
    setEnrollment(enrollment.map(e =>
      e.kelasId === kelasId && e.siswaId === siswaId
        ? { ...e, nilai: nilaiBaru }
        : e
    ));
  }

  return (
    <div>
      <h2>🏫 Manajemen Kelas</h2>
      {Object.values(kelas).map(k => (
        <div key={k.id}>
          <h3>{k.nama}</h3>
          <ul>
            {getSiswaDalamKelas(k.id).map(s => (
              <li key={s.id}>
                {s.nama} - Nilai: {s.nilai}
                <button onClick={() => updateNilai(k.id, s.id, s.nilai + 5)}>
                  +5
                </button>
              </li>
            ))}
          </ul>
        </div>
      ))}

      <hr />
      <h3>Edit Nama Siswa</h3>
      {Object.values(siswa).map(s => (
        <div key={s.id}>
          <input
            value={s.nama}
            onChange={(e) => updateNamaSiswa(s.id, e.target.value)}
          />
        </div>
      ))}
    </div>
  );
}

Challenge 3: Keranjang Belanja dengan Derived Values

Bikin komponen keranjang belanja dimana:

  • State cuma menyimpan items (array of {id, jumlah}) dan daftarProduk
  • Total harga, total item, dan diskon (>100rb dapat 10%) semuanya DIHITUNG, bukan disimpan

Hint: Semua angka yang ditampilkan harusnya derived values.

Lihat Solusi
jsx
import { useState } from 'react';

const DAFTAR_PRODUK = [
  { id: 'p1', nama: 'Kaos Polos', harga: 75000 },
  { id: 'p2', nama: 'Celana Jeans', harga: 250000 },
  { id: 'p3', nama: 'Topi Baseball', harga: 45000 },
  { id: 'p4', nama: 'Sepatu Sneakers', harga: 350000 },
];

function KeranjangBelanja() {
  // SATU-SATUNYA STATE: apa yang ada di keranjang
  const [keranjang, setKeranjang] = useState([
    // { produkId: 'p1', jumlah: 2 }
  ]);

  // === SEMUA INI DERIVED VALUES (dihitung, bukan disimpan) ===

  // Detail keranjang (gabungan data produk + jumlah)
  const detailKeranjang = keranjang.map(item => {
    const produk = DAFTAR_PRODUK.find(p => p.id === item.produkId);
    return {
      ...produk,
      jumlah: item.jumlah,
      subtotal: produk.harga * item.jumlah,
    };
  });

  // Total item di keranjang
  const totalItem = keranjang.reduce((sum, item) => sum + item.jumlah, 0);

  // Total harga sebelum diskon
  const totalSebelumDiskon = detailKeranjang.reduce(
    (sum, item) => sum + item.subtotal, 0
  );

  // Diskon 10% kalau belanja > 100rb
  const persenDiskon = totalSebelumDiskon > 100000 ? 10 : 0;
  const nilaiDiskon = totalSebelumDiskon * (persenDiskon / 100);

  // Total akhir
  const totalAkhir = totalSebelumDiskon - nilaiDiskon;

  // Cek apakah produk sudah di keranjang
  const sudahDiKeranjang = (produkId) =>
    keranjang.some(item => item.produkId === produkId);

  // === ACTIONS ===

  function tambahKeKeranjang(produkId) {
    if (sudahDiKeranjang(produkId)) {
      // Tambah jumlah
      setKeranjang(keranjang.map(item =>
        item.produkId === produkId
          ? { ...item, jumlah: item.jumlah + 1 }
          : item
      ));
    } else {
      // Tambah item baru
      setKeranjang([...keranjang, { produkId, jumlah: 1 }]);
    }
  }

  function kurangiDariKeranjang(produkId) {
    const item = keranjang.find(i => i.produkId === produkId);
    if (item.jumlah === 1) {
      // Hapus dari keranjang
      setKeranjang(keranjang.filter(i => i.produkId !== produkId));
    } else {
      // Kurangi jumlah
      setKeranjang(keranjang.map(i =>
        i.produkId === produkId
          ? { ...i, jumlah: i.jumlah - 1 }
          : i
      ));
    }
  }

  return (
    <div>
      <h2>🛒 Toko Online</h2>

      {/* Daftar Produk */}
      <h3>Produk Tersedia</h3>
      {DAFTAR_PRODUK.map(produk => (
        <div key={produk.id} style={{ marginBottom: '10px' }}>
          <span>{produk.nama} - Rp {produk.harga.toLocaleString()}</span>
          <button onClick={() => tambahKeKeranjang(produk.id)}>
            + Keranjang
          </button>
        </div>
      ))}

      {/* Keranjang */}
      <h3>Keranjang ({totalItem} item)</h3>
      {detailKeranjang.length === 0 ? (
        <p>Keranjang kosong</p>
      ) : (
        <>
          {detailKeranjang.map(item => (
            <div key={item.id}>
              <span>{item.nama} x{item.jumlah} = Rp {item.subtotal.toLocaleString()}</span>
              <button onClick={() => kurangiDariKeranjang(item.id)}>-</button>
              <button onClick={() => tambahKeKeranjang(item.id)}>+</button>
            </div>
          ))}
          <hr />
          <p>Subtotal: Rp {totalSebelumDiskon.toLocaleString()}</p>
          {persenDiskon > 0 && (
            <p style={{ color: 'green' }}>
              Diskon {persenDiskon}%: -Rp {nilaiDiskon.toLocaleString()}
            </p>
          )}
          <p><strong>Total: Rp {totalAkhir.toLocaleString()}</strong></p>
        </>
      )}
    </div>
  );
}

Kesimpulan

Memilih struktur state yang tepat itu kayak mendesain database. Keputusan di awal menentukan seberapa mudah atau sulitnya kamu bekerja ke depannya.

5 Prinsip yang harus selalu diingat:

  1. Kelompokkan state yang selalu berubah bersamaan
  2. Hindari kontradiksi dengan menggabungkan state yang saling eksklusif
  3. Hindari redundansi dengan menghitung nilai turunan
  4. Hindari duplikasi dengan menyimpan ID, bukan copy objek
  5. Hindari nesting dengan memflatkan atau menormalisasi data

Kalau kamu ragu, tanya: "Apakah state ini bisa dihitung dari state lain?" dan "Apakah ada kemungkinan state ini jadi kontradiktif?" Dua pertanyaan itu aja udah cukup buat menghindari 80% masalah struktur state.

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.