Kenapa ini penting
Di modul sebelumnya kamu belajar menemukan elemen. Sekarang saatnya mengubahnya dan membuat halaman bereaksi terhadap pengguna. Coba ingat aplikasi yang kamu pakai setiap hari: menekan tombol "+" di keranjang lalu angkanya bertambah, mengetik di kolom pencarian lalu daftar langsung menyaring, mencentang tugas di to-do list lalu tugas itu dicoret. Semuanya terjadi tanpa reload halaman, dan semuanya adalah kombinasi tiga hal: membuat/mengubah elemen, mendengarkan event, dan menyimpan state.
Analoginya seperti panggung teater. HTML adalah set panggung yang sudah disiapkan sebelum pertunjukan. JavaScript adalah kru panggung yang bisa memindahkan properti, menyalakan lampu, atau menambah kursi di tengah pertunjukan. Event adalah aba-aba dari sutradara: "saat aktor masuk, nyalakan lampu sorot". Kru tidak bergerak sembarangan; mereka menunggu aba-aba.
Modul ini juga membahas satu isu keamanan yang sering diremehkan pemula, yaitu XSS (Cross-Site Scripting). Salah memilih antara innerHTML dan textContent bisa membuat situsmu menjalankan kode jahat milik orang lain. Ini bukan teori: celah XSS rutin masuk daftar kerentanan web paling umum.
Konsep inti
Membuat dan menyisipkan elemen
Langkahnya selalu sama: buat, isi, sisipkan.
const li = document.createElement("li"); // 1. buat (belum tampil di layar)
li.textContent = "Cappuccino"; // 2. isi
li.className = "item";
document.querySelector("#daftar-menu").append(li); // 3. sisipkan → sekarang tampil
Method penyisipan yang perlu kamu tahu:
| Method | Posisi |
|---|---|
parent.append(a, b, "teks") |
di akhir anak-anak parent; bisa banyak sekaligus dan menerima string |
parent.prepend(x) |
di awal anak-anak parent |
el.before(x) / el.after(x) |
tepat sebelum atau sesudah el, sebagai saudara |
el.replaceWith(x) |
mengganti el dengan x |
parent.replaceChildren(a, b) |
mengganti seluruh isi parent sekaligus |
el.remove() |
menghapus el dari DOM |
parent.appendChild(x) |
versi lama: hanya satu node, tidak menerima string |
Kalau kamu menambah banyak elemen sekaligus (misalnya 100 produk), kumpulkan dulu di sebuah DocumentFragment lalu sisipkan sekali. Fragment adalah wadah sementara di memori, jadi DOM yang tampil hanya disentuh satu kali, bukan 100 kali.
innerHTML vs textContent, dan bahaya XSS
| Properti | Membaca | Menulis |
|---|---|---|
textContent |
semua teks di dalam elemen | string dianggap teks biasa; tag ditampilkan apa adanya |
innerHTML |
HTML di dalam elemen sebagai string | string di-parse sebagai HTML; tag menjadi elemen |
innerText |
teks yang terlihat (memperhitungkan CSS) | mirip textContent, tapi lebih lambat |
innerHTML enak dipakai karena bisa menulis banyak struktur sekaligus dengan template literal. Masalahnya muncul ketika isi string berasal dari pengguna atau sumber luar (API, URL, database). Misalkan kamu menampilkan komentar dengan div.innerHTML = komentar, lalu seseorang mengirim komentar:
<img src="x" onerror="alert(document.cookie)">
Browser akan membuat tag <img>, gambarnya gagal dimuat, lalu onerror menjalankan JavaScript milik penyerang di halaman milikmu. Penyerang bisa mencuri sesi login, mengubah tampilan, atau mengarahkan pengunjung ke situs palsu. Inilah XSS.
Aturan praktisnya:
- Data dari pengguna atau API ditampilkan dengan
textContent(ataucreateElement+textContent). innerHTMLhanya untuk HTML yang kamu tulis sendiri dan tidak mengandung data luar, misalnya mengosongkan elemen denganel.innerHTML = ""atau kerangka statis.- Kalau terpaksa menyisipkan data ke template
innerHTML, escape dulu karakter& < > " 'menjadi entity HTML. Contoh functionescapeHTMLada di bawah.
classList dan style
Cara terbaik mengubah tampilan adalah mengganti class, lalu biarkan CSS yang mengatur gayanya. JavaScript cukup menyalakan atau mematikan "saklar".
el.classList.add("aktif"),remove("aktif"),toggle("aktif"),contains("aktif"),replace("lama", "baru").toggle("aktif", kondisi)menambah jikakondisibernilaitruedan menghapus jikafalse. Sangat praktis.
el.style mengubah inline style satu per satu. Nama properti CSS ditulis camelCase: el.style.backgroundColor = "tomato". Pakai style hanya untuk nilai yang benar-benar dinamis, misalnya posisi hasil hitungan atau lebar progress bar. Pilihan lain yang rapi adalah mengubah CSS custom property: el.style.setProperty("--progress", "70%"). Untuk membaca gaya akhir yang benar-benar diterapkan (termasuk dari file CSS), pakai getComputedStyle(el).
Event listener dan event object
el.addEventListener("click", handler) mendaftarkan function handler yang dipanggil setiap kali event terjadi. Hindari atribut onclick="..." di HTML. Atribut itu mencampur logika dengan markup dan hanya bisa menyimpan satu handler.
Event yang sering dipakai:
| Kategori | Event |
|---|---|
| Mouse/pointer | click, dblclick, mouseenter, mouseleave, pointerdown |
| Keyboard | keydown, keyup (gunakan e.key, misalnya "Enter" atau "Escape") |
| Form | input (setiap ketikan), change (setelah nilai selesai diubah), submit, focus, blur |
| Dokumen | DOMContentLoaded (HTML selesai di-parse), scroll, resize |
Handler menerima event object (biasa dinamai e atau event) berisi detail kejadian:
e.target: elemen asli yang memicu event (misalnya ikon di dalam tombol).e.currentTarget: elemen tempat listener dipasang.e.type: jenis event.e.key: tombol keyboard yang ditekan.e.preventDefault(): batalkan perilaku bawaan browser.e.stopPropagation(): hentikan perjalanan event ke elemen lain.
Opsi ketiga addEventListener juga berguna: { once: true } membuat listener otomatis dilepas setelah sekali jalan. Untuk melepas listener secara manual dengan removeEventListener, kamu harus memberikan function yang sama (bukan arrow function baru).
Bubbling dan capturing
Saat kamu mengklik sebuah tombol di dalam kartu di dalam main, event tidak hanya terjadi di tombol. Event menempuh tiga fase:
- Capturing: turun dari
window→document→html→body→ ... → menuju target. - Target: sampai di elemen yang diklik.
- Bubbling: naik kembali dari target ke
body,html,document,window.
Secara default listener berjalan di fase bubbling. Itu sebabnya listener di main tetap terpicu saat kamu mengklik tombol di dalamnya. Pasang { capture: true } untuk mendengarkan di fase capturing. Analoginya seperti batu yang dijatuhkan ke kolam: capturing adalah batu yang tenggelam ke dasar, bubbling adalah gelembung yang naik ke permukaan dan melewati setiap lapisan air.
Tidak semua event bubbling. Contoh yang sering menjebak: focus dan blur tidak bubbling, sedangkan pasangannya focusin dan focusout bubbling.
Event delegation
Bayangkan katalog dengan 200 produk, masing-masing punya tombol "Tambah ke keranjang". Memasang 200 listener boros memori, dan lebih repot lagi: produk yang ditambahkan belakangan (misalnya dari fetch) tidak punya listener. Event delegation memanfaatkan bubbling. Pasang satu listener di elemen pembungkus, lalu cek e.target.closest(...) untuk mengetahui tombol mana yang diklik. Ini pola yang akan kamu pakai di proyek akhir.
preventDefault dan form handling
Beberapa elemen punya perilaku bawaan. Link berpindah halaman, form dikirim dan halaman reload, checkbox tercentang. e.preventDefault() membatalkan perilaku itu sehingga JavaScript yang mengambil alih. Untuk form, polanya:
- Dengarkan event
submitdi<form>, bukanclickdi tombol. Event submit juga terpicu saat pengguna menekan Enter. - Panggil
e.preventDefault(). - Baca data dengan
new FormData(form)laluObject.fromEntries(...)untuk menjadikannya object. Ini membutuhkan atributnamedi setiap input. - Validasi (regex dari modul 1), tampilkan pesan error, atau proses data.
form.reset()untuk mengosongkan.
Atribut HTML seperti required, type="email", minlength, dan pattern memberikan validasi bawaan browser secara gratis. Tetap validasi lagi di JavaScript untuk pesan yang lebih ramah, dan wajib validasi ulang di server, karena validasi di browser bisa dilewati siapa saja lewat DevTools.
localStorage: ingatan browser
localStorage menyimpan pasangan key-value di browser, dan datanya tetap ada walau tab ditutup atau komputer dimatikan. Batasnya kecil, umumnya sekitar 5 MB per origin, dan hanya bisa menyimpan string.
localStorage.setItem("kunci", "nilai"),getItem("kunci")(nulljika tidak ada),removeItem("kunci"),clear().- Untuk array/object: simpan dengan
JSON.stringify(data), baca denganJSON.parse(teks). sessionStoragepunya API yang sama, tapi data hilang saat tab ditutup.
Jangan simpan data sensitif (password, token rahasia, nomor kartu) di localStorage, karena script apa pun di halaman itu, termasuk script jahat dari celah XSS, bisa membacanya. Bungkus JSON.parse dengan try/catch karena data bisa rusak atau diubah manual oleh pengguna.
Contoh kode
Membuat elemen, fragment, dan XSS
const daftar = document.querySelector("#daftar-menu");
// Tambah satu item dengan aman
function tambahItem(nama, harga) {
const li = document.createElement("li");
li.className = "item";
li.dataset.harga = harga;
li.textContent = `${nama}, Rp${harga.toLocaleString("id-ID")}`; // aman: dianggap teks
daftar.append(li);
return li;
}
tambahItem("Cappuccino", 27000); // tampil: 'Cappuccino, Rp27.000'
// Banyak item sekaligus lewat DocumentFragment
const menuBaru = [
{ nama: "Mocha", harga: 28000 },
{ nama: "Americano", harga: 20000 },
{ nama: "Matcha Latte", harga: 30000 },
];
const frag = document.createDocumentFragment();
for (const m of menuBaru) {
const li = document.createElement("li");
li.textContent = m.nama;
frag.append(li);
}
daftar.append(frag); // satu kali sisip ke DOM
// Demonstrasi XSS
const komentarJahat = `<img src="x" onerror="alert('Kamu kena XSS!')">`;
const kotak = document.createElement("div");
document.body.append(kotak);
kotak.textContent = komentarJahat; // AMAN → teks tag-nya terlihat apa adanya
// kotak.innerHTML = komentarJahat; // BAHAYA → alert muncul, script penyerang jalan
// Kalau tetap mau pakai template innerHTML, escape data luar dulu
function escapeHTML(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
})[c]);
}
console.log(escapeHTML(komentarJahat));
// → '<img src="x" onerror="alert('Kamu kena XSS!')">'
const nama = "<b>Budi</b>";
kotak.innerHTML = `<p class="sapa">Halo, ${escapeHTML(nama)}!</p>`; // tampil: Halo, <b>Budi</b>!
classList, style, dan dark mode
<button id="tombol-tema" aria-pressed="false">🌙 Mode gelap</button>
<div class="progress"><div class="progress__bar"></div></div>
<style>
body { background: #fff; color: #222; transition: background .3s, color .3s; }
body.gelap { background: #121212; color: #eee; }
.progress { position: sticky; top: 0; height: 8px; background: #ddd; }
.progress__bar { height: 100%; width: var(--progress, 0%); background: #e67e22; }
</style>
const tombolTema = document.querySelector("#tombol-tema");
function terapkanTema(gelap) {
document.body.classList.toggle("gelap", gelap);
tombolTema.textContent = gelap ? "☀️ Mode terang" : "🌙 Mode gelap";
tombolTema.setAttribute("aria-pressed", String(gelap)); // aksesibilitas untuk pembaca layar
}
tombolTema.addEventListener("click", () => {
const gelap = !document.body.classList.contains("gelap");
terapkanTema(gelap);
localStorage.setItem("tema", gelap ? "gelap" : "terang");
});
// Terapkan tema tersimpan saat halaman dibuka
terapkanTema(localStorage.getItem("tema") === "gelap");
// style untuk nilai dinamis: progress bar mengikuti posisi scroll
const bar = document.querySelector(".progress__bar");
window.addEventListener("scroll", () => {
const maksScroll = document.documentElement.scrollHeight - window.innerHeight;
const persen = maksScroll > 0 ? (window.scrollY / maksScroll) * 100 : 0;
bar.style.setProperty("--progress", `${persen}%`);
});
console.log(getComputedStyle(document.body).backgroundColor); // → 'rgb(255, 255, 255)' atau 'rgb(18, 18, 18)'
Event object, bubbling, capturing
<section id="luar">
<div id="tengah">
<button id="dalam">Klik saya</button>
</div>
</section>
const log = (label) => (e) =>
console.log(`${label} | target: ${e.target.id} | currentTarget: ${e.currentTarget.id}`);
document.querySelector("#luar").addEventListener("click", log("luar (bubble)"));
document.querySelector("#tengah").addEventListener("click", log("tengah (bubble)"));
document.querySelector("#dalam").addEventListener("click", log("dalam"));
document.querySelector("#luar").addEventListener("click", log("luar (capture)"), { capture: true });
// Klik tombol, urutan di console:
// → luar (capture) | target: dalam | currentTarget: luar
// → dalam | target: dalam | currentTarget: dalam
// → tengah (bubble) | target: dalam | currentTarget: tengah
// → luar (bubble) | target: dalam | currentTarget: luar
// Keyboard: tutup modal dengan Escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") console.log("Tutup modal"); // → 'Tutup modal' saat Esc ditekan
});
// Listener sekali jalan
document.querySelector("#dalam").addEventListener(
"click",
() => console.log("Bonus klik pertama!"),
{ once: true }
);
Coba tambahkan e.stopPropagation() di handler tengah. Log luar (bubble) tidak akan muncul lagi karena perjalanan event dihentikan di tengah. Gunakan ini dengan hemat, karena kode lain (misalnya analytics atau penutup dropdown) sering bergantung pada bubbling.
Event delegation: to-do list lengkap dengan localStorage
<form id="form-todo">
<input name="judul" placeholder="Mau ngerjain apa?" required maxlength="80">
<button>Tambah</button>
</form>
<p id="info-todo"></p>
<ul id="list-todo"></ul>
<style>
.selesai span { text-decoration: line-through; opacity: .5; }
</style>
const KUNCI = "todo-v1";
const form = document.querySelector("#form-todo");
const list = document.querySelector("#list-todo");
const info = document.querySelector("#info-todo");
// State: sumber kebenaran ada di array ini, bukan di DOM
function muat() {
try {
return JSON.parse(localStorage.getItem(KUNCI)) ?? [];
} catch {
return []; // data rusak → mulai dari kosong
}
}
let todos = muat();
function simpan() {
localStorage.setItem(KUNCI, JSON.stringify(todos));
}
// Render ulang seluruh list dari state
function render() {
const frag = document.createDocumentFragment();
for (const t of todos) {
const li = document.createElement("li");
li.dataset.id = t.id;
li.classList.toggle("selesai", t.selesai);
const cek = document.createElement("input");
cek.type = "checkbox";
cek.checked = t.selesai;
cek.dataset.aksi = "centang";
const teks = document.createElement("span");
teks.textContent = t.judul; // input pengguna → textContent, anti-XSS
const hapus = document.createElement("button");
hapus.textContent = "Hapus";
hapus.dataset.aksi = "hapus";
li.append(cek, " ", teks, " ", hapus);
frag.append(li);
}
list.replaceChildren(frag); // ganti seluruh isi sekaligus
const sisa = todos.filter((t) => !t.selesai).length;
info.textContent = `${sisa} dari ${todos.length} tugas belum selesai`;
}
// Form handling
form.addEventListener("submit", (e) => {
e.preventDefault(); // cegah reload halaman
const data = Object.fromEntries(new FormData(form)); // → { judul: '...' }
const judul = data.judul.trim();
if (!judul) return;
todos.push({ id: Date.now(), judul, selesai: false });
simpan();
render();
form.reset();
form.elements.judul.focus();
});
// SATU listener untuk semua tombol & checkbox, termasuk yang dibuat nanti
list.addEventListener("click", (e) => {
const aksi = e.target.dataset.aksi;
if (!aksi) return; // klik di area lain, abaikan
const id = Number(e.target.closest("li").dataset.id);
if (aksi === "hapus") {
todos = todos.filter((t) => t.id !== id);
} else if (aksi === "centang") {
todos = todos.map((t) => (t.id === id ? { ...t, selesai: !t.selesai } : t));
}
simpan();
render();
});
render();
// Tambah "Belajar DOM" lalu "Push ke GitHub" → info: '2 dari 2 tugas belum selesai'
// Centang yang pertama → info: '1 dari 2 tugas belum selesai'
// Reload halaman → data tetap ada karena dibaca ulang dari localStorage
Perhatikan pola state → render. Data disimpan di array todos, lalu tampilan dibangun ulang dari array itu setiap kali ada perubahan. Kamu tidak perlu mengedit DOM satu per satu dan khawatir tampilan tidak sinkron dengan data. Pola inilah yang nanti diotomatiskan oleh React.
Validasi form dengan pesan per kolom
<form id="form-daftar" novalidate>
<label>Email <input name="email" type="email"></label>
<small class="error" data-untuk="email"></small>
<label>No. HP <input name="hp" inputmode="tel"></label>
<small class="error" data-untuk="hp"></small>
<button>Daftar</button>
</form>
const formDaftar = document.querySelector("#form-daftar");
const aturan = {
email: { pola: /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/, pesan: "Format email belum benar" },
hp: { pola: /^(?:\+62|62|0)8[1-9]\d{7,10}$/, pesan: "Nomor HP Indonesia tidak valid" },
};
formDaftar.addEventListener("submit", (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(formDaftar));
data.hp = data.hp.replace(/[\s-]/g, "");
let semuaValid = true;
for (const [kolom, { pola, pesan }] of Object.entries(aturan)) {
const valid = pola.test(data[kolom]);
formDaftar.querySelector(`[data-untuk="${kolom}"]`).textContent = valid ? "" : pesan;
formDaftar.elements[kolom].setAttribute("aria-invalid", String(!valid));
if (!valid) semuaValid = false;
}
if (semuaValid) console.log("Kirim ke server:", data); // → { email: '...', hp: '0812...' }
});
// Validasi langsung saat kolom ditinggalkan (blur tidak bubble, focusout bubble)
formDaftar.addEventListener("focusout", (e) => {
const kolom = e.target.name;
if (!aturan[kolom]) return;
const nilai = e.target.value.replace(/[\s-]/g, "");
const valid = nilai === "" || aturan[kolom].pola.test(nilai);
formDaftar.querySelector(`[data-untuk="${kolom}"]`).textContent = valid ? "" : aturan[kolom].pesan;
});
Atribut novalidate mematikan balon validasi bawaan browser supaya pesan custom kita yang tampil. Karena blur tidak bubbling, delegation di form memakai pasangannya, focusout.
Kesalahan umum
- Memasukkan data pengguna atau API ke
innerHTML. Ini pintu XSS. PakaitextContentataucreateElement. Kalau terpaksa memakai template string, escape dulu denganescapeHTML. - Mendengarkan
clickdi tombol submit alih-alihsubmitdi form. Akibatnya, menekan Enter tetap me-reload halaman karenapreventDefaulttidak pernah terpanggil. Selalu pasang listenersubmitdi elemen<form>. - Memanggil handler, bukan memberikannya.
btn.addEventListener("click", simpan())langsung menjalankansimpansekali dan mendaftarkan hasilnya (undefined). Tulissimpantanpa tanda kurung, atau() => simpan(id)kalau butuh argumen. - Bingung
e.targetdane.currentTarget. Pada tombol berisi<span>atau ikon,e.targetbisa jadispan-nya. Di delegation, pakaie.target.closest("button")supaya selalu dapat tombolnya. - Menyimpan object langsung ke localStorage.
localStorage.setItem("cart", [1, 2])tersimpan sebagai"1,2", dan object menjadi"[object Object]". SelaluJSON.stringifysaat menyimpan danJSON.parsesaat membaca.
Latihan
- Mudah, Buat tombol "Suka" dengan penghitung. Setiap diklik angkanya bertambah, dan setelah reload angkanya tetap (pakai localStorage).
- Sedang, Buat kolom pencarian yang menyaring
<li>di#daftar-menusecara langsung saat pengguna mengetik (eventinput), tidak peduli huruf besar/kecil, dan menampilkan teks "Tidak ada menu yang cocok" bila hasilnya kosong. - Menantang, Buat komponen tab (tiga tombol tab dan tiga panel). Pakai satu listener (delegation) di wadah tab, tandai tab aktif dengan class
aktifdanaria-selected, tampilkan hanya panel yang sesuai, dan dukung navigasi keyboard panah kiri/kanan.
Lihat solusi
<!-- 1. Mudah -->
<button id="suka">❤️ <span id="jumlah-suka">0</span></button>
const tombolSuka = document.querySelector("#suka");
const jumlahSuka = document.querySelector("#jumlah-suka");
let suka = Number(localStorage.getItem("suka")) || 0;
jumlahSuka.textContent = suka;
tombolSuka.addEventListener("click", () => {
suka++;
jumlahSuka.textContent = suka;
localStorage.setItem("suka", suka); // angka otomatis jadi string, dibaca balik dengan Number()
});
<!-- 2. Sedang: letakkan di atas #daftar-menu -->
<input id="cari" type="search" placeholder="Cari menu...">
<p id="kosong" hidden>Tidak ada menu yang cocok</p>
const cari = document.querySelector("#cari");
const kosong = document.querySelector("#kosong");
cari.addEventListener("input", () => {
const kata = cari.value.trim().toLowerCase();
let tampil = 0;
document.querySelectorAll("#daftar-menu li").forEach((li) => {
const cocok = li.textContent.toLowerCase().includes(kata);
li.hidden = !cocok; // atribut hidden = display:none bawaan browser
if (cocok) tampil++;
});
kosong.hidden = tampil > 0;
});
<!-- 3. Menantang -->
<div class="tabs">
<div role="tablist" id="tablist">
<button role="tab" class="aktif" aria-selected="true" data-panel="p1">Kopi</button>
<button role="tab" aria-selected="false" tabindex="-1" data-panel="p2">Teh</button>
<button role="tab" aria-selected="false" tabindex="-1" data-panel="p3">Snack</button>
</div>
<section id="p1" role="tabpanel">Daftar kopi...</section>
<section id="p2" role="tabpanel" hidden>Daftar teh...</section>
<section id="p3" role="tabpanel" hidden>Daftar snack...</section>
</div>
const tablist = document.querySelector("#tablist");
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
function aktifkan(tab) {
tabs.forEach((t) => {
const aktif = t === tab;
t.classList.toggle("aktif", aktif);
t.setAttribute("aria-selected", String(aktif));
t.tabIndex = aktif ? 0 : -1;
document.getElementById(t.dataset.panel).hidden = !aktif;
});
tab.focus();
}
tablist.addEventListener("click", (e) => {
const tab = e.target.closest('[role="tab"]');
if (tab) aktifkan(tab);
});
tablist.addEventListener("keydown", (e) => {
const i = tabs.indexOf(document.activeElement);
if (i === -1) return;
if (e.key === "ArrowRight") aktifkan(tabs[(i + 1) % tabs.length]);
if (e.key === "ArrowLeft") aktifkan(tabs[(i - 1 + tabs.length) % tabs.length]);
});
Operasi % tabs.length membuat navigasi berputar: dari tab terakhir, panah kanan kembali ke tab pertama.
Ringkasan
- Pola membuat elemen:
createElement→ isi dengantextContent/atribut → sisipkan denganappend/prepend/before/after/replaceChildren. GunakanDocumentFragmentuntuk banyak elemen. textContentuntuk data dari luar,innerHTMLhanya untuk HTML buatanmu sendiri. Melanggar aturan ini membuka celah XSS.- Ubah tampilan lewat
classListdan CSS; pakaistyleatau CSS custom property hanya untuk nilai dinamis. - Event berjalan capturing → target → bubbling. Bubbling memungkinkan event delegation: satu listener di induk dan
e.target.closest()untuk menemukan pemicunya. - Form: dengarkan
submit, panggilpreventDefault(), baca denganFormData, validasi, lalureset(). localStoragehanya menyimpan string. GunakanJSON.stringify/JSON.parsedengantry/catch, dan jangan simpan data sensitif.