Hydration drift pada halaman donasi SSR terjadi ketika markup atau nilai yang dirender di server tidak identik dengan render awal di klien. Pada halaman donasi, gejalanya sering terlihat sebagai peringatan hydration mismatch, angka total donasi yang berubah sesaat setelah halaman dimuat, progress bar meloncat, format mata uang berbeda, atau status campaign berubah dari “aktif” menjadi “selesai” setelah JavaScript berjalan.
Masalah ini bukan sekadar warning kosmetik. Pada UI finansial atau kampanye publik, mismatch kecil bisa menurunkan kepercayaan pengguna karena angka yang tampil tidak stabil. Solusinya bukan “mematikan SSR”, melainkan memastikan bahwa render pertama di server dan klien memakai sumber data, format, dan logika kondisi yang sama.
Memahami hydration drift pada halaman donasi SSR
Pada arsitektur SSR, server mengirim HTML awal agar halaman cepat tampil dan mudah diindeks. Setelah itu, framework di browser melakukan hydration: menghubungkan HTML yang sudah ada dengan komponen interaktif. Jika hasil render awal di browser tidak sama dengan HTML dari server, framework akan mendeteksi mismatch.
Di halaman donasi, perbedaan ini sangat mudah terjadi karena UI sering berisi nilai dinamis:
- Total donasi yang terus berubah.
- Progress bar yang dihitung dari total saat ini dibanding target.
- Mata uang lokal berdasarkan locale pengguna.
- Timestamp seperti “diperbarui 3 menit lalu”.
- Status campaign berdasarkan waktu saat ini atau flag eksperimen.
- Preferensi klien seperti mata uang pilihan yang disimpan di localStorage.
Mismatch data vs mismatch markup
Penting membedakan dua jenis masalah:
- Mismatch data: struktur HTML sama, tetapi isinya berbeda. Contoh: server menulis
Rp 1.250.000, klien menulisIDR 1.250.000,00. - Mismatch markup: struktur node HTML berbeda. Contoh: server merender badge
<span>Campaign Aktif</span>, klien tidak merender elemen itu sama sekali karena status berubah.
Mismatch markup biasanya lebih berbahaya karena dapat memicu penggantian subtree DOM, event listener tidak terpasang sesuai harapan, atau warning yang lebih sulit ditelusuri. Mismatch data cenderung lebih mudah diperbaiki, tetapi tetap problematis pada UI angka dan uang.
Gejala umum pada halaman donasi
1. Angka total donasi berubah sesaat setelah load
Server merender total dari satu snapshot data, lalu klien melakukan fetch ulang dan menerima angka lebih baru. Hasilnya, pengguna melihat total “naik sendiri” saat hydration atau sesaat setelahnya.
2. Progress bar meloncat atau persentase berbeda
Persentase sering dihitung langsung di komponen. Jika pembulatan, tipe data, atau sumber angka berbeda antara server dan klien, nilai width, aria-valuenow, atau teks persentase dapat tidak sama.
3. Format mata uang tidak konsisten
Server mungkin merender dengan locale default lingkungan runtime, sedangkan browser memakai locale pengguna. Akibatnya separator ribuan, simbol mata uang, atau jumlah digit desimal berbeda.
4. Timestamp “x menit lalu” langsung berubah
Jika server menghitung waktu relatif saat render, lalu klien menghitung ulang beberapa detik kemudian, string hasilnya bisa berbeda walau data sumber sama.
5. Status campaign berbeda
Status “aktif”, “hampir selesai”, atau “ditutup” sering ditentukan oleh waktu saat ini, hasil fetch kedua, atau feature flag. Jika evaluasinya tidak konsisten, server dan klien dapat merender elemen yang berbeda.
Root cause yang paling sering
1. Perbedaan Intl, locale, dan timezone
Ini penyebab klasik. Server dan browser bisa memiliki locale default, timezone, atau implementasi format yang tidak identik. Bahkan jika keduanya sama-sama memakai Intl.NumberFormat, hasilnya masih bisa berbeda bila parameter tidak eksplisit.
Masalah umum:
- Server memakai locale default runtime, klien memakai locale browser.
- Server memakai timezone UTC, klien memakai timezone lokal.
- Server menampilkan tanggal absolut, klien mengubah menjadi format relatif.
Prinsip aman: jangan bergantung pada default environment untuk nilai yang muncul pada render pertama.
// Lebih aman: locale dan currency eksplisit, input angka stabil
const formatDonation = (amount) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(amount)Jika Anda memang perlu locale pengguna, ada dua pilihan yang lebih aman:
- Tentukan locale di server dari header, cookie, atau route, lalu kirim sebagai prop yang sama ke klien.
- Tunda format yang bergantung pada browser sampai komponen sudah ter-mount, sambil menampilkan fallback yang stabil dari server.
2. Data fetch ganda saat SSR dan di klien
Halaman donasi sering SSR total donasi dari API, tetapi komponen klien juga memanggil endpoint yang sama pada mount. Jika respons kedua lebih baru, hydration drift muncul. Ini bukan selalu bug data, tetapi bug alur render.
Contoh pola bermasalah:
- SSR memanggil
/api/campaign/zig. - Komponen di browser juga memanggil
/api/campaign/zigpadauseEffectatau hook serupa. - Respons kedua punya
totalDonationsyang sudah berubah beberapa detik.
Prinsip aman: gunakan hasil SSR sebagai initial state, lalu lakukan revalidasi setelah hydration secara sadar, bukan sebagai render awal yang tak sinkron.
3. Race condition antar sumber data
Anda mungkin menggabungkan data dari beberapa sumber: total donasi dari API A, target dari CMS, status campaign dari service flag, kurs konversi dari API B. Jika urutan update tidak terkendali, klien bisa menghasilkan kombinasi state yang tidak pernah ada di server.
Masalah ini makin jelas bila angka progress dihitung di klien dari dua nilai yang datang terpisah.
4. Feature flag atau eksperimen A/B
Jika server menentukan varian berdasarkan cookie, tetapi klien mengevaluasi ulang berdasarkan local state atau identitas pengguna yang belum siap, Anda bisa mendapat mismatch markup. Misalnya badge “Matching Donation Active” muncul di klien tetapi tidak ada di HTML server.
5. Akses localStorage atau browser-only state saat render awal
Misalnya pengguna memilih mata uang tampilan “USD” dan preferensi itu disimpan di localStorage. Server tidak bisa membacanya, sehingga SSR merender “IDR”, sedangkan klien langsung merender “USD” pada pass pertama. Itu mismatch data, dan bisa menjadi mismatch markup jika simbol, label, atau elemen tambahan berubah.
6. Penggunaan waktu saat ini langsung di render
Kode seperti new Date(), Date.now(), atau “sisa waktu campaign” yang dievaluasi langsung saat render hampir selalu berisiko. Selisih beberapa detik saja cukup membuat string berubah.
// Berisiko pada SSR + hydration
const label = Date.now() > endAt ? 'Closed' : 'Open'Lebih aman jika server mengirim snapshot waktu referensi yang dipakai oleh klien pada render awal.
Langkah diagnosis yang efektif
1. Pastikan dulu: data atau markup?
Buka source HTML hasil SSR dan bandingkan dengan DOM setelah hydration. Fokus pada node yang sama:
- Apakah elemen ada di server tetapi hilang di klien?
- Apakah hanya teksnya berbeda?
- Apakah atribut seperti
style,class, atauaria-valuenowberubah?
Jika struktur berubah, cari percabangan render seperti if, conditional component, feature flag, atau status campaign. Jika hanya nilai berubah, curigai formatting, pembulatan, timezone, dan fetch ulang.
2. Bekukan snapshot data untuk render pertama
Saat debugging, log payload yang dipakai server dan payload yang dipakai klien tepat sebelum render pertama. Tujuannya memastikan apakah keduanya benar-benar memakai data yang sama.
Prinsip penting: jangan bandingkan data setelah effect jalan. Hydration mismatch terjadi pada render awal klien, bukan setelah update normal.
3. Log locale, timezone, dan formatter
Untuk kasus format uang dan tanggal, log hal berikut di server dan klien:
- locale yang dipakai
- currency yang dipakai
- timezone yang diasumsikan
- nilai mentah sebelum diformat
Perbedaannya sering terlihat jelas setelah ini.
4. Matikan sementara re-fetch klien
Jika warning hilang setelah re-fetch dimatikan, besar kemungkinan drift berasal dari data fetch ganda atau race condition. Setelah terkonfirmasi, ubah alur menjadi “SSR data dulu, revalidate setelah mount dengan transisi yang disengaja”.
5. Cari penggunaan API browser saat render
Audit kode untuk akses ke:
windowdocumentlocalStoragenavigator.languageIntl.DateTimeFormat().resolvedOptions()
Jika nilai ini dipakai untuk memengaruhi render pertama, potensi mismatch sangat tinggi.
Pola implementasi yang aman
1. Render dari snapshot server, lalu revalidate setelah mount
Untuk angka donasi yang sering berubah, server mengirim snapshot yang stabil. Klien memakai snapshot itu sebagai state awal. Setelah hydration selesai, baru lakukan refresh jika memang dibutuhkan.
// Contoh pola React/Next.js generik
function DonationWidget({ initialCampaign }) {
const [campaign, setCampaign] = useState(initialCampaign)
useEffect(() => {
let cancelled = false
async function refresh() {
const res = await fetch('/api/campaign/zig')
const next = await res.json()
if (!cancelled) setCampaign(next)
}
refresh()
return () => { cancelled = true }
}, [])
return (
<div>
<p>Total: {formatDonation(campaign.total)}</p>
<div
role="progressbar"
aria-valuenow={campaign.progressPercent}
style={{ width: `${campaign.progressPercent}%` }}
/>
</div>
)
}Mengapa ini bekerja? Karena render pertama klien memakai initialCampaign yang sama dengan HTML server. Update baru terjadi setelah hydration, sehingga bukan mismatch, melainkan update UI biasa.
2. Kirim nilai turunan dari server bila perlu konsistensi penuh
Jika progress bar, status campaign, atau label tertentu sangat sensitif, pertimbangkan mengirim hasil hitung final dari server, bukan menghitung ulang semuanya di klien pada render awal.
{
"total": 1250000,
"goal": 5000000,
"progressPercent": 25,
"status": "active",
"formattedTotal": "Rp 1.250.000"
}Trade-off: ada duplikasi logika presentasi di backend, dan locale pengguna mungkin belum ideal. Namun untuk angka publik yang harus stabil, ini sering lebih aman.
3. Hindari Date.now() di render awal
Untuk status campaign berdasarkan waktu, gunakan timestamp referensi dari server.
// Data dari server
const initial = {
now: '2026-01-15T10:00:00Z',
endAt: '2026-01-31T23:59:59Z'
}
function getStatus(nowIso, endAtIso) {
return new Date(nowIso) > new Date(endAtIso) ? 'closed' : 'active'
}Jika Anda ingin label relatif seperti “berakhir dalam 2 hari”, render teks absolut yang stabil saat SSR, lalu ubah menjadi label relatif hanya setelah mount.
4. Pisahkan rendering browser-only
Jika nilai memang harus bergantung pada browser, render placeholder yang stabil di server.
function LocalCurrency({ amount, serverFormatted }) {
const [formatted, setFormatted] = useState(serverFormatted)
useEffect(() => {
const locale = navigator.language || 'id-ID'
setFormatted(new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'IDR'
}).format(amount))
}, [amount])
return <span>{formatted}</span>
}Server dan klien sama-sama mulai dari serverFormatted. Setelah mount, klien boleh meningkatkan pengalaman dengan locale lokal pengguna tanpa memicu hydration drift.
5. Gunakan guard untuk state dari localStorage
Jangan membaca localStorage langsung untuk menentukan render awal.
function CurrencySelector({ initialCurrency = 'IDR' }) {
const [currency, setCurrency] = useState(initialCurrency)
useEffect(() => {
const saved = localStorage.getItem('preferred_currency')
if (saved) setCurrency(saved)
}, [])
return <span>Mata uang: {currency}</span>
}Jika preferensi pengguna harus memengaruhi SSR juga, pindahkan sumber kebenaran ke cookie agar server dapat membacanya.
6. Stabilkan pembulatan dan tipe angka
Perbedaan kecil juga bisa muncul dari pembulatan yang tidak konsisten, misalnya server menyimpan angka pecahan lalu klien menghitung persen dengan rumus berbeda. Gunakan fungsi utilitas yang sama atau kirim nilai final yang sudah dibulatkan.
function calcProgressPercent(total, goal) {
if (!goal || goal <= 0) return 0
return Math.min(100, Math.floor((total / goal) * 100))
}Contoh kasus nyata: mismatch data vs mismatch markup
Kasus A: mismatch data pada format uang
// Server menghasilkan
<span>Rp 1.250.000</span>
// Klien render awal menghasilkan
<span>IDR 1.250.000,00</span>Struktur sama, hanya teks berbeda. Perbaikan: eksplisitkan locale, currency, dan jumlah digit desimal; atau kirim string format final dari server sebagai initial render.
Kasus B: mismatch markup pada status campaign
// Server
{isClosed ? null : <button>Donasi Sekarang</button>}
// Klien
{isClosed ? <span>Campaign Ditutup</span> : <button>Donasi Sekarang</button>}Jika isClosed berbeda antara server dan klien, subtree DOM berubah total. Perbaikan: hitung status dari snapshot waktu server atau data campaign yang sama untuk render pertama.
Panduan praktis di Next.js, Nuxt, dan Inertia
Next.js
- Pastikan data SSR yang dipakai halaman menjadi single source of truth untuk render awal komponen klien.
- Jika ada komponen yang benar-benar browser-only, render setelah mount atau pisahkan agar tidak ikut menentukan HTML SSR.
- Hindari memformat tanggal/uang dengan default locale di dua environment yang berbeda.
- Jika memakai revalidation di klien, lakukan setelah hydration dan tampilkan perubahan sebagai update normal, bukan sebagai initial render.
Nuxt
- Gunakan data awal dari SSR sebagai state yang sama di klien, lalu re-fetch secara eksplisit jika dibutuhkan.
- Untuk nilai berbasis browser seperti locale lokal atau localStorage, gunakan fallback SSR yang stabil.
- Audit composable yang bisa berjalan di server dan klien agar tidak menghasilkan nilai berbeda pada pass awal.
Inertia
- Karena props server menjadi dasar render awal, jaga agar komponen tidak langsung menimpa props dengan fetch atau state lokal yang berbeda sebelum mount selesai.
- Jika ada preferensi pengguna, lebih aman kirim lewat cookie atau shared props daripada membaca localStorage saat render.
- Bedakan dengan jelas data yang authoritative dari server dan data enhancement yang boleh datang belakangan di klien.
Catatan: detail API atau helper bisa berbeda per stack, tetapi prinsip utamanya sama: render pertama harus deterministik dan berasal dari input yang sama di server dan klien.
Checklist pencegahan hydration drift
- Jangan gunakan nilai waktu saat ini langsung di render awal.
- Jangan bergantung pada locale/timezone default environment.
- Gunakan snapshot SSR sebagai initial state klien.
- Hindari fetch ganda yang menimpa state sebelum hydration selesai.
- Pisahkan browser-only state seperti localStorage, navigator, atau preferensi perangkat.
- Kirim nilai turunan yang sensitif seperti status campaign atau persentase jika konsistensi lebih penting daripada fleksibilitas.
- Audit conditional rendering yang bisa mengubah struktur markup.
- Stabilkan pembulatan dan formatting dengan utilitas yang sama atau payload final dari server.
- Log payload render awal di server dan klien saat debugging.
- Uji dengan timezone dan locale berbeda, bukan hanya lingkungan developer lokal.
Kapan mismatch boleh diabaikan?
Untuk halaman donasi, jawabannya hampir selalu: jangan. Konten seperti angka total, target, status campaign, dan mata uang adalah informasi yang sangat terlihat. Walau warning tampak kecil, efek persepsinya besar. Jika nilai memang harus diperbarui real-time, lakukan setelah hydration dengan indikator yang jelas, misalnya “diperbarui barusan” atau animasi update yang tidak mengaburkan sumber angka awal.
Penutup
Hydration drift pada halaman donasi SSR biasanya bukan masalah framework, melainkan masalah determinisme render. Jika server dan klien memakai snapshot data yang sama, formatter yang eksplisit, logika waktu yang stabil, dan browser-only state yang ditunda sampai mount, mismatch akan jauh berkurang.
Mulailah diagnosis dengan membedakan mismatch data dan mismatch markup. Dari situ, telusuri penyebab yang paling umum: Intl, timezone, fetch ganda, race condition, feature flag, dan localStorage. Pada UI donasi, pendekatan yang paling aman biasanya sederhana: SSR dari snapshot yang stabil, hydrate dengan state yang sama, lalu revalidate secara sadar setelahnya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!