Kenapa ini penting
Bayangkan setiap request ke API butuh token login di header Authorization. Kalau ada 30 endpoint, kamu harus menulis header itu 30 kali, dan satu saja terlupa, user mendapat error aneh. Lalu, kalau token kedaluwarsa dan server membalas 401, aplikasi harus mengeluarkan user di mana pun request itu terjadi.
Interceptor adalah "pos pemeriksaan" yang dilewati setiap request sebelum berangkat dan setiap respons sebelum sampai ke kodemu. Dengan interceptor, logika seperti menempel token, menangani 401, mencatat error, dan retry cukup ditulis sekali.
Analogi: interceptor itu seperti satpam di pintu gedung kantor. Setiap tamu yang keluar dicek sudah membawa kartu identitas (request interceptor). Setiap paket yang masuk diperiksa dulu, kalau paketnya rusak atau salah alamat, satpam yang memutuskan apa tindakannya (response interceptor).
Konsep inti
Request interceptor
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config; // WAJIB dikembalikan
},
(error) => Promise.reject(error)
);
Fungsi pertama menerima config (berisi url, method, headers, dll.), boleh mengubahnya, lalu harus mengembalikannya. Kalau lupa return config, request tidak akan terkirim dengan benar.
Response interceptor
api.interceptors.response.use(
(response) => response, // status 2xx lewat sini
(error) => {
// status di luar 2xx, timeout, atau jaringan putus lewat sini
return Promise.reject(error);
}
);
Fungsi kedua menerima error. Kamu bisa menanganinya (logout, tampilkan notifikasi) lalu tetap Promise.reject(error) supaya kode pemanggil (misalnya TanStack Query) tahu request gagal.
Anatomi error axios
| Properti | Isi |
|---|---|
error.response |
Ada kalau server membalas. Berisi status, data, headers |
error.request |
Ada kalau request terkirim tapi tidak ada balasan (server mati, jaringan putus) |
error.code |
Kode seperti "ECONNABORTED" (timeout) atau "ERR_NETWORK" |
error.message |
Pesan bawaan, misalnya "timeout of 500ms exceeded" |
error.config |
Konfigurasi request yang gagal, berguna untuk retry |
Gunakan axios.isAxiosError(error) untuk memastikan error berasal dari axios, bukan bug JavaScript lain.
Menangani status penting
| Status / kasus | Arti | Tindakan umum |
|---|---|---|
| 400 / 422 | Data yang dikirim tidak valid | Tampilkan pesan validasi dari server di form |
| 401 | Belum login atau token kedaluwarsa | Hapus token, arahkan ke halaman login |
| 403 | Login, tapi tidak punya izin | Tampilkan "Akses ditolak" |
| 404 | Data tidak ditemukan | Tampilkan halaman/pesan "tidak ditemukan" |
| 500 / 502 / 503 | Kesalahan server | Pesan "Server sedang bermasalah, coba lagi" + boleh retry |
Timeout (ECONNABORTED) |
Server terlalu lama | Pesan "Koneksi lambat" + boleh retry |
Tanpa response |
Jaringan putus | Pesan "Periksa koneksi internet" |
Timeout
timeout (milidetik) di config membatalkan request yang terlalu lama. Default axios adalah 0 artinya tanpa batas, request bisa menggantung selamanya. Selalu atur timeout yang masuk akal, misalnya 10 detik.
Retry
Retry artinya mencoba ulang request yang gagal. Aturannya:
- Retry hanya untuk kegagalan sementara: timeout, jaringan, 5xx. Jangan retry 400, 401, 403, 404, hasilnya akan sama.
- Hati-hati me-retry POST: bisa membuat data ganda. Paling aman retry GET.
- Beri jeda yang makin lama (exponential backoff), misalnya 500 ms, 1000 ms, 2000 ms.
Kalau kamu memakai TanStack Query, ia sudah melakukan retry untuk query (default 3 kali). Jadi untuk GET yang lewat useQuery, kamu cukup mengatur opsi retry di sana, dan tidak perlu retry di interceptor juga, kalau keduanya aktif, satu kegagalan bisa menjadi belasan request.
Contoh kode
// src/lib/api.js, instance dengan interceptor lengkap
import axios from "axios";
export const api = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
timeout: 10000,
});
// 1) Request interceptor: tempel token
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 2) Ubah error mentah menjadi pesan yang ramah
export function pesanError(error) {
if (!axios.isAxiosError(error)) return "Terjadi kesalahan tak terduga.";
if (error.code === "ECONNABORTED") return "Server terlalu lama merespons. Coba lagi.";
if (!error.response) return "Tidak bisa terhubung. Periksa koneksi internetmu.";
const { status, data } = error.response;
if (status === 401) return "Sesi kamu berakhir. Silakan login lagi.";
if (status === 403) return "Kamu tidak punya akses untuk tindakan ini.";
if (status === 404) return "Data tidak ditemukan.";
if (status >= 500) return "Server sedang bermasalah. Coba beberapa saat lagi.";
return data?.message ?? `Permintaan gagal (HTTP ${status}).`;
}
// 3) Response interceptor: tangani 401 secara global + retry GET untuk error sementara
const tunggu = (ms) => new Promise((r) => setTimeout(r, ms));
api.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
const status = error.response?.status;
if (status === 401) {
localStorage.removeItem("token");
window.location.assign("/login"); // arahkan ke halaman login
return Promise.reject(error);
}
const sementara = !error.response || error.code === "ECONNABORTED" || status >= 500;
const bolehRetry = config && config.method === "get" && !config.skipRetry;
if (sementara && bolehRetry) {
config.__retryCount = (config.__retryCount ?? 0) + 1;
if (config.__retryCount <= 2) {
await tunggu(500 * 2 ** (config.__retryCount - 1)); // 500 ms, lalu 1000 ms
return api(config); // kirim ulang request yang sama
}
}
// ubah pesan supaya komponen tinggal menampilkan error.message
error.message = pesanError(error);
return Promise.reject(error);
}
);
// src/ErrorDemo.jsx
import { useState } from "react";
import { api } from "./lib/api";
export default function ErrorDemo() {
const [pesan, setPesan] = useState("");
async function coba(path, config) {
try {
const { data } = await api.get(path, config);
setPesan(`Sukses: ${JSON.stringify(data).slice(0, 60)}...`);
} catch (err) {
setPesan(err.message);
}
}
return (
<div className="space-y-2 p-6">
<button onClick={() => coba("/todos/1")}>Request normal</button>
<button onClick={() => coba("/todos/999999")}>Request 404</button>
<button onClick={() => coba("/todos", { timeout: 1 })}>Request timeout</button>
<p>{pesan}</p>
</div>
);
}
// → Request normal : Sukses: {"userId":1,"id":1,"title":"delectus aut autem",...
// → Request 404 : Data tidak ditemukan.
// → Request timeout: (dicoba ulang 2 kali) lalu "Server terlalu lama merespons. Coba lagi."
Perhatikan bahwa retry di contoh ini memakai config.timeout yang sama (1 ms), sehingga ketiganya gagal, persis seperti yang kita harapkan untuk demo. Opsi skipRetry adalah properti buatan kita sendiri; axios meneruskan properti config tambahan ke interceptor.
Kesalahan umum
- Lupa
return configdi request interceptor. Request gagal dengan error yang membingungkan. Selalu kembalikan config. - Menelan error di response interceptor. Kalau kamu
returnsesuatu alih-alihPromise.reject(error), pemanggil mengira request sukses dan TanStack Query tidak masuk status error. - Retry tanpa batas. Tanpa penghitung, request gagal bisa diulang terus-menerus dan membebani server. Selalu beri batas dan jeda.
- Retry semua status. Me-retry 401 atau 404 hanya membuang waktu. Retry hanya kegagalan sementara.
- Retry ganda. Retry di interceptor ditambah retry TanStack Query membuat satu kegagalan menjadi banyak request. Pilih salah satu tempat untuk GET yang lewat
useQuery.
Latihan
- Mudah, Tambahkan request interceptor yang menempelkan header
Accept-Language: idke setiap request. - Sedang, Tambahkan request interceptor yang mencatat
METHOD urldi console, dan response interceptor yang mencatat lama request dalam milidetik. - Menantang, Ubah interceptor 401 supaya tidak memakai
localStoragelangsung, melainkan memanggiluseAuthStore.getState().logout()dari Zustand (modul Middleware). Jelaskan kenapagetState()bisa dipakai di file non-komponen.
Lihat solusi
// 1.
api.interceptors.request.use((config) => {
config.headers["Accept-Language"] = "id";
return config;
});
// 2. Logging + durasi
api.interceptors.request.use((config) => {
config.metadata = { mulai: performance.now() };
console.log(`${config.method.toUpperCase()} ${config.baseURL}${config.url}`);
return config;
});
api.interceptors.response.use(
(response) => {
const durasi = Math.round(performance.now() - response.config.metadata.mulai);
console.log(`selesai dalam ${durasi} ms`);
return response;
},
(error) => Promise.reject(error)
);
// → GET https://jsonplaceholder.typicode.com/todos/1
// → selesai dalam 143 ms
// 3. Integrasi dengan Zustand
import { useAuthStore } from "../stores/useAuthStore";
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token; // baca tanpa hook
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(r) => r,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout(); // persist ikut menghapus dari localStorage
}
return Promise.reject(error);
}
);
// getState() bisa dipakai di luar komponen karena store Zustand adalah object biasa
// di level modul; hook hanya diperlukan kalau komponen ingin re-render saat state berubah.
Ringkasan
- Interceptor = pos pemeriksaan untuk setiap request (sebelum berangkat) dan respons (sebelum sampai).
- Request interceptor cocok untuk menempel token; selalu
return config. - Response interceptor cocok untuk menangani 401 global dan menerjemahkan error; selalu
Promise.reject(error)di akhir. - Bedakan
error.response(server membalas), tanpa response (jaringan), danECONNABORTED(timeout). - Atur
timeout(default axios 0 = tanpa batas); retry hanya kegagalan sementara, dengan batas dan jeda, dan jangan dobel dengan retry TanStack Query.