Sebuah aplikasi todo terlihat sederhana di mata penggunanya: tulis tugas baru, baca daftarnya, hapus yang tidak diperlukan lagi. Di balik kesederhanaan itu ada pertanyaan desain yang harus dijawab sebelum satu baris kode pun ditulis: data seperti apa yang mewakili satu tugas, di berkas mana daftar itu dikelola, dan bagaimana tampilan diperbarui setiap daftarnya bertambah atau berkurang. Bagian pertama proyek ini menjawab ketiganya — kita merancang state berupa array, menuliskannya sebagai module logika murni, menyusun kerangka antarmukanya, dan mengunci perilaku awal ini dengan uji otomatis.

Proyek kalkulator sebelumnya melatih state berupa beberapa nilai tetap. Aplikasi todo melatih kebalikannya: state berupa daftar yang panjangnya berubah-ubah, dan setiap itemnya sendiri adalah object dengan identitas sendiri. Perubahan bentuk state ini membawa keputusan baru — bagaimana memberi penanda unik pada setiap tugas, dan bagaimana menambah serta mengurangi isi array tanpa mengubah array lama.

Empat Berkas, Empat Peran #

Proyek ini memakai pola berkas yang sama dengan proyek kalkulator: logika murni terpisah dari tampilan, dan uji otomatis menjaga logikanya. Setiap berkas punya satu tanggung jawab:

BerkasPeran
src/tugas.mjsOtak: menambah, menghapus, dan menyalin daftar tugas. Tidak menyentuh DOM.
src/index.html.txtWajah: form input, daftar tampilan, dan skrip penghubung ke logika.
styles/styles.cssPenampil: kartu aplikasi, baris tugas, dan tombol.
tests/tugas.test.mjsPenjaga: uji otomatis yang memastikan logika berperilaku sesuai kontrak.

Pemisahan ini yang membuat seluruh logika bisa diuji lewat node --test tanpa membuka browser — kebiasaan yang sama dengan proyek kalkulator, yang berakar pada prinsip Separation of Concerns pada section Architecture and Patterns.

Merancang State: Array Berisi Object #

Mari kita perhatikan dulu apa yang perlu diingat aplikasi. Satu tugas paling sedikit punya dua hal: teksnya dan alamat untuk menunjuknya. Teks dibutuhkan agar bisa ditampilkan; alamat dibutuhkan karena menghapus atau mengubah tugas selalu dimulai dari pertanyaan “tugas yang mana”. Maka satu tugas kita wakili satu object kecil, dan seluruh state berupa array object:

js
[
  { id: 1, teks: 'Belajar module ES' },
  { id: 2, teks: 'Menulis uji otomatis' },
]

Field id berupa number yang unik di dalam daftar. Kita tidak memakai posisi array sebagai alamat — posisi bergeser setiap kali ada item yang dihapus, sedangkan id menempel pada tugasnya. Dua keputusan desain menyertai bentuk state ini:

  • Setiap fungsi logika menerima array dan mengembalikan array baru. Array lama tidak pernah diubah — pola immutability yang dibahas pada section Functional Programming. Uji bisa membandingkan sebelum dan sesudah dengan pasti karena yang lama tetap utuh.
  • Fungsi logika ditulis murni. Tidak ada satu pun document atau localStorage di dalamnya; semua keputusan data ada di sini, dan tampilan hanya merender hasilnya.

Tabel berikut memperagakan tiga operasi dasar Bagian 1:

PanggilanDaftar sebelumDaftar sesudah
tambahTugas(daftar, 'Belajar module ES')[][{ id: 1, teks: 'Belajar module ES' }]
tambahTugas(daftar, 'Membaca materi')[{ id: 1, ... }][{ id: 1, ... }, { id: 2, teks: 'Membaca materi' }]
hapusTugas(daftar, 1)[{ id: 1, ... }, { id: 2, ... }][{ id: 2, ... }]

Module Logika Pertama #

Berikut tugas.mjs versi pertama — berkas lengkap pada akhir Bagian 1 ini. Tiga fungsi pertama proyek ini menutup operasi tambah, hapus, dan baca:

js tugas.mjs
// tugas.mjs — logika inti aplikasi todo, terpisah dari tampilan (DOM).
// Semua fungsi murni: menerima state array dan mengembalikan array baru,
// sehingga dapat diuji dengan node --test tanpa browser.

export function tambahTugas(daftar, teks) {
  const teksBersih = teks.trim();
  if (teksBersih === '') {
    throw new Error('Teks tugas tidak boleh kosong.');
  }
  const idBaru = Math.max(0, ...daftar.map((tugas) => tugas.id)) + 1;
  return [...daftar, { id: idBaru, teks: teksBersih }];
}

export function hapusTugas(daftar, id) {
  return daftar.filter((tugas) => tugas.id !== id);
}

export function daftarTugas(daftar) {
  return daftar.map((tugas) => ({ ...tugas }));
}

Mari kita bedah perannya satu per satu:

  1. const teksBersih = teks.trim();: input yang datang dari kolom form sering membawa spasi tersembunyi di kedua ujungnya. trim() membersihkannya lebih dulu, sehingga ' Belajar ' dan 'Belajar' menyimpan tugas yang sama.
  2. if (teksBersih === ''): guard clause yang menolak teks kosong dengan melempar Error. Tanpa guard ini, pengguna bisa menumpuk tugas tak bernama hanya dengan menekan tombol Tambah pada kolom yang kosong.
  3. Math.max(0, ...daftar.map((tugas) => tugas.id)) + 1: menghitung id berikutnya dari id tertinggi yang sudah ada. daftar.map(...) mengumpulkan semua id, spread ... menyebarkannya sebagai argumen Math.max, dan angka 0 di depan menjaga hasil tetap 1 ketika daftar masih kosong (tanpa itu, Math.max() tanpa argumen menghasilkan -Infinity). Konsekuensinya jujur untuk dinyatakan: id hanya unik di dalam daftar saat itu — menghapus tugas ber-id tertinggi lalu menambah tugas baru akan memakai ulang id tersebut.
  4. return [...daftar, { id: idBaru, teks: teksBersih }]: array baru yang berisi seluruh tugas lama plus satu tugas baru. Array asli tidak tersentuh sama sekali.
  5. hapusTugas(daftar, id): filter memilih semua tugas yang id-nya berbeda dari yang dituju, dan mengembalikan array baru. Bila id tidak ditemukan, hasilnya array baru dengan isi yang sama — operasi yang sopan, bukan galat.
  6. daftarTugas(daftar): mengembalikan salinan daftar berikut salinan tiap itemnya (map dengan spread). Fungsi ini yang dipanggil antarmuka sebelum merender, sehingga kode tampilan tidak pernah memegang referensi langsung ke state.

Membuktikan Logika dari Terminal #

Karena module ini bebas DOM, ketiga fungsinya bisa dicoba langsung dari terminal. Berkas demo berikut menyimulasikan pemakaian singkat tanpa browser:

// demo-tugas.mjs — mencoba fungsi dasar tugas dari terminal.
import { tambahTugas, hapusTugas, daftarTugas } from './tugas.mjs';

let daftar = [];
daftar = tambahTugas(daftar, '  Belajar module ES  ');
daftar = tambahTugas(daftar, 'Menulis uji otomatis');
daftar = tambahTugas(daftar, 'Menyiram tanaman');

console.log('Isi daftar saat ini:');
for (const tugas of daftarTugas(daftar)) {
  console.log(`  #${tugas.id} ${tugas.teks}`);
}

const salinan = daftarTugas(daftar);
daftar = hapusTugas(daftar, 2);

console.log('Salinan sebelum penghapusan :', salinan.length, 'tugas');
console.log('Daftar terkini              :', daftar.length, 'tugas');
console.log(
  'Tugas yang tersisa          :',
  daftar.map((tugas) => `#${tugas.id} ${tugas.teks}`).join(', '),
);

Jalankan dari folder bagian ini:

bash
node demo-tugas.mjs

Output yang Dihasilkan:

text
Isi daftar saat ini:
  #1 Belajar module ES
  #2 Menulis uji otomatis
  #3 Menyiram tanaman
Salinan sebelum penghapusan : 3 tugas
Daftar terkini              : 2 tugas
Tugas yang tersisa          : #1 Belajar module ES, #3 Menyiram tanaman

Tiga hal patut dicatat dari keluaran di atas. Teks tugas pertama tersimpan bersih tanpa spasi ujungnya, bukti kerja trim(). Baris kelima dan keenam memperlihatkan immutability dalam aksi: salinan diambil sebelum penghapusan dan tetap berisi tiga tugas, sementara daftar yang menampung hasil hapusTugas berisi dua. Baris terakhir menegaskan bahwa id 2 benar-benar pergi bersama tugasnya, sedangkan id 1 dan 3 tidak bergeser.

Kerangka Antarmuka: index.html #

Berkas antarmuka disimpan sebagai index.html.txt — salin ulang sebagai index.html bila ingin mencobanya. Berikut wujudnya pada akhir Bagian 1:

html index.html.txt
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Aplikasi Todo</title>
  <link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
  <main class="aplikasi">
    <h1>Daftar Tugas</h1>
    <form id="form-tugas" class="form-tugas">
      <input type="text" id="input-tugas" name="tugas" placeholder="Tulis tugas baru..." autocomplete="off">
      <button type="submit" class="tombol-tambah">Tambah</button>
    </form>
    <p id="pesan" class="pesan" hidden></p>
    <ul id="daftar-tugas" class="daftar-tugas"></ul>
  </main>
  <script type="module">
    import { tambahTugas, hapusTugas, daftarTugas } from './tugas.mjs';

    let daftar = [];

    const form = document.querySelector('#form-tugas');
    const input = document.querySelector('#input-tugas');
    const elemenDaftar = document.querySelector('#daftar-tugas');
    const elemenPesan = document.querySelector('#pesan');

    function render() {
      elemenDaftar.textContent = '';
      const tampilan = daftarTugas(daftar);

      for (const tugas of tampilan) {
        const item = document.createElement('li');
        item.className = 'tugas';

        const label = document.createElement('span');
        label.textContent = tugas.teks;

        const tombolHapus = document.createElement('button');
        tombolHapus.className = 'tombol-hapus';
        tombolHapus.textContent = 'Hapus';
        tombolHapus.dataset.id = String(tugas.id);

        item.append(label, tombolHapus);
        elemenDaftar.append(item);
      }
    }

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      try {
        daftar = tambahTugas(daftar, input.value);
        input.value = '';
        elemenPesan.hidden = true;
        render();
      } catch (galat) {
        elemenPesan.textContent = galat.message;
        elemenPesan.hidden = false;
      }
    });

    elemenDaftar.addEventListener('click', (event) => {
      if (event.target.matches('.tombol-hapus')) {
        daftar = hapusTugas(daftar, Number(event.target.dataset.id));
        render();
      }
    });

    render();
  </script>
</body>
</html>

Skrip di bagian bawah menghubungkan tampilan ke logika. Mari kita bedah baris-baris pentingnya:

  1. let daftar = [];: seluruh skrip menjaga satu variabel array sebagai satu-satunya sumber kebenaran tampilan — state awalnya kosong karena aplikasi baru dibuka.
  2. elemenDaftar.textContent = '';: setiap kali render() dipanggil, isi daftar dikosongkan lebih dulu, lalu dibangun ulang dari state. Pola re-render penuh ini sengaja dipilih karena sederhana: tidak ada sinkronisasi parsial yang bisa meleset, karena tampilan selalu digambar ulang dari nol.
  3. tombolHapus.dataset.id = String(tugas.id);: setiap tombol Hapus membawa id tugasnya lewat data attribute. dataset menyimpan nilai sebagai string, maka konversinya eksplisit di sini — dan nanti dikembalikan dengan Number() saat dibaca.
  4. event.preventDefault();: menghentikan perilaku bawaan form yang memuat ulang halaman saat submit — perilaku yang dibahas pada chapter Preventing Default Behavior.
  5. try { ... } catch (galat) { ... }: guard tambahTugas bisa melempar galat, dan batas antarmuka yang menangkapnya. Pesan galat ditulis ke elemen #pesan lewat textContent — bukan innerHTML — sesuai aturan Safe DOM Manipulation.
  6. event.target.matches('.tombol-hapus'): satu listener pada #daftar-tugas melayani seluruh tombol Hapus, sekarang dan nanti (event delegation). Klik di bagian lain baris tidak cocok dengan selector, sehingga diabaikan.
  7. hapusTugas(daftar, Number(event.target.dataset.id)): id string dari data attribute dikembalikan menjadi number sebelum diteruskan ke logika — bentuk data yang persis sama dengan saat disimpan.

Panggilan render() di baris terakhir skrip menggambar daftar kosong saat halaman baru dibuka.

Tampilan Sederhana dengan CSS #

Berkas styles/styles.css menjaga tampilan tetap sederhana namun nyata — kartu aplikasi di tengah layar, baris tugas bertumpuk rapi:

css styles.css
/* styles.css — tampilan aplikasi todo sederhana */

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f3f4f6;
  font-family: system-ui, sans-serif;
}

.aplikasi {
  width: 340px;
  padding: 20px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.aplikasi h1 {
  margin: 0 0 16px;
  font-size: 1.4rem;
}

.form-tugas {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

.form-tugas input {
  flex: 1;
  padding: 10px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  font-size: 1rem;
}

.tombol-tambah {
  padding: 10px 16px;
  border: none;
  border-radius: 8px;
  background-color: #3b82f6;
  color: #ffffff;
  font-size: 1rem;
  cursor: pointer;
}

.tombol-tambah:hover {
  background-color: #2563eb;
}

.pesan {
  margin: 0 0 12px;
  color: #b91c1c;
  font-size: 0.9rem;
}

.filter {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

.filter-tombol {
  flex: 1;
  padding: 8px 0;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background-color: #f9fafb;
  font-size: 0.9rem;
  cursor: pointer;
}

.filter-tombol:hover {
  background-color: #e5e7eb;
}

.ringkasan {
  margin: 0 0 12px;
  color: #6b7280;
  font-size: 0.9rem;
}

.daftar-tugas {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 8px;
}

.tugas {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px;
  background-color: #f9fafb;
  border-radius: 8px;
}

.tugas span {
  flex: 1;
}

.tugas--selesai span {
  color: #9ca3af;
  text-decoration: line-through;
}

.tombol-hapus {
  padding: 6px 10px;
  border: none;
  border-radius: 6px;
  background-color: #fecaca;
  font-size: 0.85rem;
  cursor: pointer;
}

.tombol-hapus:hover {
  background-color: #fca5a5;
}

Empat aturan yang paling menentukan bentuk: body memusatkan kartu aplikasi dengan flexbox; .form-tugas menempelkan kolom input dan tombol Tambah dalam satu baris (flex: 1 membuat input mengisi sisa lebar); .daftar-tugas menghilangkan bullet bawaan <ul> dan menata baris tugas dengan CSS Grid; dan .tugas menata tiap baris sebagai flexbox, dengan flex: 1 pada span agar teks tugas mendorong tombol Hapus ke tepi kanan. Kelas .filter, .ringkasan, dan .tugas--selesai belum dipakai Bagian 1 — aturannya disiapkan sekarang agar Bagian 2 cukup menambah markup dan logika tanpa menyentuh CSS lagi.

Uji Pertama dengan node –test #

Sekarang kita kunci perilaku yang sudah ditulis. Berikut isi tests/tugas.test.mjs pada kondisi Bagian 1 — berkas uji tumbuh mengikuti pembahasan, dan seluruh uji di bawah tetap ada sampai akhir proyek:

js tests/tugas.test.mjs — kondisi Bagian 1
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
  tambahTugas,
  hapusTugas,
  daftarTugas,
} from '../src/tugas.mjs';

describe('tambahTugas', () => {
  test('menambahkan tugas pertama ke daftar kosong', () => {
    const hasil = tambahTugas([], 'Belajar module ES');
    assert.deepStrictEqual(hasil, [{ id: 1, teks: 'Belajar module ES' }]);
  });

  test('id baru menyusul id tertinggi yang sudah ada', () => {
    let daftar = tambahTugas([], 'Satu');
    daftar = tambahTugas(daftar, 'Dua');
    daftar = tambahTugas(daftar, 'Tiga');
    assert.deepStrictEqual(daftar.map((tugas) => tugas.id), [1, 2, 3]);
  });

  test('teks dipangkas spasi di kedua ujungnya', () => {
    const hasil = tambahTugas([], '  Belajar module ES  ');
    assert.strictEqual(hasil[0].teks, 'Belajar module ES');
  });

  test('teks yang kosong setelah dipangkas ditolak', () => {
    assert.throws(() => tambahTugas([], '   '), /tidak boleh kosong/);
  });

  test('daftar lama tidak ikut berubah', () => {
    const lama = [{ id: 1, teks: 'Satu' }];
    const hasil = tambahTugas(lama, 'Dua');
    assert.notStrictEqual(hasil, lama);
    assert.strictEqual(lama.length, 1);
    assert.strictEqual(hasil.length, 2);
  });
});

describe('hapusTugas', () => {
  test('menghapus tugas dengan id yang dicari', () => {
    let daftar = tambahTugas([], 'Satu');
    daftar = tambahTugas(daftar, 'Dua');
    const hasil = hapusTugas(daftar, 1);
    assert.deepStrictEqual(hasil.map((tugas) => tugas.id), [2]);
  });

  test('id yang tidak ada tidak mengubah isi daftar', () => {
    const daftar = tambahTugas([], 'Satu');
    const hasil = hapusTugas(daftar, 99);
    assert.deepStrictEqual(hasil, daftar);
    assert.notStrictEqual(hasil, daftar);
  });
});

describe('daftarTugas', () => {
  test('mengembalikan salinan yang setara tetapi terpisah', () => {
    const daftar = tambahTugas([], 'Satu');
    const salinan = daftarTugas(daftar);
    assert.deepStrictEqual(salinan, daftar);
    assert.notStrictEqual(salinan, daftar);
    salinan[0].teks = 'diubah';
    assert.strictEqual(daftar[0].teks, 'Satu');
  });
});

Jalankan dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ tambahTugas
  ✔ menambahkan tugas pertama ke daftar kosong (1.163034ms)
  ✔ id baru menyusul id tertinggi yang sudah ada (0.185932ms)
  ✔ teks dipangkas spasi di kedua ujungnya (0.14889ms)
  ✔ teks yang kosong setelah dipangkas ditolak (0.359266ms)
  ✔ daftar lama tidak ikut berubah (0.133984ms)
✔ tambahTugas (3.677979ms)
▶ hapusTugas
  ✔ menghapus tugas dengan id yang dicari (0.169773ms)
  ✔ id yang tidak ada tidak mengubah isi daftar (0.228205ms)
✔ hapusTugas (0.575897ms)
▶ daftarTugas
  ✔ mengembalikan salinan yang setara tetapi terpisah (0.198051ms)
✔ daftarTugas (0.334004ms)
ℹ tests 8
ℹ suites 3
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 82.094816

Delapan uji lulus (pass 8, fail 0), dikelompokkan dalam tiga suite yang mengikuti nama fungsinya. Beberapa asersi layak dicermati. deepStrictEqual membandingkan isi array sampai ke tiap object — cocok untuk state berupa array yang isinya harus persis. assert.throws dengan RegExp mengunci pesan galat teks kosong, pola yang sama dengan uji galat kalkulator. Dan uji “daftar lama tidak ikut berubah” memakai notStrictEqual untuk membandingkan referensi: hasilnya harus array baru, bukan array yang sama yang isinya diganti. Angka durasi seperti 1.163034ms berbeda-beda setiap dijalankan karena mengukur waktu mesin Anda.

Ringkasan Bagian 1 #

Kerangka proyek telah berdiri: state array berisi object { id, teks }, tiga fungsi murni tambahTugas, hapusTugas, dan daftarTugas, antarmuka dengan form, delegasi klik, dan pesan galat, CSS yang lengkap sampai ke kelas yang belum terpakai, serta delapan uji otomatis yang lulus. Satu kebutuhan pokok masih menggantung: aplikasi tidak bisa membedakan tugas yang selesai dari yang belum — dan itulah pekerjaan Bagian 2.

Berkas Sumber

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

styles.css

Unduh berkas
css
/* styles.css — tampilan aplikasi todo sederhana */

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f3f4f6;
  font-family: system-ui, sans-serif;
}

.aplikasi {
  width: 340px;
  padding: 20px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.aplikasi h1 {
  margin: 0 0 16px;
  font-size: 1.4rem;
}

.form-tugas {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

.form-tugas input {
  flex: 1;
  padding: 10px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  font-size: 1rem;
}

.tombol-tambah {
  padding: 10px 16px;
  border: none;
  border-radius: 8px;
  background-color: #3b82f6;
  color: #ffffff;
  font-size: 1rem;
  cursor: pointer;
}

.tombol-tambah:hover {
  background-color: #2563eb;
}

.pesan {
  margin: 0 0 12px;
  color: #b91c1c;
  font-size: 0.9rem;
}

.filter {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

.filter-tombol {
  flex: 1;
  padding: 8px 0;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background-color: #f9fafb;
  font-size: 0.9rem;
  cursor: pointer;
}

.filter-tombol:hover {
  background-color: #e5e7eb;
}

.ringkasan {
  margin: 0 0 12px;
  color: #6b7280;
  font-size: 0.9rem;
}

.daftar-tugas {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 8px;
}

.tugas {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px;
  background-color: #f9fafb;
  border-radius: 8px;
}

.tugas span {
  flex: 1;
}

.tugas--selesai span {
  color: #9ca3af;
  text-decoration: line-through;
}

.tombol-hapus {
  padding: 6px 10px;
  border: none;
  border-radius: 6px;
  background-color: #fecaca;
  font-size: 0.85rem;
  cursor: pointer;
}

.tombol-hapus:hover {
  background-color: #fca5a5;
}