Hydration mismatch terjadi ketika HTML yang dirender server tidak sama dengan state awal yang dipakai framework di browser. Kasus ini sering muncul saat aplikasi SSR menampilkan banner, modal, atau notice lisensi yang berubah secara dinamis, misalnya karena region pengguna, waktu aktif kebijakan, consent cookie, atau data dari layanan eksternal.
Jika Anda perlu menampilkan notice yang berkaitan dengan perubahan lisensi atau kebijakan, termasuk dalam konteks adopsi lisensi seperti Total Reciprocity Public License sebagai pemicu kebutuhan UI notice dinamis, fokus teknisnya tetap sama: server dan client harus sepakat tentang output awal. Jika tidak, React atau Vue akan mencoba memperbaiki DOM, memunculkan warning, menyebabkan flicker, atau lebih buruk lagi, membuat perilaku UI sulit diprediksi.
Artikel ini membahas cara mencegah hydration mismatch pada SSR, terutama untuk banner/notice yang berubah per region, waktu, atau sumber data. Fokusnya praktis: penyebab mismatch, anti-pattern, strategi render deterministik, penempatan fetch, placeholder, gating komponen client-only, sinkronisasi flag/consent, contoh Next.js dan Nuxt.js, serta trade-off UX, SEO, dan cache.
Apa yang menyebabkan hydration mismatch pada banner dinamis?
Pada SSR, proses render terjadi dua kali:
- Server menghasilkan HTML awal.
- Client melakukan hydration, yaitu menghubungkan HTML tadi dengan state dan event di browser.
Masalah muncul jika keputusan seperti apakah banner tampil, isi teks banner, atau varian region dihitung dengan input yang berbeda antara server dan client.
Sumber mismatch yang paling umum
- Region berbeda: server menebak region dari header atau edge, tetapi client menghitung ulang dari locale browser atau API geolocation.
- Waktu berbeda: server merender sebelum atau sesudah cutoff waktu tertentu, client menghitung ulang dengan timezone lokal.
- Consent/cookie berbeda: server tidak membaca cookie yang sama, atau client mengubah state sebelum hydration selesai.
- Fetch ganda dengan hasil berbeda: server mengambil data dari satu snapshot, client mem-fetch ulang dan mendapat nilai baru.
- Penggunaan API browser saat render awal: misalnya langsung memakai
window,localStorage, ataumatchMediauntuk menentukan banner. - Feature flag yang tidak diserialisasi: server dan client memutuskan varian UI dari sumber flag yang berbeda.
Secara praktis, hydration mismatch pada notice lisensi sering terjadi bukan karena logika bannernya rumit, tetapi karena ada lebih dari satu sumber kebenaran untuk keputusan render awal.
Anti-pattern yang sering memicu mismatch
1. Menghitung visibilitas banner langsung dari browser API
// Anti-pattern (React / Next.js client component)
export function LicenseBanner() {
const dismissed = localStorage.getItem('license_notice_dismissed') === '1'
const region = navigator.language.startsWith('de') ? 'EU' : 'OTHER'
if (dismissed || region !== 'EU') return null
return <div>Notice lisensi untuk region EU</div>
}Masalahnya jelas: server tidak punya localStorage dan navigator saat render awal. Bahkan jika kode ini dibungkus agar hanya berjalan di browser, keputusan render awal akan berubah setelah mount, memicu flicker atau mismatch.
2. Memakai Date.now() atau timezone lokal saat render
// Anti-pattern
const showBanner = Date.now() > policyStart && Date.now() < policyEndJika server dan client melewati batas waktu yang berbeda beberapa milidetik atau memiliki timezone berbeda dalam logika turunannya, hasil render awal bisa berubah.
3. Fetch ulang di client tanpa memakai state server sebagai sumber awal
Contohnya, server merender banner “TRPL notice aktif”, lalu client segera mem-fetch endpoint yang sudah berubah menjadi “nonaktif”. Secara teknis data terbaru mungkin benar, tetapi hydration akan dimulai dari state yang tidak cocok.
4. Menyembunyikan warning tanpa memperbaiki akar masalah
Beberapa framework menyediakan cara untuk menekan warning tertentu, tetapi itu bukan solusi umum. Jika UI benar-benar berbeda antara server dan client, masalahnya tetap ada: flicker, event listener yang tidak konsisten, dan debugging yang lebih sulit.
Prinsip utama: render awal harus deterministik
Solusi paling aman adalah memastikan keputusan render awal berasal dari satu snapshot data yang sama dan dipakai baik oleh server maupun client.
Aturan praktis
- Tentukan banner di server jika kontennya penting untuk SSR, SEO, atau konsistensi awal.
- Serialisasikan hasil keputusan ke state awal client, jangan hitung ulang dari nol di browser.
- Gunakan placeholder stabil jika data hanya bisa diketahui di client.
- Pisahkan keputusan awal dan update setelah mount. Render pertama harus identik, update berikutnya boleh berubah secara sadar.
- Normalisasi input: region, consent, waktu efektif, dan feature flag sebaiknya dihitung dari sumber yang jelas.
Model data yang lebih aman
Alih-alih memberi client kebebasan menentukan sendiri, kirim objek keputusan yang sudah siap pakai, misalnya:
{
"show": true,
"variant": "eu-trpl-notice",
"message": "Notice lisensi berlaku untuk wilayah Anda.",
"dismissible": true,
"effectiveAt": "2026-08-19T00:00:00Z"
}Dengan pendekatan ini, client tidak perlu menghitung ulang logika region/waktu saat hydration. Ia cukup merender keputusan yang sama dengan server.
Strategi implementasi yang aman
1. Fetch data notice di server, lalu teruskan ke client
Ini adalah pendekatan terbaik jika notice memang bagian dari HTML awal. Server dapat membaca cookie, header, locale, atau hasil middleware untuk menentukan konteks.
Kapan dipilih:
- Banner penting untuk kepatuhan atau transparansi kebijakan.
- Anda ingin menghindari flicker.
- Konten banner perlu bisa diindeks atau terlihat tanpa JavaScript.
Trade-off:
- Variasi HTML meningkat, sehingga strategi cache harus lebih hati-hati.
- Server/edge perlu akses ke input seperti cookie, region, atau flag.
2. Gunakan placeholder stabil jika data hanya diketahui di browser
Jika region atau consent baru bisa dipastikan di client, render placeholder yang sama di server dan client saat hydration awal. Setelah mount, komponen boleh memperbarui dirinya.
Contoh placeholder yang aman:
- Kontainer kosong dengan tinggi tetap untuk mencegah layout shift.
- Skeleton atau placeholder netral tanpa klaim yang bisa berubah.
- Tombol atau area notice yang belum aktif sampai state client siap.
Kapan dipilih:
- Keputusan sangat bergantung pada data browser.
- Notice tidak kritis untuk SEO.
- Anda lebih memilih konsistensi hydration dibanding HTML final lengkap dari server.
3. Gate komponen sebagai client-only jika benar-benar tak bisa dideterministikkan
Untuk modal consent, banner A/B test, atau notice yang sepenuhnya bergantung pada state browser, komponen client-only bisa lebih aman daripada SSR parsial yang tidak stabil.
Kapan dipilih:
- Data server dan client hampir pasti berbeda.
- Komponen bukan konten inti halaman.
- Anda menerima trade-off bahwa HTML awal tidak memuat notice final.
4. Sinkronkan flag dan consent dari sumber tunggal
Jika banner bergantung pada consent atau feature flag, putuskan dari mana nilai awal berasal:
- Cookie/request server untuk SSR deterministik.
- Bootstrap payload yang disisipkan ke halaman agar client memakai nilai yang sama.
- Store global awal yang diisi dari server dan baru boleh berubah setelah hydration selesai.
Kesalahan umum adalah server memakai default false, sedangkan client membaca cookie lalu langsung menjadi true. Hasilnya adalah banner muncul atau hilang tepat saat hydration berlangsung.
Contoh Next.js: server menentukan notice, client hanya mengelola interaksi
Contoh berikut menunjukkan pola yang aman: keputusan banner dibuat di server, lalu komponen client hanya menangani aksi dismiss.
// app/components/LicenseNotice.tsx
'use client'
import { useState } from 'react'
type NoticeProps = {
initialShow: boolean
message: string
}
export default function LicenseNotice({ initialShow, message }: NoticeProps) {
const [visible, setVisible] = useState(initialShow)
if (!visible) return null
async function dismiss() {
setVisible(false)
document.cookie = 'license_notice_dismissed=1; Path=/; SameSite=Lax'
}
return (
<aside role="status" className="notice">
<p>{message}</p>
<button onClick={dismiss}>Tutup</button>
</aside>
)
}// app/page.tsx
import { cookies, headers } from 'next/headers'
import LicenseNotice from './components/LicenseNotice'
function resolveRegion(headerStore: Headers): string {
const country = headerStore.get('x-country') || 'unknown'
return ['DE', 'FR', 'NL', 'ES', 'IT'].includes(country) ? 'EU' : 'OTHER'
}
function getNoticeDecision(params: { region: string; dismissed: boolean; nowIso: string }) {
const effectiveAt = '2026-08-19T00:00:00Z'
const active = params.nowIso >= effectiveAt
if (!active || params.dismissed) {
return { show: false, message: '' }
}
if (params.region === 'EU') {
return {
show: true,
message: 'Notice lisensi/kebijakan berlaku untuk pengguna di wilayah Anda.'
}
}
return { show: false, message: '' }
}
export default async function Page() {
const cookieStore = await cookies()
const headerStore = await headers()
const dismissed = cookieStore.get('license_notice_dismissed')?.value === '1'
const region = resolveRegion(headerStore)
const decision = getNoticeDecision({
region,
dismissed,
nowIso: new Date().toISOString()
})
return (
<main>
<LicenseNotice initialShow={decision.show} message={decision.message} />
<article>...konten halaman...</article>
</main>
)
}Mengapa pendekatan ini aman?
- Server memutuskan
showdanmessagesebelum HTML dikirim. - Client memulai hydration dengan props yang sama persis.
- Interaksi setelah mount, seperti dismiss, tidak memengaruhi konsistensi render awal.
Catatan penting untuk Next.js
- Jangan hitung ulang region atau waktu efektif di client untuk render pertama jika server sudah memutuskannya.
- Jika memakai edge/middleware untuk geolocation, teruskan hasilnya secara konsisten lewat header atau request context.
- Jika halaman di-cache, pastikan variasi cache mengikuti input yang memengaruhi notice, atau jangan cache penuh HTML yang bervariasi per pengguna.
Contoh Nuxt.js: gunakan data SSR yang sama, hindari keputusan ulang di mounted
Di Nuxt, pola amannya serupa: putuskan notice saat SSR, lalu hydrasi dengan payload yang sama.
<script setup lang="ts">
const headers = useRequestHeaders(['cookie', 'x-country'])
function parseDismissed(cookieHeader?: string) {
return (cookieHeader || '').includes('license_notice_dismissed=1')
}
function resolveRegion(country?: string) {
return ['DE', 'FR', 'NL', 'ES', 'IT'].includes(country || '') ? 'EU' : 'OTHER'
}
const notice = await useAsyncData('license-notice', async () => {
const dismissed = parseDismissed(headers.cookie)
const region = resolveRegion(headers['x-country'])
const active = new Date().toISOString() >= '2026-08-19T00:00:00Z'
if (!active || dismissed || region !== 'EU') {
return { show: false, message: '' }
}
return {
show: true,
message: 'Notice lisensi/kebijakan berlaku untuk wilayah Anda.'
}
})
const visible = ref(notice.data.value?.show ?? false)
function dismiss() {
visible.value = false
document.cookie = 'license_notice_dismissed=1; Path=/; SameSite=Lax'
}
</script>
<template>
<aside v-if="visible" role="status" class="notice">
<p>{{ notice.data?.message }}</p>
<button @click="dismiss">Tutup</button>
</aside>
</template>Pola yang perlu dihindari adalah memutuskan visible di onMounted() dari nol menggunakan window, timezone browser, atau panggilan API baru, jika SSR sebelumnya sudah merender state lain.
Kapan memakai placeholder atau client-only?
Placeholder stabil
Pilih ini jika Anda masih ingin mempertahankan struktur SSR, tetapi keputusan final baru tersedia di browser.
// Contoh React sederhana
export default function NoticeSlot() {
return <div id="notice-slot" style={{ minHeight: 48 }} />
}Lalu setelah mount, isi slot tersebut dengan komponen aktual. Kelebihannya adalah tidak ada mismatch pada node awal. Kekurangannya, pengguna mungkin melihat area kosong sesaat.
Client-only
Jika notice murni berbasis browser state, lebih baik jujur bahwa komponen itu hanya bisa dirender di client daripada memaksa SSR yang tidak stabil.
Gunakan client-only untuk komponen yang tidak kritis terhadap SEO dan tidak harus tampil di HTML pertama. Ini sering cocok untuk modal consent, eksperimen UI, atau banner yang sangat personal.
Trade-off utamanya:
- Pro: hampir menghilangkan risiko hydration mismatch.
- Kontra: notice tidak hadir pada HTML awal, bisa terlambat muncul, dan kurang ideal untuk konten kepatuhan yang harus langsung terlihat.
Sinkronisasi consent, feature flag, dan sumber data
Banyak mismatch berasal dari sumber state yang tersebar. Banner lisensi/kebijakan biasanya dipengaruhi oleh beberapa sinyal sekaligus:
- cookie dismiss/consent,
- region atau locale,
- waktu mulai/berakhir kebijakan,
- flag rollout,
- data CMS atau API.
Solusi terbaik adalah membuat satu fungsi keputusan yang dapat dipakai di server, atau paling tidak menghasilkan payload final yang sama untuk client.
Contoh pendekatan keputusan terpusat
type NoticeInput = {
region: 'EU' | 'OTHER' | 'UNKNOWN'
dismissed: boolean
active: boolean
flagEnabled: boolean
}
type NoticeDecision = {
show: boolean
variant: 'eu' | 'default' | 'none'
message: string
}
export function decideLicenseNotice(input: NoticeInput): NoticeDecision {
if (!input.active || input.dismissed || !input.flagEnabled) {
return { show: false, variant: 'none', message: '' }
}
if (input.region === 'EU') {
return {
show: true,
variant: 'eu',
message: 'Notice lisensi/kebijakan berlaku untuk wilayah Anda.'
}
}
return { show: false, variant: 'none', message: '' }
}Keuntungannya:
- Logika banner mudah diuji secara unit test.
- Perubahan aturan tidak tersebar di banyak komponen.
- Risiko server/client berbeda keputusan menjadi lebih kecil.
Trade-off UX, SEO, dan cache
UX
- SSR penuh: pengalaman paling stabil, minim flicker, tetapi perlu kontrol cache yang lebih ketat.
- Placeholder: aman untuk hydration, tetapi bisa menimbulkan ruang kosong sesaat.
- Client-only: paling sederhana untuk state browser, tetapi notice muncul lebih lambat.
SEO
- Jika notice mengandung informasi publik yang memang ingin terlihat oleh crawler, SSR lebih cocok.
- Jika notice hanya relevan per pengguna dan bukan konten utama, client-only biasanya tidak masalah.
Cache
Ini bagian yang sering diabaikan. Banner yang berubah per region, consent, atau cookie bisa bertabrakan dengan cache HTML.
Masalah umum:
- HTML hasil region EU disajikan ke pengguna non-EU.
- Banner yang sudah dismissed tetap muncul karena halaman di-cache secara umum.
- CMS memperbarui notice, tetapi client memuat versi lama dari cache edge.
Pertimbangan cache yang aman:
- Variasikan cache berdasarkan sinyal yang benar-benar memengaruhi output, misalnya region.
- Jangan variasikan HTML penuh per user jika hanya status dismiss yang berubah; lebih baik status dismiss dikelola di client setelah keputusan awal yang netral.
- Gunakan payload data yang terversi jika sumber notice berasal dari CMS/API, agar invalidasi lebih mudah dilacak.
Trade-off-nya: semakin personal banner Anda, semakin sulit menjaga rasio cache hit tanpa menurunkan konsistensi.
Checklist debugging hydration mismatch di banner SSR
- Bandingkan HTML server dan render awal client. Jangan langsung melihat state setelah effect berjalan.
- Log input keputusan: region, cookie dismiss, waktu aktif, flag, dan payload notice.
- Pastikan sumber waktu konsisten. Hindari menghitung cutoff dengan timezone browser untuk render pertama.
- Cek apakah client mem-fetch ulang terlalu cepat dan mengganti state sebelum hydration selesai.
- Periksa cache layer: CDN, reverse proxy, edge cache, dan cache framework.
- Audit penggunaan browser-only API pada jalur render awal.
- Uji region dan consent berbeda dengan request yang benar-benar terpisah, bukan hanya berpindah state di browser yang sama.
- Gunakan unit test untuk fungsi keputusan agar aturan tampil/hilang banner tidak ambigu.
Gejala yang patut dicurigai
- Warning hydration hanya muncul pada region tertentu.
- Banner berkedip lalu hilang.
- Teks notice berubah sesaat setelah halaman dimuat.
- Masalah hanya terjadi di production, bukan local development, karena cache dan header edge berbeda.
Kesalahan desain yang sering tidak disadari
- Mencampur logika hukum/kebijakan dengan logika render UI. Aturan tampil notice sebaiknya diterjemahkan dulu menjadi payload UI yang sederhana.
- Membiarkan CMS/API menjadi sumber state yang berubah setiap detik tanpa snapshot SSR yang jelas.
- Menganggap dismiss user harus memengaruhi HTML server saat itu juga. Untuk banyak kasus, cukup sinkronkan pada request berikutnya melalui cookie.
- Menentukan region dua kali: sekali di edge, sekali lagi di browser, dengan aturan berbeda.
Rekomendasi praktis
Jika Anda perlu menampilkan banner atau notice lisensi/kebijakan yang bisa berubah, gunakan urutan keputusan berikut:
- Apakah notice harus ada di HTML awal? Jika ya, putuskan di server.
- Apakah inputnya tersedia di server? Jika ya, serialisasikan hasil final ke client.
- Apakah input hanya tersedia di browser? Jika ya, gunakan placeholder stabil atau client-only.
- Apakah cache HTML akan tercemar oleh variasi user? Jika ya, minimalkan personalisasi pada SSR atau pindahkan bagian tertentu ke client.
Untuk konteks seperti perubahan kebijakan atau lisensi yang dipicu oleh kebutuhan transparansi UI, termasuk skenario terkait adopsi lisensi seperti TRPL, target utamanya bukan membuat banner secerdas mungkin, tetapi membuat render awal tetap deterministik. Dengan begitu Anda menghindari hydration mismatch, mengurangi flicker, dan menjaga perilaku SSR tetap dapat diprediksi.
Singkatnya: satu keputusan render, satu snapshot data, satu output awal yang sama antara server dan client. Itu fondasi paling penting untuk SSR aman saat notice lisensi berubah.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!