Bagian 1 menghasilkan satu object muatan yang berisi data utuh dari ketiga sumber. Pertanyaannya berpindah ke tempat: object itu disimpan di mana, dan bagaimana ia berubah? Tanpa aturan yang disepakati, jawaban yang mudah muncul adalah variabel di mana-mana — statistik disimpan oleh fungsi kartu, transaksi disimpan oleh fungsi tabel, status disimpan oleh variabel lain lagi — dan setiap fungsi mengubah isinya sesuka hatinya. Satu perubahan bentuk data merambat ke banyak tempat, dan tidak ada satu pun yang bisa diuji terpisah dari DOM.

Bagian ini menata ulang aturan mainnya dengan satu keputusan: state dashboard hidup di satu tempat dan berubah lewat pintu yang tersedia. Bentuk state dibuat oleh satu fungsi buatState, dan hanya ada tiga fungsi yang boleh menghasilkan state baru: setState, patchState, dan terapkanData. Ketiganya murni — menerima state lama, mengembalikan state baru, tanpa menyentuh DOM dan tanpa mengubah state lama. Aliran satu arah ini adalah pola yang kita pelajari pada chapter State Management Fundamentals, dan disiplin “kembalikan objek baru dengan spread” sudah kita latih pada section Functional Programming; di proyek nyata, pola ini membuat setiap transisi state bisa direkonstruksi hanya dengan membaca urutan pemanggilannya.

Satu Bentuk State untuk Seluruh Halaman #

Semua yang ditampilkan dashboard — kartu, tabel, daftar aktivitas, status — adalah fungsi dari satu object state. Bentuknya dibuat tetap oleh buatState:

js dashboard.mjs — pembuat state awal
export function buatState() {
  return { statistik: null, transaksi: [], aktivitas: [], keadaan: 'memuat' };
}

Mari kita bedah:

  1. Baris 18 (export function buatState() {): pembuatan state jadi satu fungsi, bukan object literal yang disebar. Setiap kali state baru diperlukan — halaman baru dibuka, tombol muat ulang ditekan — wujud awalnya selalu berasal dari titik yang sama.
  2. Baris 19 (return { statistik: null, transaksi: [], aktivitas: [], keadaan: 'memuat' };): keempat field selalu ada sejak awal. Nilai awalnya “kosong yang jujur”: null untuk object statistik yang memang belum ada, array kosong untuk dua daftar, dan 'memuat' untuk keadaan antarmuka. Pembaca state tidak pernah perlu bertanya “apakah field ini ada” — cukup membaca isinya.

Field keadaan menyimpan posisi antarmuka. Nilai sahnya bertambah seiring proyek berjalan: di Bagian ini yang dipakai adalah 'memuat' dan 'siap', 'galat' dipakai antarmuka saat pemuatan gagal, dan 'sebagian' lahir di Bagian 4 ketika kegagalan mulai dilaporkan per sumber.

Tiga Pintu Perubahan #

Semua perubahan state lewat tiga fungsi berikut — masing-masing untuk kebutuhan yang berbeda:

js dashboard.mjs — tiga pintu perubahan state
export function setState(state, stateBaru) {
  return { ...stateBaru };
}

export function patchState(state, potongan) {
  return { ...state, ...potongan };
}

export function terapkanData(state, data) {
  return { ...state, ...data, keadaan: 'siap' };
}

Mari kita bedah, dengan nomor baris mengikuti berkas utuh yang tampil di bawah:

  1. Baris 22 (export function setState(state, stateBaru) {): penggantian wujud keseluruhan. Parameter state di sini tidak ikut menentukan hasil — mengganti penuh memang tidak membutuhkan isi lama — dan tanda tangan yang seragam (state lama, perubahan) membuat ketiga pintu terbaca dengan pola yang sama.
  2. Baris 23 (return { ...stateBaru };): hasilnya salinan baru, bukan objek argumennya. Uji “mengembalikan salinan baru, bukan object argumen” menuntut hal ini dengan assert.notStrictEqual: state dan sumber perubahan tidak boleh berbagi referensi yang sama.
  3. Baris 26–27 (export function patchState(state, potongan) { return { ...state, ...potongan }; }): pembaruan sebagian. Urutan spread menentukan segalanya — potongan ditulis belakangan sehingga field bernama sama tertimpa nilainya, sementara field lain, termasuk referensi array lama, tidak tersentuh sama sekali.
  4. Baris 30–31 (export function terapkanData(state, data) { return { ...state, ...data, keadaan: 'siap' }; }): pintu khusus muatan hasil muatDashboard. Isi muatan disebarkan dulu, lalu keadaan: 'siap' ditulis paling akhir agar keadaan siap tidak mungkin tertimpa oleh data — perpindahan ke 'siap' adalah milik fungsi ini, bukan keputusan pemanggil.

Demo: Aliran Satu Arah #

Berkas demo berikut memutar empat detik kehidupan state — awal, data tiba, muat ulang, reset:

// demo-state.mjs — aliran satu arah: setiap perubahan menghasilkan state baru,
// state lama tidak pernah diubah di belakang punggung kita.
import { buatState, patchState, setState, terapkanData } from './dashboard.mjs';

let state = buatState();
console.log('Awal   :', JSON.stringify(state));

state = terapkanData(state, {
  statistik: { pengunjung: 3420, pesanan: 128, pendapatan: 34500000 },
  transaksi: [{ id: 'TRX-1042', pembeli: 'Budi Santoso', total: 1250000 }],
  aktivitas: [{ waktu: '09:12', teks: 'Pesanan TRX-1042 dibayar' }],
});
console.log('Siap   :', state.keadaan, '- transaksi:', state.transaksi.length, 'baris');

state = patchState(state, { keadaan: 'memuat' });
console.log('Muat ulang:', state.keadaan, '- statistik masih tersimpan:', state.statistik !== null);

state = setState(state, buatState());
console.log('Reset  :', JSON.stringify(state));

Jalankan dari folder bagian ini:

bash
node demo-state.mjs

Output yang Dihasilkan:

text
Awal   : {"statistik":null,"transaksi":[],"aktivitas":[],"keadaan":"memuat"}
Siap   : siap - transaksi: 1 baris
Muat ulang: memuat - statistik masih tersimpan: true
Reset  : {"statistik":null,"transaksi":[],"aktivitas":[],"keadaan":"memuat"}

Mari kita bedah jalannya state:

  1. Baris 8 (state = terapkanData(state, {): muatan bergaya muatDashboard disuntikkan langsung. Variabel state diganti dengan state baru hasil return — objek lama tidak pernah disentuh, dan keadaan otomatis menjadi 'siap'.
  2. Baris 15 (state = patchState(state, { keadaan: 'memuat' });): simulasi tombol Muat Ulang. Hanya keadaan yang berganti; baris keluaran statistik masih tersimpan: true membuktikan patch tidak membuang data lama — inilah yang membuat tampilan lama tetap layak disajikan selagi data baru dimuat.
  3. Baris 18 (state = setState(state, buatState());): reset penuh — wujud awal dari buatState dimasukkan lewat pintu penggantian, dan keluaran Reset identik dengan baris Awal.
  4. Baris 6, 13, 16, 19 (console.log(...)): setiap detik dicetak dengan JSON.stringify atau pembacaan field tunggal; keluaran itu persis yang akan diklaim oleh uji.

dashboard.mjs pada Akhir Bagian 2 #

Berikut dashboard.mjs lengkap pada kondisi akhir Bagian ini. Dibanding versi Bagian 1, berkas ini menambah blok state: buatState, setState, patchState, dan terapkanData:

js dashboard.mjs
// dashboard.mjs — logika inti dashboard data, terpisah dari tampilan (DOM).
// Pemuat data di-inject dari luar (dependency injection): pemuat nyata bisa
// memanggil fetch, sedangkan uji memasang pemuat tiruan yang langsung selesai.
// Seluruh fungsi murni: state masuk, state baru keluar, tanpa DOM.

export async function muatDashboard(pemuat) {
  const [statistik, transaksi, aktivitas] = await Promise.all([
    pemuat.statistik(),
    pemuat.transaksi(),
    pemuat.aktivitas(),
  ]);
  return { statistik, transaksi, aktivitas };
}

// Satu tempat menyimpan wujud dashboard; satu-satunya pintu perubahannya
// adalah fungsi-fungsi di bawah — aliran satu arah, state lama tak tersentuh.

export function buatState() {
  return { statistik: null, transaksi: [], aktivitas: [], keadaan: 'memuat' };
}

export function setState(state, stateBaru) {
  return { ...stateBaru };
}

export function patchState(state, potongan) {
  return { ...state, ...potongan };
}

export function terapkanData(state, data) {
  return { ...state, ...data, keadaan: 'siap' };
}

Uji State: Perubahan yang Terkunci #

Suite setState mengunci dua hal — penggantian penuh menghasilkan state baru dengan keadaan yang diminta, dan hasilnya salinan terpisah dari objek argumennya. Suite patchState mengunci perilaku yang paling mudah salah ketika ditulis ulang:

js tests/dashboard.test.mjs — suite patchState
describe('patchState', () => {
  test('mengubah hanya field yang diberikan', () => {
    const state = buatState();
    const hasil = patchState(state, { keadaan: 'siap' });
    assert.strictEqual(hasil.keadaan, 'siap');
    assert.strictEqual(hasil.statistik, null);
    assert.strictEqual(hasil.transaksi, state.transaksi);
  });

  test('state lama tidak ikut berubah', () => {
    const state = buatState();
    patchState(state, { transaksi: [{ id: 'TRX-1040' }] });
    assert.strictEqual(state.transaksi.length, 0);
    assert.strictEqual(state.keadaan, 'memuat');
  });
});

Dua asersinya melingkupi dua sisi kontrak patch. Baris 113–115 memeriksa sisi hasil: keadaan berganti menjadi 'siap', statistik tetap null, dan hasil.transaksi bahkan masih berbagi referensi dengan array lama — bukti field yang tidak diberikan tidak dikopi ulang. Baris 120–122 memeriksa sisi lama: setelah patch dijalankan, state aslinya tetap utuh — transaksi masih kosong dan keadaan masih 'memuat'.

Pada kondisi kode Bagian 2, node --test dari folder proyek melaporkan:

bash
node --test

Output yang Dihasilkan:

text
▶ muatDashboard
  ✔ mengumpulkan data dari ketiga sumber (2.544912ms)
  ✔ ketiga pemuat dimulai bersamaan dan selesai mengikuti kecepatannya (80.214488ms)
  ✔ gagal satu sumber menolak keseluruhan muatan (0.733523ms)
✔ muatDashboard (84.771858ms)
▶ buatState
  ✔ state awal belum berisi data dan masih memuat (0.154267ms)
✔ buatState (0.336163ms)
▶ setState
  ✔ mengganti state keseluruhan dengan wujud baru (0.207938ms)
  ✔ mengembalikan salinan baru, bukan object argumen (0.167871ms)
✔ setState (0.558506ms)
▶ patchState
  ✔ mengubah hanya field yang diberikan (0.159185ms)
  ✔ state lama tidak ikut berubah (0.099309ms)
✔ patchState (0.456619ms)
▶ terapkanData
  ✔ menyimpan data muatan dan menandai keadaan siap (0.321106ms)
✔ terapkanData (1.3933ms)
ℹ tests 9
ℹ suites 5
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 150.434353

Sembilan uji lulus dari lima suite: suite Bagian 1 tetap hijau, dan empat suite state menyusul di belakangnya. duration_ms akan berbeda di mesin Anda.

Ringkasan Bagian 2 #

State dashboard kini punya rumah dan aturan: satu bentuk tetap yang dibuat buatState, tiga pintu perubahan yang semuanya murni, dan aliran satu arah yang membuat setiap perubahan terbaca sebagai urutan potongan. Yang masih menyimpan angka mentah adalah isinya — 3420 dan 34500000 belum berbicara kepada manusia. Bagian 3 menyiapkan penerjemahnya: formatter Intl yang meringkas angka, persentase yang memakai koma, bar teks yang bisa dibaca sekilas, dan antarmuka lengkap yang merendernya.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

styles/styles.css

Unduh berkas
css
/* styles.css — tampilan dashboard data */

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 24px 16px;
  background-color: #f3f4f6;
  font-family: system-ui, sans-serif;
  color: #111827;
}

.dashboard {
  max-width: 880px;
  margin: 0 auto;
}

.dashboard-atas {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
}

.dashboard-atas h1 {
  margin: 0;
  font-size: 1.5rem;
}

.tombol-muat {
  padding: 8px 16px;
  border: none;
  border-radius: 8px;
  background-color: #2563eb;
  color: #ffffff;
  font-size: 0.95rem;
  cursor: pointer;
}

.tombol-muat:hover {
  background-color: #1d4ed8;
}

.status {
  margin: 0 0 16px;
  padding: 10px 12px;
  background-color: #e5e7eb;
  border-radius: 8px;
}

.daftar-gagal {
  margin: 0 0 16px;
  padding: 10px 12px 10px 28px;
  background-color: #fee2e2;
  color: #991b1b;
  border-radius: 8px;
}

.daftar-gagal:empty {
  display: none;
}

.kartu {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
  margin-bottom: 16px;
}

.kartu-item {
  padding: 14px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}

.kartu-item h2 {
  margin: 0 0 8px;
  font-size: 0.85rem;
  font-weight: 600;
  color: #6b7280;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.kartu-angka {
  margin: 0 0 8px;
  font-size: 1.6rem;
  font-weight: 700;
}

.kartu-bar {
  margin: 0;
  font-family: monospace;
  font-size: 0.9rem;
  color: #2563eb;
  letter-spacing: 1px;
}

.konversi {
  margin: 0 0 16px;
  color: #6b7280;
}

.panel {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.panel-item {
  padding: 14px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}

.panel-item h2 {
  margin: 0 0 10px;
  font-size: 1rem;
}

.tabel-transaksi {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.9rem;
}

.tabel-transaksi th,
.tabel-transaksi td {
  padding: 6px 8px;
  text-align: left;
  border-bottom: 1px solid #e5e7eb;
}

.tabel-transaksi th {
  color: #6b7280;
  font-weight: 600;
}

.daftar-aktivitas {
  margin: 0;
  padding-left: 18px;
  font-size: 0.9rem;
}

.daftar-aktivitas li {
  margin-bottom: 6px;
  color: #374151;
}