Bab 2: Memilih Struktur State
⏱ 5 menit bacaPendahuluan: 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
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
// ❌ 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);
}// ✅ 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
// 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
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
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
// ❌ 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
}// ✅ 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)
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
// ❌ 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!
}// ✅ 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
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
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
// ❌ 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!// ✅ 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
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)
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
// ❌ 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
// ✅ 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:
// ❌ 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...// ✅ 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.
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
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
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?
- Gak ada duplikasi:
todoSedangEditdihitung daritodos+idSedangEdit - Gak ada kontradiksi: Satu todo gak bisa
selesai: trueDANselesai: false - Gak ada redundansi:
jumlahAktifdihitung, bukan disimpan - Flat: Setiap todo adalah objek sederhana, bukan nested
- Grouped: Data todo (teks, selesai) dikelompokkan dalam satu objek
Tabel Ringkasan: 5 Prinsip
| # | Prinsip | Masalah yang Dicegah | Contoh |
|---|---|---|---|
| 1 | Kelompokkan yang berhubungan | Update parsial, state gak sinkron | {x, y} bukan x + y terpisah |
| 2 | Hindari kontradiksi | Impossible state | Satu status bukan banyak boolean |
| 3 | Hindari redundansi | Data gak sinkron | Hitung total dari items, jangan simpan |
| 4 | Hindari duplikasi | Update di satu tempat, lupa di tempat lain | Simpan id, bukan copy seluruh objek |
| 5 | Hindari nesting dalam | Update yang ribet dan error-prone | Flatten atau normalisasi |
⚠️ Jebakan
Jebakan 1: Menyimpan Props di State
// ❌ 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
// ❌ 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, otomatisJebakan 3: Duplikasi Objek di State
// ❌ 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
// ❌ 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
// ❌ 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!
// 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
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!
// 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
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}) dandaftarProduk - Total harga, total item, dan diskon (>100rb dapat 10%) semuanya DIHITUNG, bukan disimpan
Hint: Semua angka yang ditampilkan harusnya derived values.
Lihat Solusi
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:
- Kelompokkan state yang selalu berubah bersamaan
- Hindari kontradiksi dengan menggabungkan state yang saling eksklusif
- Hindari redundansi dengan menghitung nilai turunan
- Hindari duplikasi dengan menyimpan ID, bukan copy objek
- 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.