Kenapa ini penting
Hampir tidak ada aplikasi modern yang datanya ditulis langsung di HTML. Daftar produk Tokopedia, cuaca hari ini, kurs Rupiah, postingan media sosial, semuanya diambil dari server saat halaman dibuka. Halaman web hanyalah "etalase", sedangkan barangnya ada di gudang. Jalur penghubung etalase dan gudang itu disebut API (Application Programming Interface). Dalam konteks web, API biasanya berupa alamat URL yang membalas data dalam format JSON.
Analoginya seperti memesan makanan di restoran. Kamu (browser) tidak masuk ke dapur (database). Kamu memberi pesanan ke pelayan (API) dengan format yang disepakati, yaitu menu. Pelayan pergi ke dapur lalu kembali membawa makanan (response). Kadang pelayan kembali dengan kabar "maaf, menu ini habis" (error 404) atau "dapur sedang kacau" (error 500), dan kamu harus siap menghadapi kabar itu dengan sopan, bukan diam saja.
Di materi async kamu sudah belajar Promise dan async/await. Modul ini memakainya untuk hal yang paling sering dikerjakan front-end developer: mengambil data, menangani loading dan error, lalu menampilkannya ke DOM. Kita akan memakai API publik gratis tanpa API key:
- JSONPlaceholder (
https://jsonplaceholder.typicode.com): data palsu berupa posts, users, todos, comments. Cocok untuk latihan GET dan POST. POST di sini hanya disimulasikan: server membalas seolah data tersimpan, padahal tidak benar-benar disimpan. - DummyJSON (
https://dummyjson.com/products): data produk lengkap dengan harga, kategori, rating, dan gambar. Kita pakai di proyek akhir.
Kedua API ini mengirim header CORS yang mengizinkan diakses dari halaman mana pun, jadi bisa langsung dipanggil dari browser.
Konsep inti
Anatomi request dan response HTTP
Setiap komunikasi dengan API terdiri dari request (permintaan) dan response (balasan).
Request berisi:
- URL: alamat sumber daya, misalnya
https://jsonplaceholder.typicode.com/posts/1. Bisa ditambah query string seperti?userId=1&_limit=5. - Method: jenis tindakan.
GET(ambil),POST(buat baru),PUT/PATCH(ubah),DELETE(hapus). - Headers: informasi tambahan, misalnya
Content-Type: application/jsonyang memberi tahu server bahwa body berformat JSON. - Body: data yang dikirim (untuk POST, PUT, PATCH). GET tidak punya body.
Response berisi:
- Status code: angka tiga digit yang meringkas hasilnya.
- Headers: misalnya tipe konten.
- Body: isi data, biasanya JSON.
| Status | Arti | Contoh situasi |
|---|---|---|
| 200 OK | Berhasil | GET produk sukses |
| 201 Created | Data baru berhasil dibuat | POST komentar sukses |
| 204 No Content | Berhasil, tanpa isi | DELETE sukses |
| 400 Bad Request | Request-mu salah format | field wajib kosong |
| 401 / 403 | Belum login / tidak punya izin | token kedaluwarsa |
| 404 Not Found | Sumber daya tidak ada | /posts/99999 |
| 429 Too Many Requests | Terlalu sering memanggil | kena rate limit |
| 500 / 503 | Server sedang bermasalah | bukan salahmu, tapi tetap harus ditangani |
Hafalkan pola umumnya: 2xx sukses, 4xx kesalahan di sisi peminta, 5xx kesalahan di sisi server.
JSON
JSON (JavaScript Object Notation) adalah format teks untuk bertukar data. Bentuknya mirip object JavaScript, tapi lebih ketat: key wajib memakai tanda kutip ganda, tidak boleh ada koma di akhir, tidak boleh ada function, undefined, atau komentar.
JSON.stringify(obj)mengubah object menjadi string JSON (untuk dikirim).JSON.parse(teks)mengubah string JSON menjadi object (untuk dipakai).response.json()membaca body response lalu mem-parse-nya sekaligus. Hasilnya Promise, jadi perluawait.
fetch dan jebakan terbesarnya
fetch(url, opsi) mengembalikan Promise berisi object Response. Ada satu hal yang sering membuat pemula bingung: fetch tidak menganggap 404 atau 500 sebagai error. Promise dari fetch hanya reject jika jaringan gagal total, misalnya offline, DNS tidak ditemukan, atau diblokir CORS. Jika server membalas 404, fetch tetap sukses dan memberikan response dengan response.ok === false.
Karena itu, setiap fetch harus memeriksa response.ok (bernilai true untuk status 200-299) dan melempar error sendiri kalau perlu. Pola yang aman:
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
Loading, error, dan empty state
Pengguna tidak bisa melihat jaringan. Kalau layar kosong selama dua detik, mereka mengira aplikasinya rusak. Setiap tampilan yang memuat data perlu menangani empat keadaan:
- Loading: data sedang diambil. Tampilkan spinner atau skeleton, lalu nonaktifkan tombol supaya tidak diklik dua kali.
- Success: data datang, lalu tampilkan.
- Empty: data datang tapi kosong (misalnya hasil pencarian nihil). Tampilkan pesan yang jelas, bukan layar putih.
- Error: gagal. Tampilkan pesan manusiawi dan, kalau masuk akal, tombol "Coba lagi".
Gunakan try / catch / finally. Blok finally selalu jalan, baik sukses maupun gagal, jadi cocok untuk mematikan indikator loading.
Timeout dan pembatalan dengan AbortController
Secara default, fetch bisa menunggu sangat lama kalau server lambat. AbortSignal.timeout(5000) membuat sinyal yang otomatis membatalkan request setelah 5 detik. AbortController juga berguna untuk membatalkan request lama ketika pengguna sudah mengetik kata kunci baru, sehingga hasil lama tidak menimpa hasil baru. Request yang dibatalkan akan reject dengan error bernama AbortError (atau TimeoutError untuk timeout).
CORS: kenapa kadang fetch ditolak
Browser menerapkan aturan keamanan bernama Same-Origin Policy. JavaScript di https://tokoku.com secara default tidak boleh membaca response dari origin lain seperti https://api.lain.com. Origin adalah gabungan protokol, domain, dan port. Tanpa aturan ini, situs jahat yang kamu buka bisa diam-diam memanggil API bank-mu memakai cookie login-mu dan membaca saldomu.
CORS (Cross-Origin Resource Sharing) adalah cara server memberi izin. Server API menambahkan header seperti Access-Control-Allow-Origin: * (semua boleh) atau Access-Control-Allow-Origin: https://tokoku.com (hanya origin itu). Browser membaca header ini lalu memutuskan apakah JavaScript-mu boleh melihat response-nya.
Beberapa hal penting tentang CORS:
- Aturan CORS ditegakkan browser, bukan server. Karena itu, URL yang sama bisa sukses di Postman,
curl, atau Node.js, tapi gagal di browser. - Error CORS diperbaiki di server, bukan di kode front-end. Menambahkan header
Access-Control-Allow-Origindi requestfetchtidak membantu, karena header itu adalah header response. - Untuk request yang "tidak sederhana" (misalnya memakai
Content-Type: application/jsonatau methodPUT/DELETE), browser lebih dulu mengirim request preflight bermethodOPTIONSuntuk menanyakan izin. Kalau server tidak menjawab dengan benar, request utamanya tidak pernah dikirim. - Kalau API pihak ketiga tidak mengizinkan CORS, solusinya adalah membuat proxy/backend sendiri (server-mu memanggil API itu, lalu browser memanggil server-mu). Opsi
mode: "no-cors"bukan solusi: response-nya menjadi opaque dan isinya tidak bisa dibaca sama sekali. - Membuka file langsung lewat
file:///sering bermasalah. Jalankan proyek lewat server lokal, misalnya ekstensi Live Server di VS Code ataunpx serve.
Di DevTools, pesan errornya biasanya berbunyi "...has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present...". Kalau melihat pesan itu, kamu sekarang tahu masalahnya ada di izin server.
Keamanan API key
API publik seperti JSONPlaceholder tidak butuh key. Banyak API lain butuh. Ingat satu hal: semua yang ada di kode front-end bisa dilihat siapa pun lewat View Source atau tab Network. Jangan pernah menaruh API key rahasia (misalnya key payment gateway atau AI berbayar) di JavaScript browser. Simpan di backend.
Contoh kode
GET dasar dan pengecekan status
async function ambilPost(id) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
console.log(res.status, res.ok); // → 200 true
if (!res.ok) throw new Error(`Gagal memuat post ${id}: HTTP ${res.status}`);
return res.json(); // Promise berisi object
}
const post = await ambilPost(1); // await di level atas bisa di Console atau <script type="module">
console.log(post.title); // → 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit'
console.log(Object.keys(post)); // → [ 'userId', 'id', 'title', 'body' ]
// Membuktikan fetch TIDAK reject saat 404
const res404 = await fetch("https://jsonplaceholder.typicode.com/posts/99999");
console.log(res404.status, res404.ok); // → 404 false (tidak masuk catch!)
try {
await ambilPost(99999);
} catch (err) {
console.log(err.message); // → 'Gagal memuat post 99999: HTTP 404'
}
Helper fetchJSON yang bisa dipakai ulang
// Satu tempat untuk: cek status, parse JSON, timeout, pesan error rapi
async function fetchJSON(url, { timeout = 8000, ...opsi } = {}) {
let res;
try {
res = await fetch(url, { ...opsi, signal: opsi.signal ?? AbortSignal.timeout(timeout) });
} catch (err) {
if (err.name === "TimeoutError") throw new Error("Server terlalu lama merespons. Coba lagi.");
if (err.name === "AbortError") throw err; // dibatalkan sengaja, teruskan apa adanya
throw new Error("Tidak bisa terhubung. Periksa koneksi internet kamu.");
}
if (!res.ok) {
const pesan = {
404: "Data tidak ditemukan.",
429: "Terlalu banyak permintaan. Tunggu sebentar.",
}[res.status] ?? (res.status >= 500 ? "Server sedang bermasalah." : `Permintaan gagal (HTTP ${res.status}).`);
throw new Error(pesan);
}
return res.status === 204 ? null : res.json();
}
// Query string dengan URLSearchParams
const params = new URLSearchParams({ userId: 1, _limit: 3 });
const posts = await fetchJSON(`https://jsonplaceholder.typicode.com/posts?${params}`);
console.log(posts.length); // → 3
console.log(posts[0].userId); // → 1
POST: mengirim data
async function kirimPost(judul, isi) {
return fetchJSON("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" }, // wajib agar server tahu body-nya JSON
body: JSON.stringify({ title: judul, body: isi, userId: 1 }), // body harus string
});
}
const baru = await kirimPost("Belajar fetch", "POST itu ternyata gampang");
console.log(baru);
// → { title: 'Belajar fetch', body: 'POST itu ternyata gampang', userId: 1, id: 101 }
// Status response-nya 201 Created. id 101 dari server simulasi; datanya tidak benar-benar tersimpan.
// PATCH dan DELETE polanya sama
const diubah = await fetchJSON("https://jsonplaceholder.typicode.com/posts/1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Judul baru" }),
});
console.log(diubah.title); // → 'Judul baru'
Render ke DOM lengkap dengan loading, empty, error
<section>
<label>Pilih user:
<select id="pilih-user"><option value="">Memuat...</option></select>
</label>
<div id="status" role="status" aria-live="polite"></div>
<ul id="hasil"></ul>
</section>
<style>
.skeleton { height: 1.2em; margin: .5em 0; border-radius: 4px;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%; animation: kilau 1.2s infinite; }
@keyframes kilau { to { background-position: -200% 0; } }
.error { color: #c0392b; }
</style>
const API = "https://jsonplaceholder.typicode.com";
const pilih = document.querySelector("#pilih-user");
const statusEl = document.querySelector("#status");
const hasil = document.querySelector("#hasil");
function tampilLoading() {
statusEl.textContent = "Memuat tugas...";
hasil.innerHTML = '<li class="skeleton"></li>'.repeat(5); // HTML statis buatan sendiri, aman
}
function tampilError(pesan, ulangi) {
hasil.innerHTML = "";
statusEl.textContent = "";
const p = document.createElement("p");
p.className = "error";
p.textContent = pesan;
const tombol = document.createElement("button");
tombol.textContent = "Coba lagi";
tombol.addEventListener("click", ulangi, { once: true });
statusEl.append(p, tombol);
}
function tampilTodos(todos) {
hasil.innerHTML = "";
if (todos.length === 0) {
statusEl.textContent = "User ini belum punya tugas.";
return;
}
const selesai = todos.filter((t) => t.completed).length;
statusEl.textContent = `${selesai} dari ${todos.length} tugas selesai`;
const frag = document.createDocumentFragment();
for (const t of todos) {
const li = document.createElement("li");
li.textContent = `${t.completed ? "✅" : "⬜"} ${t.title}`; // data API → textContent
frag.append(li);
}
hasil.append(frag);
}
let pengontrol; // untuk membatalkan request lama
async function muatTodos(userId) {
pengontrol?.abort(); // batalkan request sebelumnya kalau user cepat ganti pilihan
pengontrol = new AbortController();
tampilLoading();
pilih.disabled = true;
try {
const todos = await fetchJSON(`${API}/todos?userId=${userId}`, { signal: pengontrol.signal });
tampilTodos(todos);
} catch (err) {
if (err.name === "AbortError") return; // diganti request baru, bukan error
tampilError(err.message, () => muatTodos(userId));
} finally {
pilih.disabled = false; // selalu jalan: sukses maupun gagal
}
}
async function init() {
try {
const users = await fetchJSON(`${API}/users`);
pilih.innerHTML = '<option value="">-- pilih --</option>';
for (const u of users) {
pilih.append(new Option(u.name, u.id)); // new Option(teks, value) aman dari XSS
}
} catch (err) {
tampilError(err.message, init);
}
}
pilih.addEventListener("change", () => {
if (pilih.value) muatTodos(pilih.value);
});
init();
// Pilih "Leanne Graham" → status: '11 dari 20 tugas selesai', lalu 20 <li> tampil
Mengambil beberapa sumber sekaligus
// Promise.all: jalan paralel, gagal semua kalau satu gagal
const [user, postsUser] = await Promise.all([
fetchJSON("https://jsonplaceholder.typicode.com/users/1"),
fetchJSON("https://jsonplaceholder.typicode.com/posts?userId=1"),
]);
console.log(`${user.name} menulis ${postsUser.length} post`); // → 'Leanne Graham menulis 10 post'
// Promise.allSettled: tetap tampilkan yang berhasil walau ada yang gagal
const hasilSemua = await Promise.allSettled([
fetchJSON("https://jsonplaceholder.typicode.com/posts/1"),
fetchJSON("https://jsonplaceholder.typicode.com/posts/99999"),
]);
console.log(hasilSemua.map((h) => h.status)); // → [ 'fulfilled', 'rejected' ]
Pencarian dengan debounce
// Debounce: tunggu pengguna berhenti mengetik 400 ms baru panggil API
function debounce(fn, jeda = 400) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), jeda);
};
}
const inputCari = document.querySelector("#cari-produk");
inputCari?.addEventListener(
"input",
debounce(async () => {
const q = encodeURIComponent(inputCari.value.trim());
if (!q) return;
const data = await fetchJSON(`https://dummyjson.com/products/search?q=${q}&limit=5`);
console.log(data.products.map((p) => p.title)); // → daftar judul produk yang cocok
})
);
// Mengetik "phone" huruf demi huruf → hanya 1 request, bukan 5
Tanpa debounce, mengetik "phone" mengirim lima request (p, ph, pho, ...). Itu membebani server dan bisa memicu error 429.
Kesalahan umum
- Menganggap
catchmenangkap 404/500.fetchhanya reject untuk kegagalan jaringan. Selalu cekres.okdanthrowsendiri. - Lupa
awaitpadares.json().const data = res.json(); data.titlebernilaiundefinedkarenadatamasih Promise. Tulisconst data = await res.json();. - POST tanpa
JSON.stringifyatau tanpa header. Mengirimbody: { title: "x" }akan terkirim sebagai"[object Object]". Body wajib string, dan sertakan"Content-Type": "application/json". - "Memperbaiki" CORS dari front-end. Menambah header
Access-Control-Allow-Origindi request atau memakaimode: "no-cors"tidak menyelesaikan apa pun. Izin CORS diberikan server; kalau server tidak mengizinkan, gunakan backend/proxy sendiri. - Tidak ada loading dan error state. Layar kosong saat memuat atau gagal membuat pengguna mengira aplikasimu rusak. Tangani empat keadaan: loading, success, empty, error.
Latihan
- Mudah, Ambil data
https://jsonplaceholder.typicode.com/users/3, lalu tampilkan nama, email, dan nama kota user tersebut di console dalam satu kalimat. - Sedang, Buat halaman yang menampilkan 10 post pertama sebagai kartu (judul + isi). Saat tombol "Lihat komentar" di sebuah kartu diklik, ambil
/posts/{id}/commentsdan tampilkan jumlah serta daftar email pengomentar di bawah kartu itu. Pakai event delegation dan tampilkan teks "Memuat..." selama request berjalan. - Menantang, Buat form "Tulis post" (judul, isi) yang mengirim POST ke JSONPlaceholder. Validasi judul minimal 5 karakter, nonaktifkan tombol saat mengirim, lalu tambahkan post baru di paling atas daftar dari soal 2 saat berhasil. Tampilkan pesan error kalau gagal.
Lihat solusi
// 1. Mudah
const u = await fetchJSON("https://jsonplaceholder.typicode.com/users/3");
console.log(`${u.name} (${u.email}) tinggal di ${u.address.city}`);
// → 'Clementine Bauch (Nathan@yesenia.net) tinggal di McKenziehaven'
<!-- 2 & 3 -->
<form id="form-post">
<input name="title" placeholder="Judul" required>
<textarea name="body" placeholder="Isi" required></textarea>
<button>Kirim</button>
<p id="pesan-post" role="status"></p>
</form>
<div id="daftar-post"></div>
const API = "https://jsonplaceholder.typicode.com";
const wadah = document.querySelector("#daftar-post");
function buatKartu(post) {
const artikel = document.createElement("article");
artikel.className = "kartu";
artikel.dataset.id = post.id;
const h3 = document.createElement("h3");
h3.textContent = post.title;
const p = document.createElement("p");
p.textContent = post.body;
const tombol = document.createElement("button");
tombol.textContent = "Lihat komentar";
tombol.dataset.aksi = "komentar";
const area = document.createElement("div");
area.className = "komentar";
artikel.append(h3, p, tombol, area);
return artikel;
}
// 2. Sedang
async function muatPosts() {
wadah.textContent = "Memuat post...";
try {
const posts = await fetchJSON(`${API}/posts?_limit=10`);
wadah.textContent = "";
wadah.append(...posts.map(buatKartu));
} catch (err) {
wadah.textContent = err.message;
}
}
wadah.addEventListener("click", async (e) => {
const tombol = e.target.closest('[data-aksi="komentar"]');
if (!tombol) return;
const kartu = tombol.closest(".kartu");
const area = kartu.querySelector(".komentar");
tombol.disabled = true;
area.textContent = "Memuat...";
try {
const komentar = await fetchJSON(`${API}/posts/${kartu.dataset.id}/comments`);
area.textContent = `${komentar.length} komentar dari: `;
const ul = document.createElement("ul");
for (const k of komentar) {
const li = document.createElement("li");
li.textContent = k.email;
ul.append(li);
}
area.append(ul);
} catch (err) {
area.textContent = err.message;
tombol.disabled = false; // izinkan coba lagi
}
});
muatPosts();
// 3. Menantang
const formPost = document.querySelector("#form-post");
const pesan = document.querySelector("#pesan-post");
formPost.addEventListener("submit", async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(formPost));
if (data.title.trim().length < 5) {
pesan.textContent = "Judul minimal 5 karakter.";
return;
}
const tombol = formPost.querySelector("button");
tombol.disabled = true;
tombol.textContent = "Mengirim...";
try {
const baru = await fetchJSON(`${API}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, userId: 1 }),
});
wadah.prepend(buatKartu(baru)); // paling atas
pesan.textContent = `Post terkirim (id ${baru.id}).`; // → 'Post terkirim (id 101).'
formPost.reset();
} catch (err) {
pesan.textContent = err.message;
} finally {
tombol.disabled = false;
tombol.textContent = "Kirim";
}
});
Satu catatan: post baru dari JSONPlaceholder selalu ber-id 101 dan tidak benar-benar tersimpan, jadi tombol "Lihat komentar" di kartu itu akan mengembalikan daftar kosong. Di API sungguhan, data akan tersimpan.
Ringkasan
- API web menerima request (URL, method, headers, body) dan membalas response (status, headers, body JSON). 2xx sukses, 4xx salah peminta, 5xx salah server.
fetchhanya reject saat jaringan gagal. Selalu cekres.ok, laluawait res.json().- POST/PATCH membutuhkan
method, headerContent-Type: application/json, danbody: JSON.stringify(data). - Tangani empat keadaan UI: loading, success, empty, error. Gunakan
finallyuntuk membereskan loading,AbortController/AbortSignal.timeoutuntuk membatalkan, dan debounce untuk pencarian. - CORS adalah izin dari server yang ditegakkan browser. Error CORS diperbaiki di server atau lewat proxy sendiri, bukan di kode
fetch. - Data dari API tetap data luar: render dengan
textContent, dan jangan taruh API key rahasia di front-end.