Hydration mismatch di React SSR terjadi ketika output HTML yang dikirim server berbeda dari hasil render awal React di browser. Akibatnya, Anda bisa melihat warning hydration, UI berubah setelah mount, event handler terasa tidak konsisten, atau state awal di client tidak cocok dengan yang dipakai saat server merender halaman.
Masalah ini makin sering muncul pada framework full-stack modern yang menggabungkan routing, data loading, dan rendering server/client dalam satu alur, termasuk pendekatan seperti yang populer di ekosistem React modern dan full-stack router. Intinya tetap sama: render pertama di server dan render pertama di client harus menghasilkan markup yang konsisten. Jika ada nilai yang hanya tersedia di browser, berubah karena waktu, atau bergantung pada URL yang dibaca dengan cara berbeda, mismatch mudah terjadi.
Apa yang Sebenarnya Terjadi Saat Hydration
Pada SSR, server mengirim HTML yang sudah jadi. Setelah itu, JavaScript di browser memuat komponen React yang sama dan mencoba hydrate HTML tersebut, yaitu menyambungkan event handler dan state ke markup yang sudah ada, bukan merender ulang dari nol.
Hydration akan mulus jika hasil render awal di browser sama dengan HTML dari server. Jika tidak sama, React dapat menampilkan warning, membuang sebagian subtree, atau melakukan render ulang pada bagian tertentu. Dalam kasus ringan, gejalanya hanya teks berubah setelah mount. Dalam kasus yang lebih sulit dilacak, komponen terlihat benar tetapi perilaku interaksinya aneh karena struktur DOM sempat berbeda.
Prinsip utamanya: semua nilai yang memengaruhi markup awal harus deterministik dan tersedia dengan cara yang sama pada server dan client.
Gejala Nyata Hydration Mismatch di React SSR
1. Warning hydration di console
Gejala paling jelas adalah warning seperti teks tidak cocok, atribut berbeda, atau tree hydration gagal. Ini tanda bahwa HTML server dan hasil render awal client tidak identik.
2. UI berubah tepat setelah mount
Misalnya server menampilkan Login, lalu sesaat setelah JavaScript aktif berubah menjadi Dashboard. Perubahan semacam ini tidak selalu salah, tetapi jika terjadi pada render pertama tanpa strategi yang jelas, biasanya ada mismatch state awal.
3. Event handler terasa aneh
Tombol terlihat benar tetapi klik tidak bereaksi seperti yang diharapkan, atau elemen yang seharusnya ada ternyata diganti setelah hydration. Ini bisa terjadi jika React harus menyesuaikan ulang subtree akibat markup awal yang berbeda.
4. State awal berbeda antara server dan client
Contoh umum: server menganggap sidebar tertutup, client membaca preferensi dari localStorage lalu langsung menganggap sidebar terbuka. Hasilnya, class CSS, struktur DOM, atau teks awal menjadi berbeda.
Akar Masalah Umum
Akses window, document, dan localStorage
Objek browser seperti window dan localStorage tidak tersedia saat render di server. Masalahnya bukan hanya error runtime. Bahkan jika Anda memberi guard seperti typeof window !== 'undefined', hasil render server dan client bisa tetap berbeda jika nilai dari browser dipakai untuk membentuk markup awal.
Contoh bermasalah:
function Sidebar() {
const [open] = React.useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('sidebar-open') === '1'
}
return false
})
return <aside className={open ? 'open' : 'closed'}>...</aside>
}Di server, open selalu false. Di browser, pada render pertama bisa menjadi true. Markup awal pun berbeda.
Pola aman: gunakan initial state yang stabil, lalu sinkronkan nilai browser setelah mount.
function Sidebar() {
const [open, setOpen] = React.useState(false)
React.useEffect(() => {
const saved = localStorage.getItem('sidebar-open') === '1'
setOpen(saved)
}, [])
return <aside className={open ? 'open' : 'closed'}>...</aside>
}Trade-off-nya: ada kemungkinan perubahan visual setelah mount. Jika ini mengganggu, kirim preferensi tersebut dari server melalui cookie, session, atau data loader agar server dan client memulai dari nilai yang sama.
Pembacaan URL dan search params
URL sering dipakai untuk menentukan tab aktif, filter, modal terbuka, atau query pencarian. Mismatch muncul jika server dan client membaca sumber yang berbeda, atau jika parsing dilakukan langsung di render dengan API yang hanya ada di browser.
Contoh bermasalah:
function ProductList() {
const sort = new URLSearchParams(window.location.search).get('sort') || 'popular'
return <p>Urutan: {sort}</p>
}Di server, kode ini tidak bisa dipakai. Jika diganti dengan fallback yang berbeda, hasil render server dan client tetap bisa tidak sama.
Pola yang lebih aman:
- Jika framework Anda menyediakan akses URL pada SSR, baca search params di layer loader/router/server lalu kirim sebagai props atau data loader.
- Jika nilai URL hanya relevan di client, render placeholder yang stabil dulu, lalu update setelah mount.
Contoh dengan props dari server/data loader:
function ProductList({ initialSort }) {
const [sort, setSort] = React.useState(initialSort)
return <p>Urutan: {sort}</p>
}Pendekatan ini cocok untuk full-stack framework modern karena URL biasanya memang bagian dari state routing yang bisa diakses di server dan client secara konsisten.
Waktu, timezone, locale, dan format tanggal
Nilai waktu sangat mudah membuat mismatch. new Date(), toLocaleString(), atau format berbasis locale dapat menghasilkan output yang berbeda antara server dan browser, terutama jika timezone atau locale tidak sama.
Contoh yang sering memicu mismatch:
function Clock() {
return <span>{new Date().toLocaleTimeString()}</span>
}Server merender jam pada detik tertentu. Browser meng-hydrate beberapa saat kemudian dengan detik berbeda. Teks tidak lagi sama.
Pilihan aman:
- Kirim string waktu yang sudah diformat dari server dan gunakan apa adanya saat hydration.
- Untuk nilai yang memang harus real-time, render placeholder stabil lalu update di
useEffect. - Jika format locale penting, pastikan locale/timezone yang dipakai konsisten, atau tunda formatting ke client dengan fallback yang tidak memicu mismatch.
function Clock() {
const [time, setTime] = React.useState('—')
React.useEffect(() => {
setTime(new Date().toLocaleTimeString())
}, [])
return <span>{time}</span>
}Random value dan ID yang tidak stabil
Penggunaan Math.random(), generator ID acak, atau pemilihan data acak saat render akan menghasilkan output berbeda antara server dan client.
Contoh bermasalah:
function BannerVariant() {
const variant = Math.random() > 0.5 ? 'A' : 'B'
return <div>Banner {variant}</div>
}Jika butuh variasi, tentukan nilainya di server lalu kirim ke client, atau gunakan state yang diinisialisasi setelah mount jika memang harus murni client-side.
Untuk ID elemen yang dipakai pada aksesibilitas atau relasi label/input, gunakan mekanisme ID yang stabil dari React atau kirim ID dari parent/data, bukan membuat random value saat render.
Feature flag dan personalisasi
Feature flag sering terlihat aman, tetapi bisa menyebabkan mismatch jika evaluasinya berbeda antara server dan client. Misalnya server belum tahu preferensi user tertentu, tetapi client membaca token atau storage lalu menyalakan fitur pada render pertama.
Solusi yang lebih kuat:
- Evaluasi flag di server jika memungkinkan, lalu kirim hasilnya ke client.
- Jika flag hanya tersedia di client, render fallback yang stabil terlebih dahulu.
- Hindari mengubah struktur DOM besar pada render pertama hanya karena flag client-only.
Conditional render client-only
Pola seperti if (typeof window === 'undefined') return null sering dipakai untuk mematikan komponen tertentu di server. Ini boleh, tetapi perlu dipahami konsekuensinya: server tidak merender apa-apa, browser langsung merender sesuatu saat hydration. Jika subtree itu berada di tengah struktur penting, mismatch atau layout shift bisa terjadi.
Contoh yang rawan:
function UserMenu() {
if (typeof window === 'undefined') return null
return <div>Menu user interaktif</div>
}Versi yang lebih aman:
function ClientOnly({ children, fallback = null }) {
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
return mounted ? children : fallback
}
function UserMenu() {
return (
<ClientOnly fallback={<div className="menu-skeleton">...</div>}>
<div>Menu user interaktif</div>
</ClientOnly>
)
}Dengan pola ini, server dan client sama-sama merender fallback yang stabil terlebih dahulu. Setelah mount, baru komponen interaktif tampil.
Pola Aman untuk Initial State
1. Ambil nilai awal dari server bila memengaruhi markup
Jika state awal menentukan teks, class, atribut, atau struktur DOM, sumber terbaik adalah data yang sudah diketahui server: cookie, session, header, hasil loader, atau parameter route/search params yang diekstrak di server.
Ini paling cocok untuk:
- tema terang/gelap
- sidebar terbuka/tertutup
- tab atau filter dari URL
- status login yang memengaruhi navigasi
- feature flag berbasis user
2. Gunakan fallback deterministik, lalu sinkronkan di useEffect
Jika nilai hanya tersedia di browser, buat render awal yang netral dan stabil. Setelah mount, baca nilai browser lalu update state.
Ini cocok untuk:
localStorage- ukuran viewport
- media query di browser
- API browser seperti geolocation
- waktu real-time yang tidak harus akurat saat SSR
3. Jangan hitung nilai nondeterministik di body render
Hindari memanggil new Date(), Math.random(), atau membaca environment client-only langsung di render jika hasilnya memengaruhi output HTML awal.
4. Pisahkan state data dari state presentasi
Sering kali yang memicu mismatch bukan datanya, melainkan cara data tersebut mengubah struktur DOM. Jika memungkinkan, render struktur yang stabil dahulu, lalu ubah perilaku atau presentasi minor setelah mount.
Before/After: Kasus Nyata yang Sering Muncul
Kasus 1: Tema dari localStorage
Sebelum:
function ThemeLabel() {
const [theme] = React.useState(() => localStorage.getItem('theme') || 'light')
return <span>Tema: {theme}</span>
}Ini berisiko error di server atau mismatch jika server memakai default berbeda.
Sesudah, versi aman minimal:
function ThemeLabel() {
const [theme, setTheme] = React.useState('light')
React.useEffect(() => {
setTheme(localStorage.getItem('theme') || 'light')
}, [])
return <span>Tema: {theme}</span>
}Sesudah, versi lebih baik: kirim tema dari server lewat cookie agar server dan client memulai dari nilai yang sama.
Kasus 2: Search params menentukan tab aktif
Sebelum:
function SettingsPage() {
const tab = new URLSearchParams(window.location.search).get('tab') || 'profile'
return <Tabs activeTab={tab} />
}Sesudah:
function SettingsPage({ initialTab }) {
return <Tabs activeTab={initialTab} />
}Kenapa ini bekerja: state routing yang memengaruhi markup diputuskan sebelum render, bukan saat hydration di browser.
Kasus 3: Komponen yang hanya masuk akal di browser
Sebelum:
function ViewportInfo() {
return <p>Lebar: {window.innerWidth}px</p>
}Sesudah:
function ViewportInfo() {
const [width, setWidth] = React.useState(null)
React.useEffect(() => {
const update = () => setWidth(window.innerWidth)
update()
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}, [])
return <p>Lebar: {width ?? '...'}px</p>
}Server dan client sama-sama mulai dari placeholder yang identik.
Kapan Memakai useEffect, Suspense, atau Guard SSR
Pakai useEffect jika nilai hanya tersedia di browser
Gunakan useEffect untuk membaca localStorage, ukuran layar, browser API, atau nilai yang tidak perlu hadir pada HTML awal. Ini pendekatan paling umum dan aman, dengan trade-off berupa update setelah mount.
Pakai Suspense jika Anda memang menunda bagian tertentu
Suspense berguna ketika subtree tertentu bergantung pada data atau komputasi yang memang belum siap, dan Anda ingin menampilkan fallback yang stabil. Namun Suspense bukan obat otomatis untuk hydration mismatch. Jika fallback server dan fallback client tetap berbeda, masalahnya belum selesai.
Gunakan Suspense ketika:
- Anda memang punya boundary UI yang wajar untuk ditunda.
- Fallback bisa dirender konsisten di server dan client.
- Komponen berat atau data tertentu tidak harus muncul pada HTML awal.
Pakai guard SSR untuk memisahkan komponen client-only
Guard SSR cocok untuk widget yang benar-benar tidak relevan di server, misalnya integrasi browser-only, editor WYSIWYG tertentu, peta interaktif, atau library yang bergantung penuh pada DOM.
Tetapi jangan gunakan guard SSR sebagai cara menutupi semua mismatch. Jika komponen tersebut sebenarnya memengaruhi konten utama halaman, lebih baik cari sumber state yang konsisten di server.
Checklist Debug Langkah demi Langkah
- Baca warning hydration secara teliti. Perhatikan elemen mana yang tidak cocok: teks, atribut, atau struktur tree.
- Bandingkan output render awal. Cari bagian UI yang berubah tepat setelah mount. Itu sering menjadi petunjuk komponen penyebab.
- Cari semua sumber nondeterministik. Audit penggunaan
window,document,localStorage,sessionStorage,new Date(),Math.random(), dan parser URL berbasis browser. - Periksa initial state dengan lazy initializer. Banyak mismatch lahir dari
useState(() => ...)yang membaca environment client-only. - Uji dengan menonaktifkan personalisasi. Matikan sementara feature flag, A/B test, tema, locale khusus, atau data user agar sumber perbedaan lebih mudah diisolasi.
- Pastikan data SSR dan data hydration sama. Jika memakai loader atau data fetching full-stack, cek bahwa payload yang diterima client benar-benar sama dengan yang dipakai server untuk render.
- Periksa URL dan search params. Pastikan parsing dilakukan di layer yang konsisten, bukan dengan asumsi browser-only di body render.
- Audit conditional rendering. Cari
typeof window !== 'undefined',return nulldi server, atau komponen yang hanya muncul di client. - Periksa format locale/waktu. Uji di environment dengan timezone berbeda bila perlu.
- Sederhanakan subtree yang dicurigai. Render nilai statis dulu. Jika warning hilang, kembalikan logika sedikit demi sedikit sampai penyebabnya ketemu.
Tips Praktis di Framework Full-Stack Modern
Pada framework React full-stack modern, termasuk pola yang menekankan route loader, server functions, dan data-aware routing, strategi terbaik biasanya adalah mendorong state penting ke layer server/router. Artinya:
- baca search params di loader/route context jika memang memengaruhi UI awal
- baca cookie untuk tema, preferensi, atau sesi user
- kirim hasil evaluasi feature flag dari server
- hindari membaca browser API langsung saat render route utama
Pendekatan ini mengurangi mismatch sekaligus membuat SSR lebih berarti, karena HTML awal benar-benar merepresentasikan state yang ingin dilihat user.
Jika Anda memakai komponen client-only di dalam route SSR, pertimbangkan untuk:
- membungkusnya dengan boundary/fallback yang stabil
- memastikan fallback server dan render awal client identik
- membatasi area client-only agar tidak mengubah struktur konten utama halaman
Kesalahan yang Sering Terjadi
- Menganggap guard
typeof windowotomatis aman. Guard mencegah error, tetapi tidak menjamin markup server dan client sama. - Mengambil nilai dari
localStoragedi lazy initializer. Praktis, tetapi sering memicu initial state yang berbeda. - Memformat tanggal langsung saat render. Sangat rawan berbeda karena waktu dan locale.
- Menggunakan random value untuk key, ID, atau variasi UI. Hasilnya tidak stabil lintas environment.
- Menyembunyikan mismatch dengan render null. Ini bisa mengurangi warning, tetapi sering menimbulkan layout shift dan UX yang buruk.
Penutup
Debug hydration mismatch di React SSR pada dasarnya adalah pekerjaan menyamakan dua render pertama: render di server dan render awal di client. Saat Anda melihat warning hydration, UI berubah setelah mount, atau state awal terasa tidak sinkron, curigai dulu sumber nilai yang hanya ada di browser, berubah karena waktu, atau dihitung secara nondeterministik.
Solusi yang paling andal biasanya sederhana: jika nilai memengaruhi HTML awal, tentukan nilainya di server atau kirim sebagai data awal; jika nilai hanya tersedia di browser, render fallback stabil lalu sinkronkan setelah mount. Dengan pola ini, SSR tetap konsisten, hydration lebih mulus, dan bug UI yang sulit direproduksi jadi jauh lebih mudah diburu.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!