Banyak developer yang sudah menggunakan TanStack Query (dulu React Query) selama berbulan-bulan, tapi masih menggunakannya persis seperti contoh di dokumentasi resmi — meletakkan useQuery langsung di dalam komponen dengan semua opsi hard-coded di sana. Itu bukan cara yang salah secara teknis, tapi ada pendekatan yang jauh lebih scalable yang tidak banyak dibahas.
Ada tiga hal yang secara konsisten membuat kode React Query lebih mudah dipelihara: memisahkan query options ke fungsi reusable, mengekstrak query key ke konstanta terpisah, dan memindahkan data fetching keluar dari komponen ke level navigasi. Ketiganya terdengar sederhana, tapi dampaknya terhadap skalabilitas dan performa aplikasi sangat nyata.
Masalah dengan Cara Paling Umum
Cara paling dasar menggunakan React Query terlihat seperti ini:
// pages/DashboardPage.tsx
function DashboardPage() {
const { data: user, isPending, isError } = useQuery({
queryKey: ["current-user"],
queryFn: getCurrentUser,
staleTime: 1000 * 60 * 5,
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Terjadi kesalahan</div>;
if (!user) redirect("/");
return <Dashboard user={user} />;
}
Kode ini sudah jauh lebih baik dari useEffect manual, tapi ada dua masalah nyata di sini.
Masalah pertama adalah duplikasi. Saat komponen lain — misalnya UserForm — juga butuh data current-user, kita terpaksa menyalin semua opsi yang sama: query key, query function, stale time. Dua tempat yang harus diperbarui setiap kali ada perubahan konfigurasi.
Masalah kedua adalah performa. Setiap komponen yang memanggil useQuery akan me-render setidaknya dua kali — pertama saat mount dengan data: undefined, lalu setelah fetch selesai. Dengan satu query saja ini mungkin tidak terasa, tapi komponen halaman yang realistis sering punya tiga hingga lima query. Itu berarti enam hingga sepuluh render yang masing-masing memicu render ulang semua child component di dalamnya.
Solusi Pertama: Query Options sebagai Fungsi Terpisah
Langkah paling penting yang bisa dilakukan adalah memisahkan query options ke dalam fungsi standalone — bukan custom hook.
Kebanyakan developer yang sadar akan masalah duplikasi akan langsung membuat custom hook seperti ini:
// hooks/useCurrentUser.ts
function useCurrentUser() {
return useQuery({
queryKey: ["current-user"],
queryFn: getCurrentUser,
staleTime: 1000 * 60 * 5,
});
}
Ini satu langkah lebih baik, tapi masih ada batasan: fungsi ini selalu terikat dengan useQuery. Kita tidak bisa menggunakannya dengan useSuspenseQuery, tidak bisa dipanggil di luar React component (misalnya di route loader), dan tidak bisa dipakai dengan queryClient.prefetchQuery.
Solusinya adalah menggunakan helper queryOptions bawaan TanStack Query:
// queries/auth-queries.ts
import { queryOptions } from "@tanstack/react-query";
import { getCurrentUser } from "@/lib/api";
export function currentUserQueryOptions() {
return queryOptions({
queryKey: ["current-user"],
queryFn: getCurrentUser,
staleTime: 1000 * 60 * 5,
});
}
Fungsi ini hanya mengembalikan konfigurasi query — tanpa hook, tanpa ketergantungan pada React. Karena itu, ia bisa dipakai di mana saja:
// Dengan useQuery biasa
const { data: user } = useQuery(currentUserQueryOptions());
// Dengan useSuspenseQuery
const { data: user } = useSuspenseQuery(currentUserQueryOptions());
// Di luar React component — di route loader
await queryClient.ensureQueryData(currentUserQueryOptions());
Alasan fungsi ini dibuat sebagai fungsi (bukan konstanta) adalah untuk mendukung argumen dinamis. Ketika fetching data berdasarkan ID misalnya, kita bisa tulis userQueryOptions(userId) dan pass ID tersebut ke query key dan query function sekaligus.
Solusi Kedua: Query Key Constants
Masalah tersembunyi berikutnya adalah query key yang masih hard-coded sebagai string literal di dalam fungsi options. Ketika mutation perlu menginvalidasi query, kita menulis:
queryClient.invalidateQueries({ queryKey: ["current-user"] });
String "current-user" ini harus cocok persis dengan yang ada di query options. Tidak ada type-checking, tidak ada autocomplete, dan jika ada typo, invalidasi tidak akan berjalan.
Solusinya elegan: ekstrak semua query key ke file konstanta tersendiri.
// queries/auth-keys.ts
export const authQueryKeys = {
currentUser: ["current-user"] as const,
session: ["session"] as const,
} as const;
Lalu gunakan konstanta ini di query options:
// queries/auth-queries.ts
import { queryOptions } from "@tanstack/react-query";
import { authQueryKeys } from "./auth-keys";
import { getCurrentUser } from "@/lib/api";
export function currentUserQueryOptions() {
return queryOptions({
queryKey: authQueryKeys.currentUser,
queryFn: getCurrentUser,
staleTime: 1000 * 60 * 5,
});
}
Dan di mutation, kita bisa import konstanta yang sama:
// components/UserForm.tsx
import { authQueryKeys } from "@/queries/auth-keys";
const updateUser = useMutation({
mutationFn: updateCurrentUser,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: authQueryKeys.currentUser,
});
},
});
Sekarang query key bisa di-rename dengan confidence karena TypeScript akan menangkap semua tempat yang menggunakannya.
Solusi Ketiga: Pindahkan Fetching ke Level Navigasi
Ini adalah perubahan yang paling berdampak, tapi juga yang paling jarang dilakukan. Idenya sederhana: data fetching tidak seharusnya terjadi di dalam komponen. Komponen seharusnya hanya bertanggung jawab untuk rendering UI.
Dengan TanStack Router (atau React Router v6+), kita bisa melakukan data fetching di loader — sebelum komponen pernah di-render:
// routes/dashboard.tsx
import { createFileRoute, redirect } from "@tanstack/react-router";
import { currentUserQueryOptions } from "@/queries/auth-queries";
export const Route = createFileRoute("/dashboard")({
loader: async ({ context: { queryClient } }) => {
const user = await queryClient.ensureQueryData(currentUserQueryOptions());
if (!user) {
throw redirect({ to: "/" });
}
},
component: DashboardPage,
});
Di sinilah manfaat currentUserQueryOptions() sebagai fungsi terpisah benar-benar terasa. Karena ia bukan hook, ia bisa dipanggil bebas di luar konteks React.
Loader ini berjalan sebelum komponen di-render. Data sudah ada di cache React Query ketika komponen akhirnya mount. Karena itu, komponen bisa langsung menggunakan useSuspenseQuery tanpa perlu handle state isPending atau isError:
// pages/DashboardPage.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import { currentUserQueryOptions } from "@/queries/auth-queries";
function DashboardPage() {
const { data: user } = useSuspenseQuery(currentUserQueryOptions());
// Tidak ada isPending, tidak ada isError, tidak ada redirect check
// User dijamin ada — loader sudah menangani semua kondisi edge case
return <Dashboard user={user} />;
}
Komponen menjadi sangat bersih. Satu baris untuk mengambil data, sisanya adalah UI.
Pola ini berlaku untuk TanStack Router dan React Router v6. Untuk Next.js App Router, ekuivalennya adalah memindahkan fetch ke Server Component — bukan ke Client Component — karena masalah intinya adalah fetching di dalam client component, bukan di component secara umum.
Berbagi Data di Antara Beberapa Komponen
Pertanyaan yang sering muncul: jika data sudah di-fetch di loader, mengapa masih perlu useSuspenseQuery di child component? Kenapa tidak pass data melalui props saja?
Jawabannya: biarkan setiap komponen yang butuh data mengambilnya sendiri dari cache React Query. Ini tidak menyebabkan fetch ulang — React Query akan mengembalikan data dari cache secara instan. Kita bisa bayangkan useSuspenseQuery seperti useContext: hanya mengakses nilai yang sudah ada, tanpa fetch baru.
// components/UserForm.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import { currentUserQueryOptions } from "@/queries/auth-queries";
function UserForm() {
// Data ini sudah ada di cache — tidak ada network request
const { data: user } = useSuspenseQuery(currentUserQueryOptions());
const updateUser = useMutation({
mutationFn: updateCurrentUser,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: authQueryKeys.currentUser,
});
},
});
if (!user) return null;
return (
<form onSubmit={() => updateUser.mutate({ name: user.name })}>
{/* form fields */}
</form>
);
}
Karena UserForm menggunakan useSuspenseQuery, ia perlu dibungkus Suspense di parent-nya:
// pages/DashboardPage.tsx
<Suspense fallback={<FormSkeleton />}>
<UserForm />
</Suspense>
Dengan TanStack Router, suspense boundary ini sudah ditangani otomatis oleh router.
Perbandingan Tiga Pendekatan
Berikut ringkasan perbedaan ketiga pendekatan yang sudah dibahas:
| Pendekatan | Reusable | Fleksibel | Render awal |
|---|---|---|---|
useQuery di komponen | Tidak | Tidak | 2+ render |
Custom hook useXxx | Ya | Terikat useQuery | 2+ render |
queryOptions + loader | Ya | Semua konteks | 1 render |
Kesimpulan
React Query bukan sekadar pengganti useEffect untuk fetching. Kekuatan sesungguhnya muncul ketika query options diperlakukan sebagai unit yang bisa dikomposisi — dipisah dari hook, dipakai ulang di mana saja, dan dikombinasikan dengan sistem navigasi untuk memindahkan fetching keluar dari component tree. Untuk memahami pola React lebih lanjut, eksplorasi 4 React Hook Patterns yang Wajib Dikuasai Developer bisa menjadi langkah berikutnya.