Hydration mismatch dari state turunan yang berbeda di SSR biasanya terjadi ketika HTML hasil render server tidak cocok dengan hasil render pertama di browser. Penyebabnya bukan selalu data utama yang berbeda, tetapi sering justru state turunan seperti format waktu, locale, ukuran layar, status login dari storage, atau deteksi kemampuan browser yang dihitung dengan asumsi berbeda di dua lingkungan.
Solusi utamanya adalah membuat render awal deterministik: server dan client harus menghasilkan output awal yang sama, lalu perubahan yang bergantung pada browser dijalankan setelah hydration selesai. Cara berpikir ini mirip analogi “sirkuit” yang memisahkan fungsi melihat dan berpikir: anggap render server sebagai jalur yang hanya boleh menghasilkan tampilan awal yang stabil, sementara reaksi client adalah jalur terpisah untuk menyesuaikan UI setelah browser benar-benar tersedia.
Apa itu hydration mismatch dan kenapa UI jadi membingungkan?
Pada aplikasi SSR, server mengirim HTML awal agar halaman cepat tampil dan mudah diindeks. Setelah itu, JavaScript di browser melakukan hydration: framework memasang event handler dan menyambungkan state ke markup yang sudah ada. Masalah muncul saat output render pertama di client berbeda dari HTML yang sudah dikirim server.
Gejalanya bisa ringan sampai serius:
- Peringatan di console tentang text content atau DOM tree yang tidak cocok.
- UI berkedip karena client mengganti isi yang sebelumnya dirender server.
- Komponen interaktif tidak stabil pada render awal.
- Class CSS berbeda sehingga layout meloncat.
- Event attach ke node yang salah pada kasus struktur DOM yang berubah.
Yang membingungkan, data mentahnya bisa sama, tetapi hasil turunannya berbeda. Contoh: server dan client sama-sama punya timestamp, tetapi masing-masing memformatnya dengan timezone atau locale yang berbeda. Akibatnya string teks berbeda, lalu hydration mismatch muncul.
Fokus masalah: state turunan yang dihitung dengan asumsi berbeda
State turunan adalah nilai yang tidak datang langsung dari sumber data utama, tetapi dihitung dari data lain atau dari lingkungan eksekusi. Di SSR, lingkungan server dan browser tidak identik. Karena itu, rumus yang tampak “aman” di komponen sering menghasilkan output awal yang berbeda.
Contoh asumsi yang sering berbeda
- Waktu: penggunaan
Date.now(),new Date(), atau format relative time saat render. - Locale dan timezone:
toLocaleString()dapat berbeda antara server dan browser. - Ukuran layar: akses
window.innerWidthatau media query saat render pertama. - Data browser-only:
localStorage,sessionStorage, cookie tertentu, tema sistem, preferensi user. - Deteksi capability: sentuh/non-sentuh, user agent, dukungan fitur, device pixel ratio.
- Nilai acak:
Math.random()atau ID yang dihasilkan saat render.
Secara mental, ini membantu: server hanya “melihat” input request dan data backend, sedangkan client bisa “bereaksi” terhadap lingkungan browser. Jika logika keduanya dicampur dalam satu render awal, mismatch mudah terjadi.
Root cause umum di Next.js, Nuxt, dan Inertia
Meskipun implementasinya berbeda, pola bug-nya mirip di berbagai stack SSR.
Next.js
- Komponen langsung menghitung state dari
window,document, atau storage saat render. - Pemformatan tanggal/angka dilakukan di komponen tanpa menyamakan locale/timezone.
- Percabangan UI berdasarkan
matchMediaatau lebar layar pada render pertama. - Penggunaan nilai acak untuk key, id, atau konten yang ikut dirender server.
Nuxt
- Computed state bergantung pada API browser sebelum komponen benar-benar berada di client.
- Plugin atau composable mencampur state SSR dan state client tanpa fallback awal yang sama.
- Percabangan template berdasarkan tema atau preferensi user yang hanya diketahui browser.
Inertia
- Halaman awal dirender dari server, tetapi komponen langsung membaca storage atau ukuran layar untuk menentukan variant UI.
- Shared props dari server tidak dijadikan sumber kebenaran, lalu client menimpa tampilan berdasarkan asumsi lokal.
- Format angka, tanggal, atau bahasa ditentukan berbeda antara backend dan browser.
Intinya sama: state turunan yang mempengaruhi markup awal harus berasal dari input yang juga diketahui server, atau harus ditunda sampai client siap.
Contoh mismatch yang paling sering terjadi
1) Waktu relatif atau timestamp yang dihitung saat render
Contoh umum: server merender “baru saja”, tetapi beberapa ratus milidetik kemudian client menghitung “1 detik lalu”. Walau terlihat sepele, teks berbeda sudah cukup untuk memicu mismatch.
// Bermasalah untuk SSR jika langsung dirender di server dan client
function PublishedAt({ publishedAt }) {
const label = formatRelativeTime(Date.now() - new Date(publishedAt).getTime())
return <span>{label}</span>
}Perbaikannya: kirim string yang sudah diformat dari server, atau render nilai stabil terlebih dahulu lalu perbarui di client setelah hydration.
2) Locale dan timezone berbeda
toLocaleDateString() tampak nyaman, tetapi hasilnya bisa berbeda tergantung runtime, locale default, dan timezone mesin server.
// Rawan mismatch jika locale/timezone tidak eksplisit
const formatted = new Date(createdAt).toLocaleString()Lebih aman jika Anda:
- menetapkan locale dan timezone secara eksplisit, atau
- memformat di server lalu mengirim string final, atau
- menampilkan format netral pada render awal, lalu meningkatkan tampilan di client.
3) Ukuran layar menentukan struktur markup
Misalnya komponen mobile dan desktop dirender berdasarkan window.innerWidth. Server tidak punya ukuran viewport nyata, sehingga biasanya memakai fallback. Jika fallback itu berbeda dengan kondisi browser, struktur DOM awal ikut berubah.
// Bermasalah: render awal bergantung pada browser-only state
function Nav() {
const isMobile = window.innerWidth < 768
return isMobile ? <MobileNav /> : <DesktopNav />
}Jika yang berubah adalah struktur besar, mismatch-nya lebih sulit dipulihkan. Gunakan CSS responsif untuk variasi tampilan, bukan percabangan markup besar pada render awal, kecuali server juga punya sinyal yang konsisten.
4) Tema atau preferensi dari localStorage
Server merender tema terang, tetapi browser membaca localStorage.theme = 'dark' saat render pertama. Hasilnya class pada elemen root berbeda, dan UI berkedip atau mismatch.
Untuk kasus tema, lebih aman memakai salah satu pola berikut:
- server membaca preferensi dari cookie yang juga tersedia saat request, atau
- render awal memakai tema netral/stabil, lalu apply tema client sesegera mungkin sebelum komponen kompleks dirender.
5) Data browser-only sebagai sumber kebenaran utama
Contohnya status onboarding, dismissed banner, atau pilihan tab terakhir yang hanya disimpan di storage. Jika state itu mengubah markup awal, server tidak bisa menebaknya dengan tepat.
Langkah debugging terurut
Ketika melihat warning hydration mismatch, hindari langsung menambal dengan menonaktifkan SSR untuk seluruh komponen. Lakukan investigasi berurutan agar akar masalahnya jelas.
1) Identifikasi node yang tidak cocok
Lihat pesan warning di console dan cari komponen atau elemen yang paling dekat dengan mismatch. Perhatikan apakah perbedaannya berupa:
- teks,
- atribut/class,
- jumlah child node, atau
- struktur elemen.
Teks berbeda sering mengarah ke waktu, locale, angka, atau state turunan sederhana. Struktur berbeda biasanya berasal dari percabangan render yang bergantung pada browser.
2) Bandingkan input render server vs client
Tuliskan semua input yang dipakai komponen untuk menghasilkan output awal:
- props dari server,
- cookie, header, session,
- timezone/locale,
- nilai waktu saat render,
- storage browser,
- viewport atau media query,
- feature detection.
Lalu tanyakan: apakah semua input ini tersedia dan identik di server dan client pada render pertama? Jika tidak, di situlah kandidat root cause-nya.
3) Cari state turunan yang dihitung di body komponen
Audit perhitungan yang langsung berjalan saat render, terutama yang memakai:
Date.now()
new Date()
Math.random()
window
document
localStorage
sessionStorage
navigator
matchMedia
innerWidth
Intl dengan locale/timezone implisitJika nilai tersebut mempengaruhi HTML awal, kemungkinan besar itulah sumber mismatch.
4) Bekukan output awal untuk verifikasi
Coba ganti sementara perhitungan dinamis dengan nilai statis atau props dari server. Jika mismatch hilang, berarti masalahnya memang pada state turunan, bukan pada data fetching atau event binding.
5) Pisahkan “render awal” dari “enhancement client”
Refactor komponen menjadi dua fase:
- fase SSR/hydration awal: hanya memakai input deterministik,
- fase client setelah mount: baru membaca browser API dan memperbarui state.
Jika setelah pemisahan warning hilang, pola penyebabnya sudah terkonfirmasi.
6) Uji dengan locale, timezone, dan viewport berbeda
Banyak bug tidak muncul di laptop developer yang kondisinya kebetulan mirip dengan server. Uji dengan:
- timezone berbeda,
- bahasa browser berbeda,
- incognito tanpa storage,
- viewport kecil dan besar,
- SSR di environment yang berbeda dari mesin lokal.
Pola perbaikan agar render awal deterministik
1) Jadikan server sebagai sumber kebenaran untuk output awal
Jika nilai turunan memang penting untuk tampilan pertama, hitung di server dan kirim hasilnya sebagai props. Ini paling cocok untuk:
- format tanggal final,
- status autentikasi yang berbasis session/cookie,
- fitur yang bergantung pada data backend.
Kelebihannya: HTML awal stabil. Kekurangannya: output mungkin kurang personal jika informasi personal hanya ada di browser.
2) Tunda logika browser-only sampai setelah hydration
Gunakan efek, lifecycle hook, atau mekanisme sejenis untuk membaca browser-only state setelah komponen terpasang. Render awal gunakan placeholder atau fallback yang sama antara server dan client.
// Contoh generik React-like
function ClientTimezoneTime({ iso }) {
const [text, setText] = useState(() => new Date(iso).toISOString())
useEffect(() => {
setText(new Date(iso).toLocaleString())
}, [iso])
return <time dateTime={iso}>{text}</time>
}Mengapa ini bekerja? Karena server dan client sama-sama memulai dari nilai awal yang identik, yaitu toISOString(). Format lokal baru diterapkan setelah hydration, sehingga bukan lagi bagian dari kontrak kecocokan HTML awal.
3) Gunakan fallback yang stabil, bukan fallback yang “menebak” browser
Fallback yang terlalu pintar sering malah berbahaya. Misalnya menebak mobile/desktop di server tanpa sinyal yang kuat. Jika bisa, pilih fallback netral:
- gunakan CSS responsif daripada percabangan markup,
- tampilkan skeleton atau layout universal,
- hindari teks relatif yang berubah tiap detik pada render awal.
4) Pastikan locale dan timezone eksplisit
Jika Anda perlu formatting saat render awal, jangan bergantung pada default environment. Tetapkan locale/timezone secara sadar, atau pusatkan formatting di satu tempat agar perilakunya konsisten.
Trade-off-nya: output mungkin tidak sepersonal format browser pengguna, tetapi konsistensinya lebih tinggi. Untuk banyak halaman SSR, ini pilihan yang lebih aman.
5) Sinkronkan preferensi user lewat request-friendly storage
Jika tema, bahasa, atau variant UI harus sudah benar sejak HTML pertama, simpan preferensi di media yang bisa dibaca server saat request, seperti cookie. Dengan begitu server dan client memiliki input awal yang sama.
6) Hindari percabangan struktur besar dari state yang belum pasti
Jika kondisi hanya mempengaruhi gaya, pakai class atau CSS. Jika kondisi mempengaruhi struktur DOM besar, pastikan input kondisi itu juga tersedia di server. Semakin besar perbedaan struktur, semakin tinggi risiko hydration error yang sulit dilacak.
Contoh refactor praktis
Sebelum: state turunan dihitung langsung dari browser
function Greeting() {
const isNarrow = window.innerWidth < 768
const lastVisit = localStorage.getItem('lastVisit')
const label = lastVisit
? new Date(lastVisit).toLocaleString()
: 'Pertama kali datang'
return (
<section>
<h2>{isNarrow ? 'Halo mobile' : 'Halo desktop'}</h2>
<p>Kunjungan terakhir: {label}</p>
</section>
)
}Masalahnya jelas: ukuran layar dan storage hanya tersedia di browser, sementara format waktu juga bergantung pada locale/timezone.
Sesudah: render awal stabil, enhancement di client terpisah
function Greeting({ initialVisitLabel }) {
const [isNarrow, setIsNarrow] = useState(false)
const [visitLabel, setVisitLabel] = useState(initialVisitLabel)
useEffect(() => {
setIsNarrow(window.innerWidth < 768)
const lastVisit = localStorage.getItem('lastVisit')
if (lastVisit) {
setVisitLabel(new Date(lastVisit).toLocaleString())
}
}, [])
return (
<section>
<h2>Halo</h2>
<p>Kunjungan terakhir: {visitLabel}</p>
{isNarrow && <p>Mode layar sempit aktif</p>}
</section>
)
}Perbaikannya ada pada dua hal:
- Markup awal tidak bergantung pada browser-only state.
- Nilai awal yang dirender server sama dengan nilai awal client.
Jika Anda tetap ingin UI berbeda untuk mobile/desktop sejak awal, pertimbangkan CSS responsif terlebih dahulu. Gunakan percabangan komponen hanya jika ada sinyal server yang dapat dipercaya.
Kesalahan yang sering dilakukan saat memperbaiki
- Mematikan SSR terlalu cepat. Ini bisa menyembunyikan gejala tanpa menyelesaikan desain state yang bermasalah.
- Menggunakan suppress/warning ignore sebagai solusi utama. Ini hanya aman untuk kasus yang benar-benar dipahami dan tidak mengganggu struktur UI.
- Memformat waktu lokal di banyak tempat. Lebih baik pusatkan strategi formatting.
- Membuat fallback server yang berbeda dengan default client. Fallback harus identik.
- Mengandalkan user agent untuk layout bila tujuan utamanya hanya responsif visual. CSS biasanya lebih tepat.
- Memakai nilai acak untuk key atau id saat render. Gunakan ID stabil dari data atau hasil generate yang konsisten.
Checklist review PR untuk mencegah mismatch berulang
Gunakan checklist ini saat mereview komponen SSR baru atau perubahan pada halaman yang sudah ada.
- Apakah render awal memakai input yang tersedia di server dan client secara identik?
- Apakah ada penggunaan
Date.now(),new Date(), atau format relatif di body render? - Apakah locale dan timezone untuk formatting sudah eksplisit?
- Apakah komponen membaca
window,document,navigator, storage, atau media query sebelum hydration selesai? - Apakah ukuran layar hanya mempengaruhi style dan bisa dipindahkan ke CSS responsif?
- Apakah tema, bahasa, atau preferensi awal bisa disinkronkan lewat cookie atau props dari server?
- Apakah ada nilai acak, ID dinamis, atau key yang berubah antar render?
- Apakah fallback server sama persis dengan nilai awal state di client?
- Apakah perubahan struktur DOM besar bergantung pada state yang hanya diketahui browser?
- Apakah sudah diuji dengan locale, timezone, dan viewport yang berbeda?
Kapan perlu menerima perbedaan client?
Tidak semua perbedaan harus dihilangkan. Beberapa hal memang lebih baik dihitung di client, misalnya personalisasi berbasis kemampuan browser atau preferensi lokal. Yang penting, perbedaan itu tidak ikut menentukan HTML awal. Biarkan server merender baseline yang stabil, lalu client meningkatkan pengalaman setelah hydration.
Prinsip praktis: jika suatu nilai tidak bisa diketahui server dengan pasti, jangan jadikan nilai itu penentu markup awal. Render baseline yang deterministik lebih penting daripada tebakan yang tampak cerdas tetapi memicu mismatch.
Penutup
Debug hydration mismatch dari state turunan yang berbeda di SSR pada dasarnya adalah pekerjaan memisahkan dua jalur tanggung jawab: server menghasilkan tampilan awal yang konsisten, client menambahkan reaksi terhadap realitas browser setelah hydration. Dengan memisahkan “apa yang boleh dirender server” dan “apa yang baru boleh dihitung di client”, Anda bukan hanya menghilangkan warning, tetapi juga membuat UI lebih stabil, mudah diuji, dan lebih mudah direview.
Jika Anda memakai Next.js, Nuxt, atau Inertia, pola pencegahannya tetap sama: audit state turunan, paksa render awal menjadi deterministik, dan pindahkan logika browser-only ke fase enhancement client. Itu biasanya jauh lebih efektif daripada menambal satu peringatan demi satu peringatan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!