Coba tutup tab browser tempat aplikasi todo kita berjalan, lalu buka lagi: seluruh daftar tugas kembali kosong. Penyebabnya bukan kejadian langka, melainkan sifat dasar variabel JavaScript — seluruh state hidup di memori selama halaman terbuka, dan memori itu dikosongkan begitu halaman ditutup. Browser menyediakan jawabannya pada chapter Web Storage: localStorage, penyimpanan kunci-nilai yang bertahan di luar siklus hidup halaman. Bagian ini memasangnya pada aplikasi todo — dengan satu batasan yang justru menentukan desainnya.

Batasan itu berbunyi: localStorage hanya ada di browser. Test runner Node tidak mengenalnya sama sekali, padahal seluruh kekuatan proyek ini selama tiga bagian datang dari uji yang berjalan di terminal. Melempar localStorage ke dalam tugas.mjs berarti membuat seluruh rangkaian uji itu gagal jalan. Solusinya adalah membelah pekerjaan penyimpanan menjadi dua lapis: logika murni yang mengubah state menjadi string JSON dan sebaliknya (bisa diuji Node), serta pembungkus tipis di antarmuka yang hanya bertugas memanggil setItem dan getItem (diuji mata kita di browser).

Wujud Baru tugas.mjs #

Berikut tugas.mjs lengkap pada akhir Bagian 3. Dibanding versi Bagian 2, ada satu kelompok fungsi baru di bagian bawah berkas — simpanTugas, helper tugasSah, dan muatTugas:

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.

// Nilai sah untuk argumen status filterTugas.
const STATUS_FILTER = ['semua', 'aktif', 'selesai'];

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, selesai: false }];
}

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

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

export function ubahStatus(daftar, id) {
  return daftar.map((tugas) =>
    tugas.id === id ? { ...tugas, selesai: !tugas.selesai } : tugas,
  );
}

export function filterTugas(daftar, status = 'semua') {
  if (!STATUS_FILTER.includes(status)) {
    throw new Error(`Status filter tidak dikenal: ${status}`);
  }
  if (status === 'aktif') {
    return daftar.filter((tugas) => !tugas.selesai);
  }
  if (status === 'selesai') {
    return daftar.filter((tugas) => tugas.selesai);
  }
  return [...daftar];
}

export function hitungAktif(daftar) {
  return daftar.filter((tugas) => !tugas.selesai).length;
}

// Penyimpanan: module ini hanya bekerja dengan string JSON; pembungkus
// localStorage hidup di antarmuka (lihat index.html).

export function simpanTugas(daftar) {
  return JSON.stringify(daftar);
}

function tugasSah(item) {
  return (
    typeof item?.id === 'number' &&
    typeof item?.teks === 'string' &&
    typeof item?.selesai === 'boolean'
  );
}

export function muatTugas(teksJson) {
  if (teksJson === null) {
    return [];
  }
  let data;
  try {
    data = JSON.parse(teksJson);
  } catch (galat) {
    throw new Error(`Data tersimpan bukan JSON yang valid: ${galat.message}`);
  }
  if (!Array.isArray(data)) {
    throw new Error('Data tersimpan bukan daftar tugas.');
  }
  return data.filter(tugasSah).map((item) => ({ ...item }));
}

Perhatikan bahwa tidak ada satu pun baris yang menyentuh window atau localStorage. Module ini berjanji: “beri aku array, aku beri teks JSON; beri aku teks JSON, aku kembalikan state”. Keputusan di mana teks itu disimpan bukan urusannya — dan justru karena itu seluruh perilakunya bisa dibuktikan dengan node --test.

Menyimpan: Satu Baris JSON.stringify #

Web Storage hanya menyimpan string — object yang ditulis langsung akan berubah menjadi teks "[object Object]", seperti dibahas pada chapter Web Storage dan JSON Serialization. Jadi sebelum disimpan, state array harus melewati JSON.stringify. Itulah seluruh pekerjaan simpanTugas:

js tugas.mjs — fungsi simpanTugas
export function simpanTugas(daftar) {
  return JSON.stringify(daftar);
}

Fungsi sesederhana ini tetap layak jadi fungsi publik yang diuji, bukan dipanggil langsung dari antarmuka. Alasannya konsisten dengan seluruh proyek: kontrak “array menjadi teks JSON” adalah keputusan data, dan keputusan data tinggal di module logika. Bila suatu hari format penyimpanan berubah — misalnya menambah field tanggal — tempatnya di sini, dan ujinya yang memaksa format baru diikuti di mana pun fungsi ini dipanggil.

Memuat dengan Tiga Lapis Pertahanan #

Arah sebaliknya lebih licin: string yang dibaca kembali dari penyimpanan bisa apa saja — bukan JSON sama sekali, JSON yang bukan array, atau array berisi entri yang tidak lengkap. muatTugas menyambutnya dengan tiga lapis pemeriksaan:

  1. if (teksJson === null): getItem mengembalikan null bila kuncinya belum pernah ditulis — keadaan wajar saat aplikasi pertama kali dibuka. Lapis ini mengembalikan [] tanpa drama, membedakan “belum ada data” dari “data yang ada tetapi tidak bisa dibaca”.
  2. try { data = JSON.parse(teksJson); } catch (galat) { ... }: teks yang bukan JSON membuat JSON.parse melempar galat. Kita menangkapnya dan melempar galat baru dengan pesan yang lebih mudah dibaca, sambil membawa pesan asli engine di dalamnya (Unexpected token ... at position ...) agar lokasi kerusakan tetap terlacak.
  3. if (!Array.isArray(data)): JSON valid belum tentu daftar tugas — '{"id":1}' berhasil diparsing tetapi hasilnya object, bukan array. Lapis ini menolaknya dengan pesan khusus.

Setelah ketiga lapis lolos, data.filter(tugasSah).map((item) => ({ ...item })) menyeleksi isinya. Helper tugasSah memeriksa tiga field wajib dengan optional chaining (item?.id aman dipanggil pada entri apa pun, termasuk null maupun number — hasilnya undefined yang gagal pemeriksaan typeof). Entri yang gagal diperiksa dilewati, bukan membuat seluruh muatan gagal: satu entri rusak tidak seharusnya menghapus daftar lain yang masih sah.

Pembungkus localStorage di Antarmuka #

Kini lapisan browsernya. Berikut index.html.txt lengkap pada akhir Bagian 3 — markup tidak berubah dari Bagian 2; yang bertambah ada di skrip:

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>
    <div class="filter">
      <button class="filter-tombol" data-filter="semua">Semua</button>
      <button class="filter-tombol" data-filter="aktif">Aktif</button>
      <button class="filter-tombol" data-filter="selesai">Selesai</button>
    </div>
    <p id="ringkasan" class="ringkasan"></p>
    <ul id="daftar-tugas" class="daftar-tugas"></ul>
  </main>
  <script type="module">
    import {
      tambahTugas,
      hapusTugas,
      daftarTugas,
      ubahStatus,
      filterTugas,
      hitungAktif,
      simpanTugas,
      muatTugas,
    } from './tugas.mjs';

    const KUNCI_PENYIMPANAN = 'daftar-tugas';

    let daftar = [];
    let filterAktif = 'semua';

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

    // Pembungkus localStorage — kode browser, tidak ikut diuji node --test.
    function simpan() {
      window.localStorage.setItem(KUNCI_PENYIMPANAN, simpanTugas(daftar));
    }

    function muat() {
      try {
        daftar = muatTugas(window.localStorage.getItem(KUNCI_PENYIMPANAN));
      } catch (galat) {
        console.warn('Data tersimpan dilewati:', galat.message);
        daftar = [];
      }
    }

    function render() {
      elemenDaftar.textContent = '';
      const tampilan = filterTugas(daftar, filterAktif);

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

        const kotakCentang = document.createElement('input');
        kotakCentang.type = 'checkbox';
        kotakCentang.checked = tugas.selesai;
        kotakCentang.dataset.id = String(tugas.id);

        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(kotakCentang, label, tombolHapus);
        elemenDaftar.append(item);
      }

      elemenRingkasan.textContent = `${hitungAktif(daftar)} tugas belum selesai`;
    }

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

    elemenDaftar.addEventListener('change', (event) => {
      if (event.target.matches('input[type="checkbox"]')) {
        daftar = ubahStatus(daftar, Number(event.target.dataset.id));
        simpan();
        render();
      }
    });

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

    document.querySelector('.filter').addEventListener('click', (event) => {
      if (event.target.matches('.filter-tombol')) {
        filterAktif = event.target.dataset.filter;
        render();
      }
    });

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

Lima perubahan yang layak dibedah:

  1. const KUNCI_PENYIMPANAN = 'daftar-tugas';: nama kunci dinyatakan sekali di atas skrip. Kunci yang salah ketik di satu tempat saja bisa membuat data terbaca kosong padahal tersimpan.
  2. function simpan(): satu baris pemanggilan setItem dengan hasil simpanTugas(daftar) — seluruh “urusan localStorage” di skrip hanya hidup di sini dan di muat().
  3. function muat(): getItem dipanggil terlebih dahulu, hasilnya langsung diteruskan ke muatTugas. Bila galat terlempar dari logika (JSON rusak, misalnya), blok catch mencatat peringatan ke console lewat console.warn lalu memulai dari daftar kosong — keputusan produk yang bisa diterima untuk aplikasi todo, dan sekarang tinggal di satu tempat.
  4. simpan() setelah setiap mutasi: listener submit, change, dan klik hapus masing-masing memanggil simpan() sebelum render(). Listener klik filter sengaja tidak memanggilnya — posisi filter bukan data tugas, dan menyimpannya hanya menimpa berkas yang sama tanpa perubahan isi.
  5. muat(); render(); di akhir skrip: urutan boot halaman. Data dipulihkan lebih dulu, baru digambar — dibalik urutan ini, pengguna sempat melihat daftar kosong selengganas satu frame.

Menguji Siklus Data di Node #

Tujuh uji baru menutup kontrak penyimpanan — semuanya berjalan di Node karena muatTugas menerima string, bukan kunci penyimpanan. Impornya kini mengambil simpanTugas dan muatTugas:

js tests/tugas.test.mjs — tambahan Bagian 3
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
  tambahTugas,
  hapusTugas,
  daftarTugas,
  ubahStatus,
  filterTugas,
  hitungAktif,
  simpanTugas,
  muatTugas,
} from '../src/tugas.mjs';

// ... kelompok uji Bagian 1 dan 2 tetap sama ...

describe('simpanTugas', () => {
  test('mengubah daftar menjadi teks JSON', () => {
    const daftar = [{ id: 1, teks: 'Satu', selesai: false }];
    assert.strictEqual(
      simpanTugas(daftar),
      '[{"id":1,"teks":"Satu","selesai":false}]',
    );
  });
});

describe('muatTugas', () => {
  test('null dikembalikan sebagai daftar kosong', () => {
    assert.deepStrictEqual(muatTugas(null), []);
  });

  test('mengurai teks JSON menjadi daftar tugas', () => {
    const hasil = muatTugas('[{"id":7,"teks":"Tugas lama","selesai":true}]');
    assert.deepStrictEqual(hasil, [
      { id: 7, teks: 'Tugas lama', selesai: true },
    ]);
  });

  test('JSON rusak ditolak dengan pesan yang jelas', () => {
    assert.throws(() => muatTugas('{bukan json'), /bukan JSON yang valid/);
  });

  test('data yang bukan array ditolak', () => {
    assert.throws(() => muatTugas('{"id":1}'), /bukan daftar tugas/);
  });

  test('entri yang tidak valid dilewati', () => {
    const teks = '[{"id":1,"teks":"A","selesai":false},{"teks":"tanpa id"},42]';
    assert.deepStrictEqual(muatTugas(teks), [{ id: 1, teks: 'A', selesai: false }]);
  });
});

describe('siklus data', () => {
  test('simpan lalu muat mengembalikan daftar yang sama', () => {
    let daftar = tambahTugas([], 'Satu');
    daftar = tambahTugas(daftar, 'Dua');
    daftar = ubahStatus(daftar, 2);
    assert.deepStrictEqual(muatTugas(simpanTugas(daftar)), daftar);
  });
});

Jalankan kembali dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ tambahTugas
  ✔ menambahkan tugas pertama ke daftar kosong (2.066116ms)
  ✔ id baru menyusul id tertinggi yang sudah ada (0.312314ms)
  ✔ teks dipangkas spasi di kedua ujungnya (0.259132ms)
  ✔ teks yang kosong setelah dipangkas ditolak (0.637832ms)
  ✔ daftar lama tidak ikut berubah (0.341141ms)
✔ tambahTugas (5.561673ms)
▶ hapusTugas
  ✔ menghapus tugas dengan id yang dicari (0.313128ms)
  ✔ id yang tidak ada tidak mengubah isi daftar (0.218495ms)
✔ hapusTugas (0.898405ms)
▶ daftarTugas
  ✔ mengembalikan salinan yang setara tetapi terpisah (0.400506ms)
✔ daftarTugas (0.721804ms)
▶ ubahStatus
  ✔ menandai tugas sebagai selesai (0.35257ms)
  ✔ menandai kembali menjadi aktif (0.337023ms)
  ✔ tugas lain mempertahankan referensinya (0.297824ms)
✔ ubahStatus (2.31944ms)
▶ filterTugas
  ✔ filter "semua" mengembalikan seluruh tugas dalam array baru (0.26735ms)
  ✔ filter "aktif" hanya menampilkan tugas yang belum selesai (0.196164ms)
  ✔ filter "selesai" hanya menampilkan tugas yang sudah selesai (0.11675ms)
  ✔ status yang tidak dikenal ditolak (0.172722ms)
✔ filterTugas (0.951794ms)
▶ hitungAktif
  ✔ menghitung tugas yang belum selesai (0.19115ms)
✔ hitungAktif (0.279152ms)
▶ skenario pengguna
  ✔ tambah dua tugas, tandai satu selesai, sisa satu aktif (0.198067ms)
✔ skenario pengguna (0.277247ms)
▶ simpanTugas
  ✔ mengubah daftar menjadi teks JSON (0.153462ms)
✔ simpanTugas (0.230107ms)
▶ muatTugas
  ✔ null dikembalikan sebagai daftar kosong (0.221572ms)
  ✔ mengurai teks JSON menjadi daftar tugas (0.305422ms)
  ✔ JSON rusak ditolak dengan pesan yang jelas (0.168081ms)
  ✔ data yang bukan array ditolak (0.108859ms)
  ✔ entri yang tidak valid dilewati (0.105382ms)
✔ muatTugas (1.097053ms)
▶ siklus data
  ✔ simpan lalu muat mengembalikan daftar yang sama (0.214751ms)
✔ siklus data (0.32289ms)
ℹ tests 24
ℹ suites 10
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 105.114554

Dua puluh empat uji lulus. Uji terakhir — “simpan lalu muat mengembalikan daftar yang sama” — adalah uji round-trip yang paling mewakili Bagian 3: state dibangun lewat fungsi-fungsi nyata (tambahTugas, ubahStatus), disimpan ke teks, dimuat kembali, lalu dibandingkan dengan deepStrictEqual. Selama round-trip ini lolos, apa pun yang browser tulis ke localStorage hari ini dijamin terbaca utuh esoknya. Uji “entri yang tidak valid dilewati” mengunci keputusan seleksi: entri tanpa id dan entri berupa number tidak menggagalkan muatan, entri yang sah tetap terselamatkan.

Ringkasan Bagian 3 #

Aplikasi todo kini hidup melampaui halamannya: setiap mutasi state ditulis ke localStorage sebagai teks JSON, dan setiap halaman yang dibuka memulihkannya lewat tiga lapis pertahanan di muatTugas. Batas tanggung jawabnya juga tegas — logika bekerja dengan string JSON dan diuji dua puluh empat kali di Node; pembungkus browser cukup dipanggil dan diuji mata di tab yang asli. Satu hal tersisa sebelum proyek ditutup: merapikan seluruh strategi pengujian ini menjadi satu gambaran utuh, di Bagian 4.

Berkas Sumber

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

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