Kenapa ini penting
Di dua modul sebelumnya kita membuat instance axios dan interceptor. Sekarang pertanyaannya: di mana semua itu diletakkan supaya rapi? Dan bagaimana kalau alamat API saat development (http://localhost:3000) berbeda dengan saat produksi (https://api.tokokamu.com)? Mengganti URL manual setiap mau deploy adalah resep bencana.
Modul ini menyatukan semuanya: satu API client terpusat, environment variable Vite untuk konfigurasi per lingkungan, dan pola menghubungkan axios dengan TanStack Query sehingga komponen tetap bersih.
Konsep inti
Struktur folder yang disarankan
src/
├── lib/
│ ├── api.js ← instance axios + interceptor (satu-satunya tempat)
│ └── queryClient.js ← konfigurasi QueryClient
├── features/
│ └── todos/
│ ├── todoApi.js ← fungsi request khusus todos
│ └── useTodos.js ← custom hook TanStack Query
└── components/
Analogi: api.js itu seperti dapur pusat restoran waralaba. Semua cabang (fitur) memesan bahan dari dapur pusat dengan standar yang sama. Kalau resep bumbu diubah (misalnya cara menempel token), cukup ubah di dapur pusat.
Ada tiga lapis tanggung jawab:
api.js, cara berbicara dengan server (baseURL, timeout, token, error).todoApi.js, apa yang diminta (endpoint, params), mengembalikan data bersih.useTodos.js, kapan dan bagaimana data di-cache (queryKey, staleTime, mutation).
Komponen hanya memanggil hook di lapis ketiga.
Environment variable di Vite
Vite membaca file .env di root proyek. Hanya variabel berawalan VITE_ yang diekspos ke kode browser, lewat import.meta.env:
# .env (dipakai di semua mode)
VITE_APP_NAME=Dashboard Tugas
# .env.development (dipakai saat npm run dev)
VITE_API_URL=https://jsonplaceholder.typicode.com
# .env.production (dipakai saat npm run build)
VITE_API_URL=https://jsonplaceholder.typicode.com
import.meta.env.VITE_API_URL; // → "https://jsonplaceholder.typicode.com"
import.meta.env.MODE; // → "development" atau "production"
import.meta.env.DEV; // → true saat npm run dev
import.meta.env.PROD; // → true di hasil build
Hal yang perlu diingat:
- Nilai env dibaca saat build dan ditanam ke file JavaScript. Mengubah
.envbutuh restartnpm run devatau build ulang. - Semua variabel
VITE_bisa dilihat siapa pun yang membuka DevTools. Jangan taruh rahasia (secret key, password database) di sana. Kunci rahasia hanya boleh ada di server. - File
.env.localdan.env.*.localuntuk pengaturan pribadi; template Vite sudah memasukkan*.localke.gitignore. - Semua nilai env berupa string.
VITE_TIMEOUT=10000dibaca sebagai"10000", jadi ubah denganNumber(). - Di Vercel/Netlify, variabel env diatur di dashboard proyek (bukan dari file
.envyang tidak di-commit).
Integrasi axios dengan TanStack Query
TanStack Query tidak peduli kamu memakai fetch atau axios: queryFn cukup mengembalikan Promise yang berisi data, atau melempar error kalau gagal. axios cocok sekali karena sudah melempar error untuk status 4xx/5xx.
Ada juga fitur pembatalan: TanStack Query memberikan signal (AbortSignal) ke queryFn. Kalau komponen hilang sebelum request selesai atau query dibatalkan, request bisa ikut dibatalkan. axios mendukung opsi signal:
queryFn: ({ signal }) => api.get("/todos", { signal }).then((r) => r.data),
Query key factory
Supaya key tidak salah ketik di banyak tempat, kumpulkan dalam satu object:
export const todoKeys = {
all: ["todos"],
list: (filter) => ["todos", "list", filter],
detail: (id) => ["todos", "detail", id],
};
Lalu invalidateQueries({ queryKey: todoKeys.all }) otomatis menyentuh semua list dan detail.
Alur lengkap sebuah request
Supaya gambaran besarnya jelas, ikuti perjalanan satu klik "centang tugas":
- Komponen memanggil
update.mutate({ id, completed })dari hookuseUpdateTodo. - Hook menjalankan
patchTododitodoApi.js, yang memanggilapi.patch(...). - Request interceptor di
api.jsmenempelkan token, lalu request berangkat keVITE_API_URL. - Server membalas. Response interceptor menerjemahkan error (kalau ada) menjadi pesan ramah.
- Kembali di hook,
onSuccessmemperbarui cache; semua komponen yang memakai key terkait otomatis menampilkan data baru.
Setiap lapis punya satu tugas. Kalau suatu hari API pindah alamat, kamu mengubah satu baris di .env. Kalau cara autentikasi berubah, kamu mengubah satu interceptor. Kalau desain daftar berubah, kamu hanya menyentuh komponen. Inilah yang dimaksud kode yang mudah dirawat.
Contoh nyata: banyak tim memakai tiga lingkungan, development (laptop), staging (server uji), dan production. Dengan pola ini, aplikasi yang sama cukup di-build dengan .env berbeda, tanpa satu baris kode pun berubah.
Contoh kode
# .env.development
VITE_API_URL=https://jsonplaceholder.typicode.com
VITE_API_TIMEOUT=10000
// src/lib/api.js
import axios from "axios";
const baseURL = import.meta.env.VITE_API_URL;
if (!baseURL) {
// gagal cepat dengan pesan jelas, daripada request ke "undefined/todos"
throw new Error("VITE_API_URL belum diatur di file .env");
}
export const api = axios.create({
baseURL,
timeout: Number(import.meta.env.VITE_API_TIMEOUT ?? 10000),
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
if (import.meta.env.DEV) console.debug(`[api] ${config.method?.toUpperCase()} ${config.url}`);
return config;
});
api.interceptors.response.use(
(res) => res,
(error) => {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 404) error.message = "Data tidak ditemukan.";
else if (status >= 500) error.message = "Server sedang bermasalah.";
else if (!error.response) error.message = "Tidak bisa terhubung ke server.";
}
return Promise.reject(error);
}
);
// src/lib/queryClient.js
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: (jumlahGagal, error) => {
const status = error?.response?.status;
if (status && status < 500) return false; // 4xx tidak perlu dicoba ulang
return jumlahGagal < 2;
},
refetchOnWindowFocus: import.meta.env.PROD, // saat dev tidak refetch tiap pindah tab
},
},
});
// src/features/todos/todoApi.js
import { api } from "../../lib/api";
export const todoKeys = {
all: ["todos"],
list: (params) => ["todos", "list", params],
detail: (id) => ["todos", "detail", id],
};
export async function fetchTodos(params, signal) {
const { data } = await api.get("/todos", { params, signal });
return data;
}
export async function fetchTodo(id, signal) {
const { data } = await api.get(`/todos/${id}`, { signal });
return data;
}
export async function patchTodo({ id, ...perubahan }) {
const { data } = await api.patch(`/todos/${id}`, perubahan);
return data;
}
// src/features/todos/useTodos.js
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchTodo, fetchTodos, patchTodo, todoKeys } from "./todoApi";
export function useTodos(params = { userId: 1, _limit: 10 }) {
return useQuery({
queryKey: todoKeys.list(params),
queryFn: ({ signal }) => fetchTodos(params, signal),
});
}
export function useTodo(id) {
return useQuery({
queryKey: todoKeys.detail(id),
queryFn: ({ signal }) => fetchTodo(id, signal),
enabled: Boolean(id), // jangan jalan kalau id belum ada
});
}
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: patchTodo,
onSuccess: (todoBaru) => {
// perbarui semua list yang memuat todo ini (jsonplaceholder tidak menyimpan, jadi tidak invalidate)
queryClient.setQueriesData({ queryKey: ["todos", "list"] }, (lama) =>
Array.isArray(lama) ? lama.map((t) => (t.id === todoBaru.id ? todoBaru : t)) : lama
);
queryClient.setQueryData(todoKeys.detail(todoBaru.id), todoBaru);
},
});
}
// src/features/todos/TodoList.jsx, komponen jadi sangat bersih
import { useTodos, useUpdateTodo } from "./useTodos";
export function TodoList() {
const { data: todos = [], isPending, isError, error } = useTodos();
const update = useUpdateTodo();
if (isPending) return <p>Memuat...</p>;
if (isError) return <p className="text-red-600">{error.message}</p>;
return (
<ul className="space-y-1">
{todos.map((t) => (
<li key={t.id}>
<label className="flex gap-2">
<input
type="checkbox"
checked={t.completed}
onChange={() => update.mutate({ id: t.id, completed: !t.completed })}
/>
{t.title}
</label>
</li>
))}
</ul>
);
}
// → 10 tugas milik user 1. Mencentang memanggil PATCH lalu memperbarui cache list dan detail.
Kesalahan umum
- Nama variabel env tanpa awalan
VITE_.API_URL=...tidak akan terbaca (undefined) di kode browser. WajibVITE_API_URL. - Menaruh secret di
.envfrontend. SemuaVITE_ikut ter-bundle dan bisa dibaca publik. Kunci rahasia hanya di backend. - Lupa restart dev server setelah mengubah
.env. Nilai lama tetap dipakai sampai server di-restart. - Lupa mengatur env di dashboard hosting. Build di Vercel/Netlify gagal atau memakai
undefinedkarena file.envlokal tidak ikut di-upload (atau tidak di-commit). - Membuat instance axios di banyak file. Interceptor jadi tidak konsisten. Satu instance di
lib/api.js, semua fitur mengimpornya.
Latihan
- Mudah, Tambahkan
VITE_APP_NAMEdi.envdan tampilkan di judul halaman (komponen header). - Sedang, Buat
userApi.jsdanuseUsers.jsmengikuti pola todos:userKeys,fetchUsers, dan hookuseUsers()yang mengambil/users. - Menantang, Buat hook
useTodo(id)dipakai di komponen detail, dan aturinitialDatadari cache list supaya detail tampil instan saat diklik dari daftar (petunjuk:queryClient.getQueriesData).
Lihat solusi
// 1.
// .env → VITE_APP_NAME=Dashboard Tugas
export function Header() {
return <h1 className="text-xl font-bold">{import.meta.env.VITE_APP_NAME}</h1>;
}
// → Dashboard Tugas
// 2. src/features/users/userApi.js
import { api } from "../../lib/api";
export const userKeys = { all: ["users"], detail: (id) => ["users", id] };
export async function fetchUsers(signal) {
const { data } = await api.get("/users", { signal });
return data;
}
// src/features/users/useUsers.js
import { useQuery } from "@tanstack/react-query";
import { fetchUsers, userKeys } from "./userApi";
export function useUsers() {
return useQuery({
queryKey: userKeys.all,
queryFn: ({ signal }) => fetchUsers(signal),
staleTime: 10 * 60 * 1000, // data user jarang berubah
});
}
// 3. initialData dari cache list
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchTodo, todoKeys } from "./todoApi";
export function useTodo(id) {
const queryClient = useQueryClient();
return useQuery({
queryKey: todoKeys.detail(id),
queryFn: ({ signal }) => fetchTodo(id, signal),
enabled: Boolean(id),
initialData: () => {
// cari di semua list yang sudah ada di cache
for (const [, list] of queryClient.getQueriesData({ queryKey: ["todos", "list"] })) {
const ketemu = Array.isArray(list) ? list.find((t) => t.id === id) : undefined;
if (ketemu) return ketemu;
}
return undefined; // tidak ada → fetch seperti biasa
},
});
}
// → Klik tugas dari daftar: detail langsung tampil tanpa "Memuat...".
Ringkasan
- Satu instance axios di
lib/api.js; fungsi endpoint ditodoApi.js; hook TanStack Query diuseTodos.js; komponen hanya memakai hook. - Env Vite: hanya awalan
VITE_yang terbaca lewatimport.meta.env; nilainya string dan ditanam saat build. - Jangan pernah menaruh secret di env frontend; atur env juga di dashboard hosting.
- axios cocok dengan TanStack Query karena melempar error otomatis; teruskan
signaluntuk pembatalan. - Query key factory (
todoKeys) mencegah salah ketik dan memudahkan invalidasi.