Snapshot SSR untuk cegah hydration mismatch adalah pendekatan untuk memastikan HTML hasil render di server dibangun dari data awal yang sama persis dengan data yang dipakai client saat hydration. Jika server merender satu nilai, tetapi client menghitung ulang nilai berbeda pada render pertama, framework akan mendeteksi perbedaan DOM dan memunculkan warning, re-render, atau bahkan perilaku UI yang sulit dilacak.
Penyebabnya sering bukan bug besar, melainkan sumber data yang nondeterministic: tanggal saat ini, locale berbeda, angka acak, ukuran viewport, isi localStorage, atau hasil fetch yang balapan. Solusi praktisnya adalah mengirim snapshot state awal yang minimal, terstruktur per concern, dan cukup untuk render pertama. Setelah hydration selesai, barulah client boleh memperkaya state dari sumber yang hanya tersedia di browser.
Mengapa hydration mismatch terjadi
Pada aplikasi SSR, server mengirim HTML awal agar halaman cepat tampil dan ramah crawler. Setelah itu, JavaScript di client melakukan hydration: memasang event listener dan menyambungkan state ke DOM yang sudah ada. Masalah muncul ketika output render awal di client tidak sama dengan HTML dari server.
Contoh gejala yang umum:
- Warning seperti Text content does not match server-rendered HTML.
- Elemen yang berkedip karena framework membuang DOM awal lalu merender ulang.
- Status komponen berubah sesaat setelah halaman tampil.
- Class CSS, atribut aria, atau urutan list berbeda antara server dan client.
Secara teknis, hydration mengasumsikan bahwa fungsi render pertama di client bersifat deterministik terhadap input yang sama. Jika input berubah, hasil render juga berubah. Karena itu fokus utamanya bukan sekadar “menyamakan API”, tetapi menyamakan snapshot input render pertama.
Pola snapshot SSR: kirim state awal yang konsisten
Cara berpikir yang berguna adalah analogi dengan penyimpanan log kolumnar: pisahkan data per concern dan kirim hanya field yang benar-benar diperlukan untuk render awal. Jangan serialisasi seluruh objek aplikasi bila sebagian besar tidak dipakai oleh komponen yang di-hydrate pada saat itu.
Prinsip utamanya
- State dibagi per concern: misalnya user, featureFlags, pricing, timeContext, locale.
- Kirim hanya field untuk render awal: misalnya nama pengguna, mata uang, dan daftar item yang tampil; bukan seluruh payload backend.
- Jangan hitung ulang nilai nondeterministic saat render pertama di client.
- Client membaca snapshot yang sama dengan yang dipakai server untuk membangun HTML.
- Update data lanjutan dilakukan setelah hydration, lewat effect atau mekanisme revalidasi.
Dengan pola ini, render awal menjadi fungsi dari snapshot yang stabil. Ini mengurangi mismatch sekaligus menekan ukuran payload karena hanya field penting yang ikut diserialisasi.
Struktur snapshot yang disarankan
Snapshot sebaiknya eksplisit dan dangkal untuk kebutuhan awal. Contoh:
{
"page": {
"title": "Checkout"
},
"user": {
"id": "u_123",
"name": "Nadia"
},
"pricing": {
"currency": "IDR",
"subtotal": 125000,
"discount": 10000,
"total": 115000
},
"timeContext": {
"serverEpochMs": 1735689600000,
"timeZone": "UTC"
},
"locale": {
"language": "id-ID"
},
"features": {
"showPromoBanner": false
}
}Perhatikan bahwa snapshot ini tidak mencoba menyimpan semua state interaktif. Tujuannya sempit: cukup untuk memastikan render pertama identik.
Root cause umum hydration mismatch
1. Tanggal, waktu, dan zona waktu
Pemanggilan Date.now(), new Date(), atau formatting tanggal saat render sering menghasilkan output berbeda antara server dan client, terlebih jika zona waktu berbeda.
Salah: langsung memanggil waktu saat render.
export function Header() {
return <p>Diperbarui: {new Date().toLocaleString()}</p>
}Lebih aman: hitung di server, kirim sebagai snapshot, dan gunakan nilai itu pada render pertama.
export function Header({ snapshot }) {
return <p>Diperbarui: {snapshot.lastUpdatedLabel}</p>
}Jika label harus menyesuaikan timezone browser, render placeholder stabil dulu, lalu format ulang setelah hydration.
2. Locale dan formatting
Intl.NumberFormat atau Intl.DateTimeFormat dapat menghasilkan output berbeda jika locale default server tidak sama dengan browser. Jangan bergantung pada locale implisit.
- Tentukan locale secara eksplisit di server dan client.
- Atau kirim string yang sudah diformat bila memang harus identik pada render awal.
3. Nilai acak
Math.random(), generator ID acak, atau key yang dibentuk saat render akan hampir pasti mismatch.
Hindari:
const id = Math.random().toString(36).slice(2)Gunakan: ID stabil dari data backend, atau hasil generate di server yang ikut diserialisasi ke snapshot.
4. Viewport dan media query
Server tidak tahu ukuran viewport aktual browser. Jika Anda merender layout berbeda berdasarkan window.innerWidth pada render pertama, mismatch mudah terjadi.
Pendekatan aman:
- Gunakan CSS responsif untuk layout awal sebanyak mungkin.
- Jika perlu logika JS berbasis viewport, aktifkan setelah hydration.
- Render struktur HTML yang sama, lalu ubah perilaku atau detail minor setelah mount.
5. localStorage, sessionStorage, dan API browser lain
State dari browser tidak tersedia di server. Jika komponen langsung membaca localStorage saat render dan hasilnya mengubah output, server dan client akan berbeda.
Pola aman:
- Gunakan snapshot SSR untuk default awal.
- Baca
localStoragedi effect setelah hydration. - Jika perbedaan UI signifikan, tampilkan status netral dulu sampai preferensi browser terbaca.
6. Async race dan fetch ganda
Server merender data versi A, tetapi client saat hydration langsung melakukan fetch dan mendapat versi B sebelum render stabil. Hasilnya DOM awal tidak cocok.
Solusinya adalah menjadikan data SSR sebagai sumber kebenaran untuk render pertama, lalu revalidasi setelah hydration, bukan sebelum render awal selesai.
Serialisasi state yang aman untuk snapshot SSR
Snapshot harus aman secara keamanan, stabil secara bentuk, dan cukup kecil agar tidak membebani HTML.
Aturan serialisasi
- Gunakan data JSON-serializable: string, number, boolean, array, object sederhana.
- Hindari instance kompleks:
Date,Map,Set, class custom. Ubah ke string atau angka primitif. - Jangan serialisasi rahasia: token, session internal, kredensial, atau field sensitif yang tidak perlu untuk UI.
- Jaga urutan dan bentuk data stabil: terutama untuk list yang dirender.
- Escape output dengan benar saat menyisipkan JSON ke HTML untuk mencegah XSS.
Pisahkan state per concern
Alih-alih satu objek besar seperti pageProps yang bercampur antara data UI, hasil API mentah, dan state internal, buat snapshot yang jelas batasannya:
- renderState: semua yang wajib untuk render awal.
- clientHints: info tambahan yang boleh berubah setelah hydration.
- deferredData: data yang tidak diperlukan untuk tampilan pertama.
Manfaatnya mirip pendekatan kolumnar: komponen hanya membaca “kolom” yang relevan. Ini memudahkan audit penyebab mismatch, mengurangi ukuran snapshot, dan mencegah coupling antarkomponen.
Contoh pola aman
const snapshot = {
renderState: {
user: { id: user.id, name: user.name },
cart: items.map((item) => ({
id: item.id,
name: item.name,
qty: item.qty,
price: item.price
})),
currency: 'IDR',
total: total
},
clientHints: {
themeFromCookie: themeCookie ?? 'light'
}
}Yang penting: nilai pada renderState tidak boleh dihitung ulang dengan sumber yang berbeda saat render pertama di client.
Validasi snapshot dan checksum
Untuk kasus yang sulit dilacak, tambahkan validasi sederhana bahwa snapshot yang dipakai client memang identik dengan yang dipakai server. Ini bukan fitur wajib framework, tetapi teknik debugging yang sangat berguna.
Apa yang divalidasi
- Hash dari JSON snapshot yang sudah dinormalisasi.
- Versi schema snapshot.
- Field wajib untuk komponen kritis.
Pola implementasi
Server menghitung checksum dari snapshot yang sudah diserialisasi secara stabil, lalu menyertakannya ke HTML. Client menghitung ulang checksum dari objek yang dibaca saat bootstrap. Jika berbeda, log detail sebelum hydration atau pada mode development.
function stableStringify(value) {
return JSON.stringify(value, Object.keys(value).sort())
}
function checksum(input) {
let hash = 0
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) | 0
}
return String(hash)
}
const serialized = stableStringify(snapshot)
const snapshotChecksum = checksum(serialized)Untuk produksi, gunakan checksum terutama sebagai alat observabilitas, bukan sebagai mekanisme keamanan. Tujuannya membantu membuktikan apakah mismatch berasal dari snapshot berbeda, atau dari kode render yang nondeterministic.
Perlu dicatat bahwa contoh stableStringify sederhana di atas hanya ilustrasi. Untuk objek bertingkat, gunakan pendekatan serialisasi stabil yang benar-benar mengurutkan key secara rekursif.
Contoh implementasi di Next.js
Inti polanya sama: ambil data di server, bentuk snapshot kecil dan deterministik, render dari snapshot itu, lalu pakai snapshot yang sama di client.
Server: bentuk snapshot eksplisit
export async function getServerSideProps(context) {
const user = await getUserFromSession(context.req)
const cart = await getCart(user.id)
const snapshot = {
renderState: {
user: {
id: user.id,
name: user.name
},
cart: cart.items.map((item) => ({
id: item.id,
name: item.name,
qty: item.qty,
price: item.price
})),
currency: 'IDR',
total: cart.total,
generatedAtIso: new Date().toISOString()
}
}
return {
props: {
snapshot
}
}
}Client: render pertama hanya dari snapshot
import { useEffect, useState } from 'react'
export default function CheckoutPage({ snapshot }) {
const [theme, setTheme] = useState(snapshot.renderState.theme ?? 'light')
const { user, cart, currency, total, generatedAtIso } = snapshot.renderState
useEffect(() => {
const storedTheme = window.localStorage.getItem('theme')
if (storedTheme) setTheme(storedTheme)
}, [])
return (
<main data-theme={theme}>
<h1>Checkout</h1>
<p>Halo, {user.name}</p>
<ul>
{cart.map((item) => (
<li key={item.id}>
{item.name} × {item.qty}
</li>
))}
</ul>
<p>Total: {currency} {total}</p>
<small>Snapshot: {generatedAtIso}</small>
</main>
)
}Perhatikan bahwa pembacaan localStorage dipindahkan ke useEffect, sehingga tidak memengaruhi output render pertama. Jika tema harus benar-benar konsisten sejak awal, lebih baik ambil dari cookie di server dan masukkan ke snapshot atau atribut HTML.
Pola penting di Next.js
- Jangan panggil API browser di tubuh komponen untuk memengaruhi output awal.
- Jangan membangun key list dari nilai acak.
- Jika memakai formatting tanggal/angka, pastikan input dan locale identik.
- Untuk data yang bisa berubah cepat, render dari snapshot SSR dulu lalu revalidasi setelah mount.
Contoh implementasi di Nuxt
Di Nuxt, pola dasarnya juga sama: data untuk render awal harus berasal dari payload SSR yang sama, bukan dihitung ulang secara berbeda di client.
Server/client berbagi snapshot lewat payload
export default defineNuxtComponent({
async asyncData({ $api }) {
const product = await $api.getProduct()
return {
snapshot: {
renderState: {
product: {
id: product.id,
name: product.name,
price: product.price,
currency: product.currency
}
}
}
}
}
})Render dari snapshot, defer sumber browser
<template>
<section>
<h1>{{ snapshot.renderState.product.name }}</h1>
<p>
{{ snapshot.renderState.product.currency }}
{{ snapshot.renderState.product.price }}
</p>
</section>
</template>
<script>
export default {
props: {
snapshot: {
type: Object,
required: true
}
},
mounted() {
const preferredView = window.localStorage.getItem('preferred-view')
if (preferredView) {
// update state non-kritis setelah hydration
}
}
}
</script>Jika Anda memakai composable atau store, tetap pastikan nilai awal store diisi dari payload SSR yang sama, bukan dari perhitungan terpisah di client.
Strategi render untuk data yang memang harus berbeda di client
Tidak semua data harus identik selamanya. Yang penting adalah render pertama. Untuk data yang memang bergantung pada browser, gunakan salah satu strategi ini:
1. Placeholder stabil
Server dan client sama-sama merender placeholder, lalu setelah mount nilai sebenarnya ditampilkan.
function ClientTime() {
const [value, setValue] = useState('—')
useEffect(() => {
setValue(new Date().toLocaleString('id-ID'))
}, [])
return <span>{value}</span>
}2. Client-only untuk bagian tertentu
Jika sebuah widget sepenuhnya bergantung pada API browser, pertimbangkan render khusus client untuk komponen itu. Trade-off-nya, HTML awal untuk bagian tersebut tidak tersedia penuh dari server.
3. Cookie atau request hint
Untuk preferensi seperti tema, bahasa, atau eksperimen fitur, pindahkan sumber kebenaran ke sesuatu yang tersedia di server, misalnya cookie. Dengan begitu server dan client sama-sama membaca nilai awal yang identik.
Checklist debugging hydration mismatch
- Bandingkan output render awal: teks, class, atribut, urutan list.
- Cari sumber nondeterministic:
Date, random, locale default, viewport, storage browser. - Audit semua cabang render yang bergantung pada
window,document, atau cookie client-only. - Pastikan key list stabil dan berasal dari ID data, bukan index yang mudah berubah atau nilai acak.
- Bekukan snapshot render awal dan log checksum di server serta client.
- Matikan re-fetch agresif sementara untuk memastikan mismatch bukan akibat race data.
- Periksa formatting locale dengan locale eksplisit yang sama.
- Uji zona waktu berbeda jika aplikasi menampilkan tanggal atau jam.
- Periksa kondisi viewport yang mengubah struktur markup saat render.
- Audit store global: apakah nilai awal store di client sama dengan payload SSR?
Anti-pattern yang sering menyebabkan mismatch
- Menghitung state awal dua kali: sekali di server, sekali di client, dari sumber yang tidak sama.
- Menyerialisasi terlalu banyak data mentah lalu membiarkan komponen menurunkan state render sendiri secara berbeda.
- Menggunakan waktu saat ini pada JSX/template.
- Membaca localStorage saat render.
- Mengubah struktur markup berdasarkan viewport saat render awal.
- Mengandalkan locale default environment.
- Menggunakan random untuk key atau ID DOM.
- Langsung revalidate sebelum hydration stabil.
Jika diringkas, anti-pattern terbesar adalah: render awal tidak bergantung pada satu snapshot deterministik.
Trade-off: performa vs kompleksitas
Kelebihan pendekatan snapshot SSR
- Mengurangi hydration mismatch secara sistematis.
- Mempermudah debugging karena input render awal jelas.
- Payload bisa lebih kecil jika hanya field penting yang dikirim.
- Komponen menjadi lebih mudah diuji karena render awal bergantung pada data eksplisit.
Biaya dan keterbatasannya
- Ada desain ekstra: Anda harus menentukan schema snapshot dengan disiplin.
- Perlu pemisahan concern: tidak semua tim terbiasa memisahkan data render awal dan data interaktif lanjutan.
- Snapshot terlalu besar bisa membebani HTML dan memperlambat transfer.
- Data cepat usang: setelah halaman tampil, snapshot mungkin bukan data terbaru, sehingga perlu strategi revalidasi.
Karena itu, gunakan snapshot untuk bagian yang harus konsisten saat hydration, bukan sebagai alasan untuk memindahkan semua state aplikasi ke payload SSR.
Rekomendasi praktis
- Definisikan render state contract per halaman atau per layout.
- Pastikan contract itu berisi hanya field yang dibutuhkan untuk render pertama.
- Jadikan semua nilai sensitif terhadap waktu, locale, dan random sebagai input eksplisit, bukan hasil hitung saat render.
- Pindahkan pembacaan API browser ke fase setelah hydration, kecuali nilainya juga tersedia di server.
- Tambahkan checksum snapshot pada mode development untuk halaman yang sering mismatch.
- Gunakan CSS untuk responsivitas awal, bukan branching markup berdasarkan viewport.
Penutup
Snapshot SSR untuk cegah hydration mismatch bekerja karena ia menyederhanakan masalah ke hal yang bisa dikontrol: server dan client harus merender dari input awal yang sama. Dengan memisahkan state per concern, mengirim hanya field yang dibutuhkan, dan menunda sumber data nondeterministic sampai setelah hydration, Anda mengurangi warning, flicker, dan bug UI yang sulit direproduksi.
Jika aplikasi Anda sering terkena mismatch, jangan mulai dari patch kecil di komponen. Mulailah dari pertanyaan yang lebih mendasar: apa snapshot minimum yang dibutuhkan agar render pertama benar-benar deterministik? Biasanya, jawaban itulah yang memperbaiki masalah secara konsisten.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!