Sebuah dashboard baru terlihat hidup ketika ketiga sumber datanya sampai: statistik kunjungan, daftar transaksi, dan alur aktivitas. Cara paling mudah memuatnya adalah satu per satu — tunggu statistik selesai, baru minta transaksi, baru minta aktivitas. Cara itu bekerja, tetapi waktu tunggunya dijumlahkan: bila masing-masing server butuh 50, 80, dan 10 milidetik, pengguna menunggu 140 milidetik sebelum satu pun angka tampil. Padahal ketiga permintaan itu tidak saling menunggu — tidak ada satu pun yang butuh hasil yang lain, sehingga semuanya bisa dikirim bersamaan dan waktu tunggu tinggal yang paling lambat.

Bagian pertama proyek ini menulis fungsi pertama logika inti: muatDashboard. Sambil menyusunnya, kita mengunci dua keputusan desain sekaligus. Pertama, pemuatan paralel dengan Promise.all — mekanisme yang sudah kita kenal pada chapter Promise Combinators. Kedua, dependency injection: fungsi tidak memanggil fetch sendiri, melainkan menerima pemuat — sebuah object berisi tiga fungsi async — dari pemanggilnya. Pemuat nyata kelak boleh memanggil fetch ke API internal, sedangkan demo dan uji memasang pemuat tiruan yang langsung selesai; dengan begitu logika jaringan bisa diuji di terminal tanpa satu pun permintaan internet, sebagaimana dibahas pada chapter Dependency Injection Overview.

Kontrak Pemuat dan muatDashboard #

Kontraknya sengaja dibuat kecil: pemuat adalah object dengan tiga properti — statistik, transaksi, aktivitas — dan masing-masing berupa fungsi async tanpa argumen yang mengembalikan data sumbernya. Berikut dashboard.mjs pada kondisi akhir Bagian ini:

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 };
}

Mari kita bedah baris-baris penentu:

  1. Baris 6 (export async function muatDashboard(pemuat) {): fungsi async yang menerima satu parameter pemuat. Seluruh ketergantungan jaringan masuk lewat parameter ini — module inti tidak mengimpor fetch sama sekali, sehingga pemuat bisa diganti apa saja yang mengikuti kontrak.
  2. Baris 7 (const [statistik, transaksi, aktivitas] = await Promise.all([): Promise.all menerima array of promise dan mengembalikan satu promise yang selesai ketika semua anggotanya selesai. Hasilnya berupa array yang urutannya mengikuti urutan anggota — bukan urutan datang — sehingga destructuring [statistik, transaksi, aktivitas] selalu mendapat data yang benar meski aktivitas tiba lebih dulu.
  3. Baris 8–10 (pemuat.statistik(), dst.): tiga pemuat dipanggil di sini, satu baris setelah yang lain tanpa await di antaranya. Karena dipanggil (ada kurungnya), ketiga promise langsung dibuat dan mulai bekerja hampir bersamaan; await cukup dilakukan satu kali pada Promise.all.
  4. Baris 11 (]);): penutup array dan pemanggilan Promise.all. Dari titik ini fungsi menunggu sampai ketiga sumber selesai.
  5. Baris 12 (return { statistik, transaksi, aktivitas };): ketiga data dikemas dalam satu object dengan shorthand property — bentuk inilah yang nanti diterima terapkanData pada Bagian 2.

Ada satu konsekuensi penting yang harus kita sebut sejak sekarang: bila satu anggota saja ditolak, Promise.all menolak keseluruhan muatan — meski dua sumber lainnya sudah sehat. Sifat fail-fast ini sengaja kita kunci dengan uji di bawah, dan pada Bagian 4 kita menggantinya dengan Promise.allSettled agar satu sumber gagal tidak mengganggu sumber yang sehat.

Demo: Berurutan vs Paralel #

Perbedaan berurutan dan paralel paling jelas ketika diamati lewat urutan peristiwa, bukan angka waktu — durasi selalu bervariasi antar mesin, sedangkan urutan penyelesaian untuk jeda yang jauh berbeda bersifat pasti. Berkas demo berikut memakai jejak peristiwa untuk membandingkan keduanya:

// demo-paralel.mjs — membandingkan pemuatan berurutan dan paralel
// lewat urutan penyelesaian, bukan angka waktu (durasi selalu bervariasi).
import { muatDashboard } from './dashboard.mjs';

const catatan = [];

const buatPemuat = (nama, jeda) => async () => {
  catatan.push(`mulai ${nama}`);
  await new Promise((selesai) => setTimeout(selesai, jeda));
  catatan.push(`selesai ${nama}`);
};

function tampilkan(jejak) {
  for (const baris of jejak) console.log(baris);
}

console.log('=== berurutan ===');
catatan.length = 0;
for (const pemuat of [
  buatPemuat('statistik', 50),
  buatPemuat('transaksi', 80),
  buatPemuat('aktivitas', 10),
]) {
  await pemuat();
}
tampilkan(catatan);

console.log('=== paralel (Promise.all) ===');
catatan.length = 0;
await muatDashboard({
  statistik: buatPemuat('statistik', 50),
  transaksi: buatPemuat('transaksi', 80),
  aktivitas: buatPemuat('aktivitas', 10),
});
tampilkan(catatan);

Jalankan dari folder bagian ini:

bash
node demo-paralel.mjs

Output yang Dihasilkan:

text
=== berurutan ===
mulai statistik
selesai statistik
mulai transaksi
selesai transaksi
mulai aktivitas
selesai aktivitas
=== paralel (Promise.all) ===
mulai statistik
mulai transaksi
mulai aktivitas
selesai aktivitas
selesai statistik
selesai transaksi

Dua belas baris itu menceritakan dua skenario dengan pemuat dan jeda yang sama persis. Pada skenario berurutan, peristiwa bergantian rapi: statistik dimulai dan selesai, baru transaksi mulai. Pada skenario paralel, tiga baris mulai muncul berturut-turut sebelum satu pun selesai — bukti ketiganya bekerja bersamaan — lalu urutan selesai mengikuti kecepatannya: aktivitas (jeda 10 ms) lebih dulu, transaksi (jeda 80 ms) paling akhir.

Mari kita bedah mekanismenya:

  1. Baris 5 (const catatan = [];): satu array jejak yang diisi pemuat-pemuatnya; inilah pengganti stopwatch kita.
  2. Baris 7 (const buatPemuat = (nama, jeda) => async () => {): pabrik pemuat tiruan berupa closure — setiap pemuat mencatat mulai, menunggu jeda milidetik lewat setTimeout yang dibungkus promise, lalu mencatat selesai.
  3. Baris 18 (catatan.length = 0;): jejak dikosongkan sebelum tiap skenario, agar isinya murni milik skenario yang sedang berjalan.
  4. Baris 24 (await pemuat();): skenario berurutan — pemuat berikutnya baru dibuat dan dipanggil setelah yang sebelumnya benar-benar selesai.
  5. Baris 30 (await muatDashboard({): skenario paralel memakai fungsi Bagian 1, dengan pemuat yang sama persis.
  6. Baris 35 (tampilkan(catatan);): jejak dicetak per baris.

Uji Cakupan Bagian 1 #

Perilaku di atas dikunci oleh suite muatDashboard — suite pertama dalam tests/dashboard.test.mjs, berkas uji yang kita tinjau secara utuh pada Bagian 4. Suite ini memakai bantuan DATA_TIRUAN (isi ketiga sumber) dan pemuatTiruan() (object pemuat berisi fungsi async yang mengembalikan data tiruan) yang didefinisikan di bagian atas berkas uji:

js tests/dashboard.test.mjs — suite muatDashboard
describe('muatDashboard', () => {
  test('mengumpulkan data dari ketiga sumber', async () => {
    const hasil = await muatDashboard(pemuatTiruan());
    assert.deepStrictEqual(hasil, DATA_TIRUAN);
  });

  test('ketiga pemuat dimulai bersamaan dan selesai mengikuti kecepatannya', async () => {
    const jejak = [];
    const pemuatBerjejak = (nama, jeda) => async () => {
      jejak.push(`mulai ${nama}`);
      await new Promise((selesai) => setTimeout(selesai, jeda));
      jejak.push(`selesai ${nama}`);
    };
    await muatDashboard({
      statistik: pemuatBerjejak('statistik', 50),
      transaksi: pemuatBerjejak('transaksi', 80),
      aktivitas: pemuatBerjejak('aktivitas', 10),
    });
    assert.deepStrictEqual(jejak, [
      'mulai statistik',
      'mulai transaksi',
      'mulai aktivitas',
      'selesai aktivitas',
      'selesai statistik',
      'selesai transaksi',
    ]);
  });

  test('gagal satu sumber menolak keseluruhan muatan', async () => {
    const pemuat = pemuatTiruan();
    pemuat.transaksi = async () => {
      throw new Error('server transaksi sedang diperbaiki');
    };
    await assert.rejects(muatDashboard(pemuat), /diperbaiki/);
  });
});

Mari kita bedah asersinya:

  1. Baris 37 (const hasil = await muatDashboard(pemuatTiruan());): pemuat tiruan dipasang, sehingga uji jalan tanpa jaringan dan tanpa menunggu apa pun selain jeda yang sengaja disisipkan pada uji kedua.
  2. Baris 38 (assert.deepStrictEqual(hasil, DATA_TIRUAN);): isi ketiga sumber dibandingkan sampai ke setiap object di dalamnya — bukan sekadar “ada isinya”.
  3. Baris 43 (const pemuatBerjejak = (nama, jeda) => async () => {): uji kedua membangun pemuat berjejak dengan closure yang sama seperti demo, lalu mengklaim enam peristiwa terurut.
  4. Baris 53 (assert.deepStrictEqual(jejak, [): daftar enam baris mengunci dua hal sekaligus — tiga mulai muncul sebelum satu pun selesai (paralel), dan urutan selesai mengikuti kecepatan tiap sumber (aktivitas, statistik, transaksi).
  5. Baris 68 (await assert.rejects(muatDashboard(pemuat), /diperbaiki/);): sifat fail-fast dikunci — satu sumber melempar Error('server transaksi sedang diperbaiki'), dan keseluruhan muatan harus ditolak dengan pesan yang cocok.

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

bash
node --test

Output yang Dihasilkan:

text
▶ muatDashboard
  ✔ mengumpulkan data dari ketiga sumber (1.224899ms)
  ✔ ketiga pemuat dimulai bersamaan dan selesai mengikuti kecepatannya (79.787427ms)
  ✔ gagal satu sumber menolak keseluruhan muatan (0.740943ms)
✔ muatDashboard (82.965638ms)
ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 140.815295

Tiga uji lulus dari satu suite. Angka duration_ms akan berbeda di mesin Anda — uji kedua sengaja menunggu jeda 80 ms, dan jumlah itu ikut terhitung.

Ringkasan Bagian 1 #

Logika pemuatan dashboard sudah berdiri dan teruji: tiga sumber dimuat paralel lewat satu fungsi murni yang menerima pemuat dari luar, hasilnya satu object yang urutannya pasti, dan kegagalan satu sumber sengaja ditolak seluruhnya untuk saat ini. Yang belum kita tentukan adalah tempat menyimpan hasil muatan itu — dan aturan mainnya ketika state harus berubah. Bagian 2 menata keduanya dengan satu bentuk state dan tiga pintu perubahan yang tidak pernah mengubah state lama.

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;
}