Bab 3: Berbagi State Antar Komponen

5 menit baca

Pendahuluan: Ketika Komponen Perlu "Ngobrol"

Bayangin kamu di kantor. Ada dua divisi: Divisi Marketing dan Divisi Produksi. Marketing dapet info dari klien bahwa pesanan berubah. Produksi perlu tau info ini supaya bisa adjust. Tapi mereka gak bisa langsung ngobrol... harus lewat manajer yang membawahi keduanya.

Di React, situasi yang sama terjadi. Kadang dua komponen yang sejajar (sibling) perlu berbagi informasi. Mereka gak bisa langsung "ngobrol" satu sama lain. Solusinya? Angkat state ke parent yang membawahi keduanya. Ini namanya "lifting state up".


Masalah: Dua Komponen Butuh Data yang Sama

Skenario: Panel Accordion

Bayangin kamu bikin FAQ page. Ada beberapa panel yang bisa dibuka/tutup. Tapi aturannya: cuma satu panel yang boleh terbuka pada satu waktu. Kalau panel A dibuka, panel B harus otomatis tertutup.

Kalau setiap panel punya state sendiri-sendiri:

jsx
// ❌ Setiap panel punya state sendiri - gak bisa koordinasi!
function Panel({ judul, isi }) {
  const [terbuka, setTerbuka] = useState(false);

  return (
    <div>
      <button onClick={() => setTerbuka(!terbuka)}>
        {judul}
      </button>
      {terbuka && <p>{isi}</p>}
    </div>
  );
}

function HalamanFAQ() {
  return (
    <div>
      {/* Kedua panel bisa terbuka bersamaan! Gak sesuai requirement */}
      <Panel judul="Apa itu React?" isi="React adalah library JavaScript..." />
      <Panel judul="Apa itu JSX?" isi="JSX adalah syntax extension..." />
    </div>
  );
}

Masalahnya: Panel A gak tau kalau Panel B lagi terbuka. Mereka hidup di dunia masing-masing. Gak ada "manajer" yang koordinasi.


Solusi: Lifting State Up (Angkat State ke Atas)

💡Info

Di rumah, ada satu remote TV. Siapa yang pegang remote, dia yang kontrol channel. Remote-nya disimpan di meja ruang keluarga (parent), bukan di kamar masing-masing anak (child).

Kalau Andi mau ganti channel, dia ambil remote dari meja. Kalau Budi mau ganti, dia juga ambil dari meja yang sama. Satu sumber kontrol, dipakai bersama.

Langkah-Langkah Lifting State Up

Langkah 1: Hapus state dari komponen child

jsx
// SEBELUM: Panel punya state sendiri
function Panel({ judul, isi }) {
  const [terbuka, setTerbuka] = useState(false); // ← Hapus ini
  // ...
}

Langkah 2: Kirim data dari parent lewat props

jsx
// SESUDAH: Panel terima state dari parent
function Panel({ judul, isi, terbuka, onToggle }) {
  // Gak punya state sendiri, terima dari props
  return (
    <div>
      <button onClick={onToggle}>
        {judul} {terbuka ? '▼' : '▶'}
      </button>
      {terbuka && <p>{isi}</p>}
    </div>
  );
}

Langkah 3: Simpan state di parent (common ancestor)

jsx
function HalamanFAQ() {
  // State disimpan di PARENT
  const [indexAktif, setIndexAktif] = useState(null);

  return (
    <div>
      <h2>❓ FAQ</h2>
      <Panel
        judul="Apa itu React?"
        isi="React adalah library JavaScript untuk membangun UI."
        terbuka={indexAktif === 0}
        onToggle={() => setIndexAktif(indexAktif === 0 ? null : 0)}
      />
      <Panel
        judul="Apa itu JSX?"
        isi="JSX adalah syntax extension yang memungkinkan kamu menulis HTML di JavaScript."
        terbuka={indexAktif === 1}
        onToggle={() => setIndexAktif(indexAktif === 1 ? null : 1)}
      />
      <Panel
        judul="Apa itu State?"
        isi="State adalah data yang bisa berubah dan mempengaruhi tampilan komponen."
        terbuka={indexAktif === 2}
        onToggle={() => setIndexAktif(indexAktif === 2 ? null : 2)}
      />
    </div>
  );
}

Coba sendiri: Edit kode di bawah dan lihat hasilnya langsung!

Sekarang, parent yang kontrol panel mana yang terbuka. Kalau panel 0 dibuka, otomatis panel lain tertutup karena indexAktif cuma bisa punya SATU nilai.


Controlled vs Uncontrolled Components

💡Info

Uncontrolled (Mandiri): Karyawan yang kerja sendiri tanpa arahan detail dari bos. Dia punya "state internal" sendiri. Bos cuma tau hasilnya.

Controlled (Diawasi): Karyawan yang setiap langkahnya diarahkan bos. Dia gak punya keputusan sendiri, semua instruksi dari atas.

Dalam Kode

Uncontrolled Component:

jsx
// Komponen ini punya state SENDIRI
// Parent gak bisa kontrol apakah panel terbuka atau tidak
function PanelMandiri({ judul, isi }) {
  const [terbuka, setTerbuka] = useState(false); // State internal

  return (
    <div>
      <button onClick={() => setTerbuka(!terbuka)}>
        {judul}
      </button>
      {terbuka && <p>{isi}</p>}
    </div>
  );
}

// Parent cuma "pasang" aja, gak bisa kontrol
function App() {
  return <PanelMandiri judul="FAQ" isi="..." />;
  // Gak bisa bilang: "Panel, buka dong!" dari sini
}

Controlled Component:

jsx
// Komponen ini DIKONTROL oleh parent lewat props
// Gak punya state sendiri untuk buka/tutup
function PanelTerkontrol({ judul, isi, terbuka, onToggle }) {
  return (
    <div>
      <button onClick={onToggle}>
        {judul}
      </button>
      {terbuka && <p>{isi}</p>}
    </div>
  );
}

// Parent punya KONTROL PENUH
function App() {
  const [buka, setBuka] = useState(false);

  return (
    <PanelTerkontrol
      judul="FAQ"
      isi="..."
      terbuka={buka}
      onToggle={() => setBuka(!buka)}
    />
  );
  // Parent bisa kapan aja bilang: setBuka(true) → panel terbuka!
}

Kapan Pakai Mana?

SituasiPilihanAlasan
Komponen berdiri sendiri, gak perlu koordinasiUncontrolledLebih simpel, self-contained
Perlu koordinasi dengan komponen lainControlledParent bisa sinkronisasi
Komponen reusable (library)Kasih opsi keduanyaFleksibel untuk berbagai kebutuhan

Single Source of Truth (Satu Sumber Kebenaran)

💡Info

Di kantor yang baik, info penting ditempel di SATU papan pengumuman. Bukan di meja masing-masing karyawan. Kenapa? Karena kalau info berubah, cukup update di satu tempat. Semua orang lihat info yang sama.

Kalau setiap karyawan punya copy sendiri, bisa jadi: Andi punya info lama, Budi punya info baru. Kacau.

Dalam React

Untuk setiap "data" yang perlu di-share, tentukan SATU komponen yang jadi "pemilik" state tersebut. Komponen lain yang butuh data itu, terima lewat props.

jsx
// ✅ SATU sumber kebenaran: App punya data suhu
function App() {
  const [suhuCelsius, setSuhuCelsius] = useState(25);

  return (
    <div>
      <h1>🌡️ Konverter Suhu</h1>
      <InputCelsius
        suhu={suhuCelsius}
        onChange={setSuhuCelsius}
      />
      <InputFahrenheit
        suhu={suhuCelsius}
        onChange={setSuhuCelsius}
      />
      <TampilanSuhu suhu={suhuCelsius} />
    </div>
  );
}

function InputCelsius({ suhu, onChange }) {
  return (
    <label>
      Celsius:
      <input
        type="number"
        value={suhu}
        onChange={(e) => onChange(Number(e.target.value))}
      />
    </label>
  );
}

function InputFahrenheit({ suhu, onChange }) {
  // Konversi untuk tampilan
  const fahrenheit = (suhu * 9/5) + 32;

  function handleChange(e) {
    // Konversi balik ke Celsius sebelum kirim ke parent
    const nilaiF = Number(e.target.value);
    const nilaiC = (nilaiF - 32) * 5/9;
    onChange(nilaiC);
  }

  return (
    <label>
      Fahrenheit:
      <input
        type="number"
        value={Math.round(fahrenheit * 100) / 100}
        onChange={handleChange}
      />
    </label>
  );
}

function TampilanSuhu({ suhu }) {
  let deskripsi;
  if (suhu < 0) deskripsi = '🥶 Beku!';
  else if (suhu < 20) deskripsi = '🧥 Dingin';
  else if (suhu < 30) deskripsi = '😊 Nyaman';
  else deskripsi = '🥵 Panas!';

  return <p>{deskripsi} ({suhu}°C)</p>;
}

Perhatiin: suhuCelsius cuma ada di SATU tempat (App). InputCelsius dan InputFahrenheit keduanya "terhubung" ke sumber yang sama. Kalau satu berubah, yang lain ikut update.


Kapan Harus Lifting State Up?

Tanda-Tanda Kamu Perlu Lift State

  1. Dua komponen sibling perlu data yang sama

    • Contoh: Filter dan daftar produk perlu tau keyword pencarian
  2. Aksi di satu komponen harus mempengaruhi komponen lain

    • Contoh: Klik "Tambah ke Keranjang" di ProductCard harus update angka di CartIcon
  3. Parent perlu tau state child

    • Contoh: Form wizard dimana parent perlu tau apakah semua step sudah valid

Tanda-Tanda Kamu TIDAK Perlu Lift State

  1. State cuma dipakai oleh satu komponen

    • Contoh: Apakah dropdown terbuka atau tidak (cuma dropdown itu yang peduli)
  2. State bersifat "UI-only" dan lokal

    • Contoh: Posisi scroll, hover state, animasi internal

Contoh Lengkap: Aplikasi Pencarian Produk

jsx
import { useState } from 'react';

const PRODUK = [
  { id: 1, nama: 'Laptop ASUS', kategori: 'Elektronik', harga: 8500000, stok: true },
  { id: 2, nama: 'Mouse Logitech', kategori: 'Elektronik', harga: 350000, stok: true },
  { id: 3, nama: 'Meja Kerja', kategori: 'Furniture', harga: 1200000, stok: false },
  { id: 4, nama: 'Kursi Gaming', kategori: 'Furniture', harga: 2500000, stok: true },
  { id: 5, nama: 'Headset Sony', kategori: 'Elektronik', harga: 750000, stok: true },
  { id: 6, nama: 'Rak Buku', kategori: 'Furniture', harga: 450000, stok: false },
];

// PARENT: Pemilik state yang di-share
function AplikasiToko() {
  // State yang di-share antara SearchBar dan ProductList
  const [pencarian, setPencarian] = useState('');
  const [cariStokAja, setCariStokAja] = useState(false);
  const [kategoriAktif, setKategoriAktif] = useState('Semua');

  // Derived: produk yang sudah difilter
  const produkTerfilter = PRODUK.filter(p => {
    const cocokNama = p.nama.toLowerCase().includes(pencarian.toLowerCase());
    const cocokStok = cariStokAja ? p.stok : true;
    const cocokKategori = kategoriAktif === 'Semua' || p.kategori === kategoriAktif;
    return cocokNama && cocokStok && cocokKategori;
  });

  return (
    <div>
      <h1>🏪 Toko Online</h1>

      {/* Child 1: Kontrol pencarian */}
      <BarPencarian
        pencarian={pencarian}
        onPencarianBerubah={setPencarian}
        cariStokAja={cariStokAja}
        onStokBerubah={setCariStokAja}
        kategoriAktif={kategoriAktif}
        onKategoriBerubah={setKategoriAktif}
      />

      {/* Child 2: Tampilkan hasil */}
      <DaftarProduk produk={produkTerfilter} />

      {/* Child 3: Info jumlah */}
      <InfoHasil total={PRODUK.length} terfilter={produkTerfilter.length} />
    </div>
  );
}

// CHILD 1: Bar pencarian (controlled component)
function BarPencarian({
  pencarian,
  onPencarianBerubah,
  cariStokAja,
  onStokBerubah,
  kategoriAktif,
  onKategoriBerubah,
}) {
  const kategoriList = ['Semua', 'Elektronik', 'Furniture'];

  return (
    <div style={{ marginBottom: '20px', padding: '15px', background: '#f5f5f5' }}>
      <input
        type="text"
        placeholder="🔍 Cari produk..."
        value={pencarian}
        onChange={(e) => onPencarianBerubah(e.target.value)}
        style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
      />

      <div>
        <label>
          <input
            type="checkbox"
            checked={cariStokAja}
            onChange={(e) => onStokBerubah(e.target.checked)}
          />
          Hanya yang tersedia
        </label>
      </div>

      <div style={{ marginTop: '10px' }}>
        {kategoriList.map(kat => (
          <button
            key={kat}
            onClick={() => onKategoriBerubah(kat)}
            style={{
              marginRight: '5px',
              padding: '5px 10px',
              background: kategoriAktif === kat ? '#007bff' : '#ddd',
              color: kategoriAktif === kat ? 'white' : 'black',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            {kat}
          </button>
        ))}
      </div>
    </div>
  );
}

// CHILD 2: Daftar produk
function DaftarProduk({ produk }) {
  if (produk.length === 0) {
    return <p>😕 Gak ada produk yang cocok dengan pencarian kamu.</p>;
  }

  return (
    <div>
      {produk.map(p => (
        <div
          key={p.id}
          style={{
            padding: '10px',
            marginBottom: '8px',
            border: '1px solid #ddd',
            borderRadius: '4px',
            opacity: p.stok ? 1 : 0.5,
          }}
        >
          <strong>{p.nama}</strong>
          <span style={{ marginLeft: '10px', color: '#666' }}>{p.kategori}</span>
          <span style={{ float: 'right' }}>
            Rp {p.harga.toLocaleString()}
            {!p.stok && <span style={{ color: 'red' }}> (Habis)</span>}
          </span>
        </div>
      ))}
    </div>
  );
}

// CHILD 3: Info hasil
function InfoHasil({ total, terfilter }) {
  return (
    <p style={{ color: '#666', marginTop: '10px' }}>
      Menampilkan {terfilter} dari {total} produk
    </p>
  );
}

Kenapa Struktur Ini Bekerja?

AplikasiToko (PEMILIK STATE) ├── pencarian, cariStokAja, kategoriAktif ├── produkTerfilter (derived) │ ├── BarPencarian (CONTROLLED - terima state + handler via props) ├── DaftarProduk (DISPLAY - terima data via props) └── InfoHasil (DISPLAY - terima data via props)
  • BarPencarian mengubah state di parent lewat callback (onPencarianBerubah, dll)
  • DaftarProduk dan InfoHasil cuma terima data yang sudah difilter
  • Semua komponen "sinkron" karena sumber datanya SATU

Pattern: Passing State + Handler sebagai Props

Ini pattern yang paling sering kamu pakai saat lifting state up:

jsx
// Parent: punya state + handler
function Parent() {
  const [nilai, setNilai] = useState('');

  function handleBerubah(nilaiBaru) {
    // Bisa tambah logika di sini (validasi, transform, dll)
    setNilai(nilaiBaru);
  }

  return (
    <Child
      nilai={nilai}           // Kirim STATE ke bawah
      onBerubah={handleBerubah} // Kirim HANDLER ke bawah
    />
  );
}

// Child: terima state + handler dari parent
function Child({ nilai, onBerubah }) {
  return (
    <input
      value={nilai}
      onChange={(e) => onBerubah(e.target.value)}
    />
  );
}

Pattern ini kayak "remote control":

  • State = channel TV saat ini (data yang ditampilkan)
  • Handler = tombol remote (cara mengubah data)

Parent kasih keduanya ke child. Child bisa "lihat" data (lewat state) dan "ubah" data (lewat handler).


Contoh Lanjutan: Accordion yang Lebih Kompleks

jsx
import { useState } from 'react';

function Accordion() {
  const [indexAktif, setIndexAktif] = useState(null);

  const daftarFAQ = [
    {
      pertanyaan: 'Bagaimana cara mendaftar?',
      jawaban: 'Klik tombol "Daftar" di pojok kanan atas, isi form dengan data yang valid, lalu klik "Submit". Kamu akan menerima email konfirmasi dalam 5 menit.',
    },
    {
      pertanyaan: 'Berapa biaya berlangganan?',
      jawaban: 'Kami punya 3 paket: Basic (gratis), Pro (Rp 99.000/bulan), dan Enterprise (hubungi sales). Semua paket bisa dicoba gratis 14 hari.',
    },
    {
      pertanyaan: 'Bagaimana cara membatalkan langganan?',
      jawaban: 'Masuk ke Settings → Billing → Cancel Subscription. Langganan kamu akan tetap aktif sampai akhir periode billing saat ini.',
    },
    {
      pertanyaan: 'Apakah data saya aman?',
      jawaban: 'Ya! Kami menggunakan enkripsi end-to-end dan server kami tersertifikasi ISO 27001. Data kamu gak akan dijual ke pihak ketiga.',
    },
  ];

  function handleToggle(index) {
    // Kalau yang diklik sudah aktif, tutup (set null)
    // Kalau beda, buka yang baru (otomatis tutup yang lama)
    setIndexAktif(indexAktif === index ? null : index);
  }

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      <h2>❓ Frequently Asked Questions</h2>
      {daftarFAQ.map((faq, index) => (
        <PanelAccordion
          key={index}
          pertanyaan={faq.pertanyaan}
          jawaban={faq.jawaban}
          terbuka={indexAktif === index}
          onToggle={() => handleToggle(index)}
        />
      ))}
    </div>
  );
}

function PanelAccordion({ pertanyaan, jawaban, terbuka, onToggle }) {
  return (
    <div style={{
      border: '1px solid #ddd',
      borderRadius: '8px',
      marginBottom: '8px',
      overflow: 'hidden',
    }}>
      <button
        onClick={onToggle}
        style={{
          width: '100%',
          padding: '15px',
          textAlign: 'left',
          background: terbuka ? '#e3f2fd' : 'white',
          border: 'none',
          cursor: 'pointer',
          fontSize: '16px',
          fontWeight: 'bold',
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
        }}
      >
        {pertanyaan}
        <span style={{
          transform: terbuka ? 'rotate(180deg)' : 'rotate(0deg)',
          transition: 'transform 0.2s',
        }}>

        </span>
      </button>

      {terbuka && (
        <div style={{
          padding: '15px',
          background: '#fafafa',
          borderTop: '1px solid #eee',
        }}>
          {jawaban}
        </div>
      )}
    </div>
  );
}

Contoh: Tab Navigation

jsx
import { useState } from 'react';

function TabNavigation() {
  const [tabAktif, setTabAktif] = useState('profil');

  return (
    <div>
      <h2>👤 Akun Saya</h2>

      {/* Tab buttons */}
      <TabBar tabAktif={tabAktif} onTabBerubah={setTabAktif} />

      {/* Tab content */}
      <TabContent tabAktif={tabAktif} />
    </div>
  );
}

function TabBar({ tabAktif, onTabBerubah }) {
  const tabs = [
    { id: 'profil', label: '👤 Profil' },
    { id: 'pesanan', label: '📦 Pesanan' },
    { id: 'pengaturan', label: '⚙️ Pengaturan' },
  ];

  return (
    <div style={{ display: 'flex', borderBottom: '2px solid #ddd' }}>
      {tabs.map(tab => (
        <button
          key={tab.id}
          onClick={() => onTabBerubah(tab.id)}
          style={{
            padding: '10px 20px',
            border: 'none',
            background: 'none',
            cursor: 'pointer',
            borderBottom: tabAktif === tab.id ? '3px solid #007bff' : 'none',
            fontWeight: tabAktif === tab.id ? 'bold' : 'normal',
            color: tabAktif === tab.id ? '#007bff' : '#666',
          }}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

function TabContent({ tabAktif }) {
  switch (tabAktif) {
    case 'profil':
      return (
        <div style={{ padding: '20px' }}>
          <h3>Profil Saya</h3>
          <p>Nama: Budi Santoso</p>
          <p>Email: budi@email.com</p>
          <p>Bergabung: Januari 2024</p>
        </div>
      );
    case 'pesanan':
      return (
        <div style={{ padding: '20px' }}>
          <h3>Riwayat Pesanan</h3>
          <p>📦 Pesanan #001 - Laptop ASUS - Dikirim</p>
          <p>📦 Pesanan #002 - Mouse Logitech - Selesai</p>
        </div>
      );
    case 'pengaturan':
      return (
        <div style={{ padding: '20px' }}>
          <h3>Pengaturan</h3>
          <p>🔔 Notifikasi: Aktif</p>
          <p>🌙 Dark Mode: Nonaktif</p>
        </div>
      );
    default:
      return null;
  }
}

⚠️ Jebakan

Jebakan 1: Lift State Terlalu Tinggi

jsx
// ❌ State warna tema di-lift sampai ke App padahal cuma dipakai di Settings
function App() {
  const [tema, setTema] = useState('light'); // Ini cuma dipakai di halaman Settings!
  const [user, setUser] = useState(null);
  const [notif, setNotif] = useState([]);
  // ... 20 state lainnya

  return (
    <Router>
      <Header />
      <Settings tema={tema} onTemaBerubah={setTema} /> {/* Cuma di sini */}
      <Footer />
    </Router>
  );
}

// ✅ Simpan state sedekat mungkin dengan yang membutuhkan
function Settings() {
  const [tema, setTema] = useState('light'); // Cukup di sini!
  return <TemaSelector tema={tema} onChange={setTema} />;
}

Aturan: Lift state ke parent HANYA kalau memang ada >1 child yang butuh data itu.

Jebakan 2: Prop Drilling yang Berlebihan

jsx
// ❌ Props diteruskan melewati banyak level tanpa dipakai
function App() {
  const [user, setUser] = useState({ nama: 'Budi' });
  return <Layout user={user} />;
}

function Layout({ user }) {
  // Layout gak pakai user, cuma nerusin
  return <Sidebar user={user} />;
}

function Sidebar({ user }) {
  // Sidebar juga gak pakai, cuma nerusin
  return <UserInfo user={user} />;
}

function UserInfo({ user }) {
  // Baru di sini dipake!
  return <p>Halo, {user.nama}!</p>;
}

Kalau props harus melewati 3+ level tanpa dipakai di tengah, pertimbangkan Context (akan dibahas di Bab 6).

Jebakan 3: Lupa Kirim Handler ke Child

jsx
// ❌ Child terima state tapi gak bisa mengubahnya
function Parent() {
  const [nilai, setNilai] = useState('');
  return <Child nilai={nilai} />; // Mana handler-nya?
}

function Child({ nilai }) {
  // Gak bisa update! Gak ada cara kirim perubahan ke parent
  return <input value={nilai} onChange={???} />;
}

// ✅ Selalu kirim handler bersama state
function Parent() {
  const [nilai, setNilai] = useState('');
  return <Child nilai={nilai} onBerubah={setNilai} />;
}

function Child({ nilai, onBerubah }) {
  return <input value={nilai} onChange={(e) => onBerubah(e.target.value)} />;
}

Jebakan 4: Mengubah Props Langsung

jsx
// ❌ JANGAN ubah props! Props itu read-only
function Child({ data }) {
  data.nama = 'Baru'; // SALAH! Ini mutasi props
  return <p>{data.nama}</p>;
}

// ✅ Kalau mau ubah, panggil handler dari parent
function Child({ data, onUpdate }) {
  function handleGantiNama() {
    onUpdate({ ...data, nama: 'Baru' }); // Kirim data baru ke parent
  }
  return <button onClick={handleGantiNama}>Ganti Nama</button>;
}

🏋️ Challenge

Challenge 1: Sinkronisasi Dua Input

Bikin dua input text yang selalu sinkron (apa yang diketik di satu, muncul di yang lain). Tapi input kedua menampilkan teks dalam UPPERCASE.

Hint: Lift state ke parent. Input kedua tampilkan teks.toUpperCase() tapi saat user ngetik di input kedua, convert balik ke lowercase sebelum update state.

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

function SinkronInput() {
  // State di parent (single source of truth)
  const [teks, setTeks] = useState('');

  return (
    <div>
      <h2>🔄 Input Sinkron</h2>

      <InputNormal
        label="Normal"
        nilai={teks}
        onBerubah={setTeks}
      />

      <InputUppercase
        label="UPPERCASE"
        nilai={teks}
        onBerubah={setTeks}
      />

      <p>Nilai state: "{teks}"</p>
    </div>
  );
}

function InputNormal({ label, nilai, onBerubah }) {
  return (
    <div style={{ marginBottom: '10px' }}>
      <label>{label}: </label>
      <input
        value={nilai}
        onChange={(e) => onBerubah(e.target.value)}
      />
    </div>
  );
}

function InputUppercase({ label, nilai, onBerubah }) {
  return (
    <div style={{ marginBottom: '10px' }}>
      <label>{label}: </label>
      <input
        value={nilai.toUpperCase()}
        onChange={(e) => onBerubah(e.target.value.toLowerCase())}
      />
    </div>
  );
}

Challenge 2: Daftar dengan Filter dan Counter

Bikin aplikasi daftar tugas dimana:

  • Komponen FilterBar punya tombol filter (Semua/Aktif/Selesai)
  • Komponen TodoList menampilkan tugas yang sudah difilter
  • Komponen StatusBar menampilkan "X dari Y tugas selesai"

Ketiga komponen harus sinkron (filter berubah → list berubah → counter berubah).

Hint: Semua state (todos + filter) ada di parent. Ketiga child adalah controlled components.

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

function AplikasiTugas() {
  // State di parent
  const [tugas, setTugas] = useState([
    { id: 1, teks: 'Belajar React', selesai: true },
    { id: 2, teks: 'Bikin portfolio', selesai: false },
    { id: 3, teks: 'Lamar kerja', selesai: false },
    { id: 4, teks: 'Baca dokumentasi', selesai: true },
  ]);
  const [filter, setFilter] = useState('semua');

  // Derived values
  const tugasTerfilter = tugas.filter(t => {
    if (filter === 'aktif') return !t.selesai;
    if (filter === 'selesai') return t.selesai;
    return true;
  });
  const jumlahSelesai = tugas.filter(t => t.selesai).length;

  function toggleTugas(id) {
    setTugas(tugas.map(t =>
      t.id === id ? { ...t, selesai: !t.selesai } : t
    ));
  }

  return (
    <div>
      <h2>📋 Daftar Tugas</h2>
      <FilterBar filter={filter} onFilterBerubah={setFilter} />
      <TodoList tugas={tugasTerfilter} onToggle={toggleTugas} />
      <StatusBar selesai={jumlahSelesai} total={tugas.length} />
    </div>
  );
}

function FilterBar({ filter, onFilterBerubah }) {
  const opsi = ['semua', 'aktif', 'selesai'];

  return (
    <div style={{ marginBottom: '15px' }}>
      {opsi.map(o => (
        <button
          key={o}
          onClick={() => onFilterBerubah(o)}
          style={{
            marginRight: '5px',
            padding: '5px 15px',
            background: filter === o ? '#4CAF50' : '#eee',
            color: filter === o ? 'white' : 'black',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          {o.charAt(0).toUpperCase() + o.slice(1)}
        </button>
      ))}
    </div>
  );
}

function TodoList({ tugas, onToggle }) {
  if (tugas.length === 0) {
    return <p style={{ color: '#999' }}>Tidak ada tugas.</p>;
  }

  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {tugas.map(t => (
        <li
          key={t.id}
          style={{ padding: '8px 0', borderBottom: '1px solid #eee' }}
        >
          <label style={{ cursor: 'pointer' }}>
            <input
              type="checkbox"
              checked={t.selesai}
              onChange={() => onToggle(t.id)}
            />
            <span style={{
              marginLeft: '8px',
              textDecoration: t.selesai ? 'line-through' : 'none',
              color: t.selesai ? '#999' : 'black',
            }}>
              {t.teks}
            </span>
          </label>
        </li>
      ))}
    </ul>
  );
}

function StatusBar({ selesai, total }) {
  const persen = total > 0 ? Math.round((selesai / total) * 100) : 0;

  return (
    <div style={{ marginTop: '15px', padding: '10px', background: '#f5f5f5', borderRadius: '4px' }}>
      <p>{selesai} dari {total} tugas selesai ({persen}%)</p>
      <div style={{ background: '#ddd', borderRadius: '4px', overflow: 'hidden' }}>
        <div style={{
          width: `${persen}%`,
          height: '8px',
          background: '#4CAF50',
          transition: 'width 0.3s',
        }} />
      </div>
    </div>
  );
}

Challenge 3: Color Picker Sinkron

Bikin color picker dimana:

  • Ada input type="color" (native color picker)
  • Ada 3 input number untuk R, G, B (0-255)
  • Ada preview warna
  • Semua harus sinkron: ubah color picker → RGB update. Ubah RGB → color picker update.

Hint: Simpan warna sebagai {r, g, b} di parent. Konversi ke hex untuk color picker, konversi dari hex saat color picker berubah.

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

// Helper: RGB ke Hex
function rgbKeHex(r, g, b) {
  return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
}

// Helper: Hex ke RGB
function hexKeRgb(hex) {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result ? {
    r: parseInt(result[1], 16),
    g: parseInt(result[2], 16),
    b: parseInt(result[3], 16),
  } : { r: 0, g: 0, b: 0 };
}

function ColorPicker() {
  // Single source of truth: warna dalam format RGB
  const [warna, setWarna] = useState({ r: 66, g: 133, b: 244 });

  // Derived: hex string
  const hex = rgbKeHex(warna.r, warna.g, warna.b);

  function handleHexBerubah(hexBaru) {
    setWarna(hexKeRgb(hexBaru));
  }

  function handleRgbBerubah(channel, nilai) {
    // Clamp antara 0-255
    const nilaiValid = Math.max(0, Math.min(255, Number(nilai)));
    setWarna({ ...warna, [channel]: nilaiValid });
  }

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto' }}>
      <h2>🎨 Color Picker</h2>

      {/* Preview */}
      <div style={{
        width: '100%',
        height: '100px',
        backgroundColor: hex,
        borderRadius: '8px',
        marginBottom: '20px',
        border: '2px solid #ddd',
      }} />

      {/* Native color picker */}
      <div style={{ marginBottom: '15px' }}>
        <label>Pilih Warna: </label>
        <input
          type="color"
          value={hex}
          onChange={(e) => handleHexBerubah(e.target.value)}
        />
        <span style={{ marginLeft: '10px' }}>{hex}</span>
      </div>

      {/* RGB inputs */}
      <InputRGB label="R (Red)" nilai={warna.r} warna="red"
        onBerubah={(v) => handleRgbBerubah('r', v)} />
      <InputRGB label="G (Green)" nilai={warna.g} warna="green"
        onBerubah={(v) => handleRgbBerubah('g', v)} />
      <InputRGB label="B (Blue)" nilai={warna.b} warna="blue"
        onBerubah={(v) => handleRgbBerubah('b', v)} />
    </div>
  );
}

function InputRGB({ label, nilai, warna, onBerubah }) {
  return (
    <div style={{ marginBottom: '10px' }}>
      <label style={{ display: 'inline-block', width: '80px', color: warna }}>
        {label}:
      </label>
      <input
        type="range"
        min="0"
        max="255"
        value={nilai}
        onChange={(e) => onBerubah(e.target.value)}
        style={{ width: '150px', marginRight: '10px' }}
      />
      <input
        type="number"
        min="0"
        max="255"
        value={nilai}
        onChange={(e) => onBerubah(e.target.value)}
        style={{ width: '60px' }}
      />
    </div>
  );
}

Kesimpulan

Berbagi state antar komponen itu kayak sistem komunikasi di organisasi. Yang penting:

  1. Identifikasi komponen mana yang butuh data yang sama
  2. Temukan parent terdekat yang membawahi semua komponen tersebut
  3. Angkat state ke parent itu
  4. Kirim state + handler ke child lewat props
  5. Child jadi controlled component yang dikontrol parent

Ingat: lift state HANYA sejauh yang diperlukan. Jangan angkat ke App kalau cukup di level yang lebih rendah. Dan kalau prop drilling jadi terlalu dalam (3+ level), ada solusi yang lebih elegan yang bakal kita bahas di Bab 6 (Context).

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.