TanStack Query di Next.js App Router: Prefetch dan Hydrate

Foto oleh Nicola since 1972 on flickr
Panggilan fetch biasa di Server Component hanya mencakup render pertama, tidak ada mekanisme bawaan untuk refetch di latar belakang, invalidasi cache setelah mutasi, atau deduplikasi request antar komponen yang berdekatan. TanStack Query tetap mengelola data di sisi client setelah halaman dimuat, sementara App Router masih memungkinkan prefetch di server sehingga tampilan pertama tidak menampilkan spinner loading. Keduanya saling melengkapi, bukan saling menggantikan.
HydrationBoundary adalah komponen yang menerima state QueryClient yang sudah didehydrate dan diserialisasi dari Server Component, lalu memuatnya ke cache QueryClient milik browser sebelum komponen turunan apapun dirender. Tanpa ini, data yang sudah diprefetch di server tidak akan pernah mencapai cache di client, sehingga pemanggilan useQuery di Client Component di bawahnya akan memicu fetch baru sepenuhnya alih-alih membaca data yang sudah diambil.
Ya, dan ini adalah pola yang disarankan untuk query yang lambat. Panggil prefetchQuery tanpa await, atur dehydrate agar juga menyerialisasi query yang masih pending, dan promise yang sedang berjalan tersebut akan di-streaming ke client sebagai bagian dari response. Client Component yang menggunakan useSuspenseQuery pada key yang sama kemudian melanjutkan request yang sudah dimulai tersebut di dalam boundary Suspense alih-alih menembakkan request kedua.
Tetapkan staleTime yang tidak nol pada QueryClient yang digunakan untuk prefetch di server. staleTime default adalah nol, yang membuat data hasil hydrate langsung dianggap basi begitu tiba di browser, sehingga useQuery langsung melakukan refetch di latar belakang tepat setelah komponen dimount. Menyesuaikan staleTime dengan seberapa sering data sebenarnya berubah akan mencegah refetch yang tidak perlu ini.
Penyebab paling umum adalah queryKey di client yang tidak cocok persis dengan key yang digunakan saat prefetch di server, staleTime default yang masih nol, membuat instance QueryClient baru di dalam tree Client Component alih-alih menggunakan kembali instance yang sudah dihydrate, atau lupa membungkus tree tersebut dengan HydrationBoundary sama sekali. Memeriksa tab Network untuk mencari request duplikat dalam satu detik setelah halaman dimuat adalah cara tercepat untuk menemukan penyebabnya.

Foto oleh Nicola since 1972 on flickr
Next.js App Router sudah memungkinkan Server Component melakukan await pada fetch secara langsung, jadi wajar jika muncul pertanyaan mengapa perlu menambahkan TanStack Query sama sekali. Jawaban jujurnya adalah fetch saja hanya memberi data pada saat pemuatan pertama dan tidak lebih dari itu, tidak ada refetch di latar belakang, tidak ada invalidasi cache setelah mutasi, tidak ada deduplikasi request antar komponen yang berdekatan, dan tidak ada penanganan retry atau data basi secara otomatis di sisi client. Begitu sebuah halaman memiliki bagian yang interaktif, misalnya filter, form yang menyimpan lalu memuat ulang daftar, atau badge status yang harus berubah tanpa reload penuh, fetch biasa tidak lagi cukup dan anda akan berakhir menulis ulang logika cache yang sebenarnya sudah dipecahkan oleh TanStack Query sejak lama.
TanStack Query tetap memegang seluruh siklus hidup di sisi client tersebut, dan App Router memberikan cara yang bersih untuk mengisi cache-nya dari server sehingga tampilan pertama tidak pernah berupa spinner loading. Artikel ini membahas alur prefetch, dehydrate, dan hydrate secara lengkap untuk App Router, kapan pendekatan ini layak dipakai dibanding fetch biasa, dan kesalahan yang diam-diam membuat sebuah halaman mengambil data yang sama dua kali. Semua yang dibahas di sini mengasumsikan TanStack Query v5, versi yang pertama kali mendukung streaming query yang masih pending melalui boundary Suspense, bukan hanya mendukung prefetch yang sepenuhnya di-await.
Server rendering dengan TanStack Query adalah proses serah terima tiga langkah antara server dan browser. Setiap langkah memiliki satu tugas, dan melewatkan salah satunya akan memutus keseluruhan rangkaian. Cara yang lebih mudah untuk memahaminya adalah menganggapnya bukan sebagai fitur khusus App Router, melainkan sekadar meneruskan cache yang sudah dipanaskan sebagai sebuah prop, sama seperti meneruskan nilai hasil komputasi server lainnya ke Client Component.
QueryClient tidak boleh dijadikan singleton pada level module di server, karena setiap request akan berbagi cache yang sama untuk pengguna yang berbeda. Sebagai gantinya, bungkus factory client tersebut dengan fungsi cache dari React sehingga cakupannya terbatas pada satu request di server, dan dibuat sekali secara lazy di browser. Langkah ini juga menetapkan staleTime yang tidak nol, yang lebih penting dari kelihatannya, tanpa itu, query yang baru dihydrate langsung dianggap basi begitu tiba di browser, dan useQuery langsung menembakkan ulang request di latar belakang, sehingga tujuan prefetch menjadi sia-sia. Factory yang sama sebaiknya diimpor oleh setiap route yang membutuhkan prefetch, bukan setiap route mendefinisikan opsi QueryClient sendiri-sendiri, sehingga staleTime dan perilaku retry tetap konsisten di seluruh aplikasi.
// lib/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from "@tanstack/react-query"
import { cache } from "react"
// React's cache() scopes this to a single request on the server,
// and creates a fresh browser QueryClient the first time it runs on the client.
export const getQueryClient = cache(() => new QueryClient({
defaultOptions: {
queries: {
// Prefetched data stays "fresh" for 60s so the client
// does not immediately refetch on mount.
staleTime: 60 * 1000,
},
dehydrate: {
// Also dehydrate queries that are still pending, so we
// can stream them and resolve on the client (see section 4).
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
},
},
}))Tetapkan staleTime sesuai dengan seberapa sering data yang mendasarinya benar-benar berubah. Katalog produk mungkin masih wajar dengan lima menit, sementara halaman status pesanan mungkin membutuhkan sepuluh detik. Tidak ada angka yang benar secara universal.
Server Component melakukan prefetch dan membungkus komponen turunannya dengan HydrationBoundary, meneruskan state cache yang sudah didehydrate. Client Component di dalamnya memanggil useQuery dengan queryKey dan queryFn yang sama persis. Jika key tersebut cocok dengan yang diprefetch, useQuery membaca langsung dari cache yang sudah dihydrate dan langsung dirender tanpa request jaringan maupun status loading pada tampilan pertama. Perlu diperhatikan bahwa queryFn itu sendiri tetap harus ada di Client Component walaupun tidak akan dijalankan pada pemuatan pertama, karena TanStack Query membutuhkannya untuk refetch berikutnya, baik dipicu oleh window refocus, invalidate manual, maupun habisnya jendela waktu staleTime.
// app/[locale]/(pages)/orders/page.tsx — Server Component
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
import { getQueryClient } from "@/lib/get-query-client"
import { OrderList } from "./order-list"
import { getOrders } from "@/lib/orders"
export default async function OrdersPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ["orders"],
queryFn: getOrders,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<OrderList />
</HydrationBoundary>
)
}
// app/[locale]/(pages)/orders/order-list.tsx — Client Component
"use client"
import { useQuery } from "@tanstack/react-query"
import { getOrders } from "@/lib/orders"
export function OrderList() {
// Same queryKey and queryFn as the server prefetch — this
// reads the hydrated cache instead of firing a new request.
const { data: orders } = useQuery({
queryKey: ["orders"],
queryFn: getOrders,
})
return (
<ul>
{orders?.map((order) => <li key={order.id}>{order.reference}</li>)}
</ul>
)
}Array queryKey di Client Component harus cocok persis dengan key yang diprefetch, termasuk urutan argumen dan tipe nilainya. Key seperti ["orders", 1] di server dan ["orders", "1"] di client adalah dua entri cache yang berbeda, dan client akan diam-diam mengirim fetch baru sepenuhnya alih-alih membaca data yang sudah dihydrate.
Menunggu setiap panggilan prefetchQuery dengan await akan memblokir seluruh route sampai query paling lambat selesai, yang menghilangkan salah satu keunggulan terbaik App Router, yaitu streaming. Sebagai gantinya, cukup await query yang menentukan konten di atas lipatan layar, dan jalankan query yang lambat tanpa menunggunya. Konfigurasi dehydrate pada TanStack Query bisa diatur agar juga menyerialisasi query yang masih pending, sehingga promise yang sedang berjalan tersebut ikut di-streaming ke client. Client Component yang menggunakan useSuspenseQuery pada key yang sama kemudian cukup melanjutkan request yang sudah dimulai tersebut di dalam boundary Suspense, bukan memulai request baru. Ini adalah mekanisme yang sama dengan yang digunakan API use milik React sendiri untuk streaming sebuah promise sebagai prop, TanStack Query hanya membungkusnya dengan caching, retry, dan sisa siklus hidup query di atasnya.
// Streaming variant — do NOT await the slow query on the server.
export default async function DashboardPage() {
const queryClient = getQueryClient()
// Fast query: await it, it is ready before the HTML streams.
await queryClient.prefetchQuery({
queryKey: ["summary"],
queryFn: getSummary,
})
// Slow query: fire it but do not await. The pending promise is
// dehydrated and streamed to the client, which resumes it.
queryClient.prefetchQuery({
queryKey: ["annual-report"],
queryFn: getAnnualReport,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Summary />
<Suspense fallback={<ReportSkeleton />}>
<AnnualReport />
</Suspense>
</HydrationBoundary>
)
}
// Client Component — suspends on the still-resolving stream
"use client"
function AnnualReport() {
const { data } = useSuspenseQuery({
queryKey: ["annual-report"],
queryFn: getAnnualReport,
})
return <Report data={data} />
}Menggunakan TanStack Query pada setiap Server Component adalah pendekatan yang berlebihan. Gunakan fetch biasa atau panggilan database langsung ketika data hanya dibaca sekali per request dan tidak pernah disentuh oleh interaktivitas di sisi client. Gunakan TanStack Query ketika Client Component perlu melakukan refetch, polling, invalidasi setelah mutasi, atau berbagi query yang sama antar komponen yang tidak memiliki parent bersama untuk meneruskan props. Sinyal yang baik adalah apakah anda pernah perlu memanggil router.refresh hanya untuk menampilkan data baru, jika jawabannya ya, refresh tersebut sebenarnya adalah versi manual yang lebih buruk dari apa yang sudah dilakukan invalidateQueries.
| Situasi | Fetch biasa / Server Component async | TanStack Query dengan prefetch |
|---|---|---|
| Konten statis yang dirender sekali, tanpa interaksi client | Lebih sederhana, lebih sedikit dependency | Overhead yang tidak perlu |
| Data yang harus diinvalidasi oleh mutasi di halaman tersebut | Butuh refresh router manual atau reload penuh | invalidateQueries memperbarui cache secara instan |
| Data yang sama dibutuhkan di beberapa Client Component yang tidak berkaitan | Prop drilling atau panggilan fetch yang duplikat | Satu entri cache, dibagikan otomatis berdasarkan queryKey |
Aturan praktis yang bermanfaat: jika halaman tidak pernah perlu berkomunikasi lagi dengan server setelah render awal, kemungkinan besar anda tidak membutuhkan TanStack Query sama sekali untuk data tersebut.
Bug paling umum pada setup ini adalah halaman yang mengambil data yang sama dua kali, sekali di server saat prefetch, dan sekali lagi di client tepat saat komponen dimount. Biasanya penyebabnya salah satu dari empat hal berikut, dan keempatnya mudah diperbaiki begitu anda tahu apa yang harus dicari.
Buka tab Network saat menguji halaman yang menggunakan prefetch. Jika anda melihat request yang sama tertembak dua kali dalam satu detik setelah halaman dimuat, hampir selalu penyebabnya adalah queryKey yang tidak cocok atau staleTime default nol, periksa kedua hal ini terlebih dahulu sebelum yang lain.