SSR hydration mismatch terjadi ketika HTML hasil render di server tidak sama dengan hasil render awal di browser. Gejalanya sering muncul sebagai peringatan seperti Text content did not match, UI berkedip saat mount, event handler terasa tidak konsisten, atau komponen dirender ulang penuh di client.
Penyebab utamanya hampir selalu sama: ada logika render yang rapuh dan tidak deterministik. Artinya, output komponen bergantung pada nilai yang berbeda antara server dan client, misalnya Date.now(), Math.random(), akses window saat render, locale/timezone berbeda, atau markup yang berubah berdasarkan state yang hanya tersedia di browser. Solusinya bukan sekadar menyembunyikan warning, tetapi mengganti sumber logika rapuh dengan pola yang membuat server dan client sepakat pada render awal.
Pendekatan ini mirip dengan audit API rapuh di sistem yang lebih besar: daripada membiarkan fungsi berisiko tetap dipakai lalu menambal efek sampingnya, lebih baik hilangkan pola penyebabnya. Pada SSR, targetnya adalah satu aturan sederhana: render pertama di server dan client harus menghasilkan markup yang sama.
Mengapa hydration mismatch terjadi
Dalam alur SSR, server lebih dulu menghasilkan HTML. Browser lalu memuat JavaScript dan melakukan hydration, yaitu memasangkan struktur React dengan HTML yang sudah ada. Agar proses ini aman, React mengasumsikan struktur dan isi HTML awal cocok dengan hasil render pertama di browser.
Jika render awal di client menghasilkan output berbeda, React akan memberi warning dan bisa mengambil beberapa tindakan pemulihan, tergantung kasusnya. Efeknya antara lain:
- teks berubah setelah mount, terlihat seperti flicker,
- DOM dibongkar dan dirender ulang,
- state atau event binding terasa aneh,
- debugging menjadi sulit karena masalah hanya muncul pada environment tertentu.
Masalah ini bukan khusus Next.js. Polanya berlaku di React SSR secara umum, termasuk framework lain yang melakukan pre-render di server lalu hydrate di browser.
Sumber mismatch yang paling sering dan cara menggantinya
1. Nilai waktu dinamis: Date.now() dan new Date() saat render
Ini salah satu sumber mismatch paling umum. Jika komponen memanggil Date.now() saat render, server dan client hampir pasti menghasilkan nilai berbeda meski hanya selisih milidetik.
// Rapuh: output pasti berpotensi berbeda antara server dan client
export function Header() {
return <p>Dirender pada: {Date.now()}</p>;
}Perbaikan: hitung nilainya di server lalu kirim sebagai props, atau tunda tampilan yang benar-benar dinamis sampai setelah mount.
// Lebih aman: nilai waktu ditentukan sekali di server
export function Header({ renderedAt }) {
return <p>Dirender pada: {renderedAt}</p>;
}Jika Anda memang perlu menampilkan waktu lokal pengguna, render placeholder yang stabil dulu, lalu isi setelah hydration.
import { useEffect, useState } from 'react';
export function LocalClock() {
const [text, setText] = useState('Memuat waktu lokal...');
useEffect(() => {
setText(new Date().toLocaleString());
}, []);
return <p>{text}</p>;
}Mengapa ini bekerja: render awal server dan client sama-sama menghasilkan Memuat waktu lokal.... Nilai dinamis baru dihitung setelah hydration selesai.
2. Nilai acak: Math.random()
Math.random() saat render adalah versi lain dari masalah yang sama. Nilai acak di server tidak akan sama dengan nilai acak di browser.
// Rapuh
export function PromoBadge() {
const variant = Math.random() > 0.5 ? 'A' : 'B';
return <span>Variant {variant}</span>;
}Pilih salah satu dari tiga strategi berikut:
- Deterministik dari data: gunakan ID atau hash dari data yang sama di server dan client.
- Hitung di server: pilih varian di server dan serialisasikan ke props.
- Tunda ke client: jika memang harus acak per browser, jangan ikut dalam render SSR awal.
// Lebih aman: varian berasal dari data yang konsisten
export function PromoBadge({ userId }) {
const variant = Number(userId) % 2 === 0 ? 'A' : 'B';
return <span>Variant {variant}</span>;
}3. Akses window, document, localStorage saat render
Server tidak punya API browser seperti window atau localStorage. Bahkan jika Anda menambahkan pengecekan typeof window !== 'undefined', markup yang dihasilkan bisa tetap berbeda antara server dan client.
// Rapuh: cabang render berubah antara server dan client
export function ThemeLabel() {
const theme = typeof window !== 'undefined'
? localStorage.getItem('theme')
: 'light';
return <span>Tema: {theme}</span>;
}Di server, hasilnya bisa light. Di browser, render pertama bisa langsung dark. Itu sudah cukup untuk memicu mismatch.
Perbaikan 1: serialisasi state awal dari server. Jika tema diketahui dari cookie, session, atau preferensi user di backend, kirim nilainya ke komponen.
export function ThemeLabel({ initialTheme }) {
return <span>Tema: {initialTheme}</span>;
}Perbaikan 2: baca localStorage setelah mount.
import { useEffect, useState } from 'react';
export function ThemeLabel() {
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return <span>Tema: {theme}</span>;
}Pola kedua aman untuk hydration, tetapi ada trade-off: pengguna mungkin melihat nilai default sesaat sebelum state aktual dimuat. Jika ini menyangkut tema visual seluruh halaman, lebih baik sinkronkan dari server agar tidak terjadi flash.
4. Perbedaan locale dan timezone
Format tanggal, angka, mata uang, dan string lokal bisa berbeda karena server dan browser berjalan di locale atau timezone yang tidak sama. Komponen yang memanggil toLocaleString() saat render sering terlihat benar di development, lalu mulai mismatch setelah deploy ke server dengan timezone berbeda.
// Berisiko: format dapat berbeda antar environment
export function Price({ amount }) {
return <span>{amount.toLocaleString()}</span>;
}Perbaikan:
- formatkan di server dan kirim string final sebagai props, atau
- gunakan locale/timezone yang eksplisit dan konsisten di kedua sisi, atau
- tunda formatting yang berbasis locale pengguna sampai setelah mount.
// Aman: string sudah diformat sebelum render komponen
export function Price({ formattedAmount }) {
return <span>{formattedAmount}</span>;
}Jika format harus mengikuti locale browser pengguna, gunakan placeholder stabil dan update di useEffect. Jangan mengandalkan default locale environment.
5. Conditional markup berbasis state yang hanya ada di client
Masalah ini terjadi saat struktur elemen berubah berdasarkan data yang baru tersedia di browser, seperti ukuran viewport, status login dari storage, kemampuan perangkat, atau media query yang dihitung saat render.
// Rapuh: server tidak tahu viewport browser pengguna
export function Navigation() {
const isMobile = window.innerWidth < 768;
return isMobile
? <button>Menu</button>
: <nav>...link desktop...</nav>;
}Ini bukan hanya masalah akses window. Bahkan jika dibungkus guard, struktur markup tetap bisa berbeda.
Perbaikan:
- utamakan CSS responsif daripada conditional markup saat render,
- jika benar-benar perlu layout berbeda, render shell yang sama dulu lalu ubah setelah mount,
- atau pisahkan komponen client-only.
// Lebih aman: struktur dasar sama, responsif diatur CSS
export function Navigation() {
return (
<nav className="nav">
<button className="nav-mobile">Menu</button>
<div className="nav-desktop">...link desktop...</div>
</nav>
);
}Strategi pengganti yang stabil untuk SSR hydration
1. Serialisasikan state awal agar server dan client konsisten
Jika nilai awal diketahui saat request diproses, jadikan itu sumber kebenaran tunggal. Contohnya:
- tema dari cookie,
- bahasa dari route atau header,
- user session dari backend,
- timestamp render dari server,
- hasil eksperimen A/B yang dipilih di edge atau server.
Prinsipnya sederhana: jangan hitung ulang nilai awal secara berbeda di browser bila server sudah mengetahuinya.
Pada Next.js atau SSR lain, ini biasanya berarti mengambil data di layer server lalu meneruskannya ke komponen sebagai props atau state awal yang diserialisasikan ke HTML.
2. Pindahkan logika client-only ke useEffect
useEffect hanya berjalan di browser setelah render awal. Karena itu ia cocok untuk:
- akses
windowdandocument, - membaca
localStorageatausessionStorage, - menghitung waktu lokal pengguna,
- membaca ukuran viewport,
- sinkronisasi dengan API browser.
Namun, jangan memindahkan semuanya ke useEffect tanpa pertimbangan. Trade-off utamanya adalah UI awal bisa menampilkan placeholder atau nilai default sesaat. Untuk data penting bagi pengalaman awal, lebih baik sinkronkan nilainya dari server.
3. Gunakan komponen client-only bila SSR memang tidak relevan
Beberapa widget memang bergantung penuh pada browser, misalnya editor visual, peta interaktif, atau komponen yang berat dan tidak memberi nilai SEO pada render server. Dalam kasus seperti ini, mematikan SSR pada komponen spesifik bisa lebih bersih daripada memaksa output awal sinkron.
// Contoh pola di Next.js
import dynamic from 'next/dynamic';
const ClientOnlyChart = dynamic(() => import('./Chart'), {
ssr: false,
});
export default function Dashboard() {
return <ClientOnlyChart />;
}Kapan dipilih: saat komponen benar-benar tidak berguna di server atau sangat tergantung API browser.
Trade-off: konten tidak tersedia di HTML awal, SEO berkurang untuk bagian itu, dan pengguna mungkin melihat loading state lebih lama.
4. Gunakan placeholder yang sengaja stabil
Jika nilai akhir hanya bisa diketahui di browser, render dulu placeholder yang identik di server dan client. Ini lebih baik daripada merender dua nilai berbeda dan berharap React memperbaikinya.
import { useEffect, useState } from 'react';
export function TimezoneNotice() {
const [timezone, setTimezone] = useState(null);
useEffect(() => {
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
return <p>Zona waktu: {timezone ?? 'mendeteksi...'}</p>;
}Placeholder stabil sangat berguna untuk teks, badge, label preferensi, dan metadata ringan. Untuk layout besar, pertimbangkan skeleton agar perubahan visual setelah mount tidak terasa mengganggu.
Contoh audit komponen: before dan after
Berikut contoh komponen yang menggabungkan beberapa sumber mismatch sekaligus.
// BEFORE: banyak sumber mismatch dalam satu komponen
export function WelcomeCard() {
const name = typeof window !== 'undefined'
? localStorage.getItem('name')
: 'Guest';
const greeting = new Date().getHours() < 12 ? 'Pagi' : 'Sore';
const lucky = Math.random() > 0.5 ? '🍀' : '⭐';
return (
<section>
<h2>Selamat {greeting}, {name}! {lucky}</h2>
</section>
);
}Masalahnya:
localStoragedibaca saat render,- waktu lokal browser dan server bisa berbeda,
Math.random()tidak deterministik.
Versi yang lebih aman:
import { useEffect, useState } from 'react';
export function WelcomeCard({ initialName, initialGreeting, initialLucky }) {
const [name, setName] = useState(initialName);
useEffect(() => {
const saved = localStorage.getItem('name');
if (saved) setName(saved);
}, []);
return (
<section>
<h2>Selamat {initialGreeting}, {name}! {initialLucky}</h2>
</section>
);
}Di sini:
initialGreetingdaninitialLuckyditentukan sekali di server,initialNamemenjadi fallback yang stabil,- nama dari
localStoragebaru diambil setelah mount.
Jika nama juga bisa diketahui dari cookie atau session server, bahkan lebih baik kirim nilai final itu sejak awal agar tidak ada perubahan teks setelah hydration.
Checklist audit untuk menemukan logika rapuh
Saat mengaudit komponen SSR, telusuri fungsi render dan tanyakan pertanyaan berikut:
- Apakah komponen memakai nilai waktu saat render?
CariDate.now(),new Date(),toLocaleString(), atau perbandingan berbasis jam. - Apakah ada nilai acak?
CariMath.random(), generator ID acak, atau pemilihan varian yang tidak berbasis data deterministik. - Apakah ada akses API browser saat render?
Cariwindow,document,navigator,localStorage,sessionStorage,matchMedia. - Apakah formatting bergantung pada locale/timezone default?
Jangan mengandalkan environment default bila hasilnya akan tampil di HTML. - Apakah struktur markup berubah berdasarkan state client-only?
Termasuk viewport, preferensi pengguna di storage, status feature browser, dan kemampuan perangkat. - Apakah ada inisialisasi state yang berbeda antara server dan client?
MisalnyauseState(() => window.innerWidth)atau membaca storage di initializer. - Apakah ada library pihak ketiga yang menghasilkan ID atau DOM berbeda di client?
Audit komponen eksternal yang memanipulasi DOM atau mengandalkan browser saat render.
Jika jawaban untuk salah satu poin di atas adalah ya, anggap itu kandidat mismatch sampai terbukti aman.
Debugging: cara melokalisasi sumber mismatch
Bandingkan output render awal, bukan hanya state akhir
Banyak developer memeriksa UI setelah mount dan mengira semuanya baik-baik saja. Padahal mismatch terjadi pada render awal. Fokuskan debugging pada nilai yang dipakai sebelum useEffect berjalan.
Tambahkan logging yang memisahkan server dan client
Log nilai yang dicurigai saat render di kedua sisi. Misalnya, tambahkan penanda sederhana untuk mengetahui apakah log berasal dari server atau browser, lalu bandingkan:
const env = typeof window === 'undefined' ? 'server' : 'client';
console.log(env, { value });Jika nilai berbeda pada render pertama, itulah sumber mismatch.
Audit subtree yang warning-nya paling dekat
Warning React sering menunjuk area DOM yang mismatch, walau akar masalahnya bisa sedikit di atasnya. Mulailah dari komponen terkecil yang merender teks atau atribut berbeda, lalu telusuri ke atas.
Curigai formatting lokal dan conditional rendering
Mismatch tidak selalu terlihat sebagai crash. Kadang hanya satu string tanggal, simbol mata uang, atau urutan child element yang berbeda. Ini cukup untuk memicu warning.
Kapan memakai useEffect, ssr: false, atau serialisasi state awal
Pilih serialisasi state awal jika
- nilainya sudah diketahui saat request diproses,
- konten penting untuk SEO atau first paint,
- Anda ingin menghindari flicker.
Contoh: tema dari cookie, status login, preferensi bahasa dari route, timestamp server, hasil A/B testing yang ditentukan server.
Pilih useEffect jika
- nilainya hanya tersedia di browser,
- perubahan setelah mount bisa diterima,
- komponen masih layak dirender dengan placeholder stabil.
Contoh: timezone lokal, viewport, nilai dari localStorage yang tidak kritikal, integrasi API browser.
Pilih ssr: false jika
- komponen sangat tergantung browser,
- SSR tidak memberi manfaat berarti untuk komponen tersebut,
- memaksa SSR justru menambah kompleksitas dan risiko mismatch.
Contoh: chart interaktif berat, editor berbasis DOM, widget peta, komponen yang memakai library browser-only.
Kesalahan umum yang perlu dihindari
- Menyembunyikan warning tanpa memperbaiki akar masalah. Warning bisa hilang, tetapi UI tetap flicker atau tidak konsisten.
- Menganggap guard
typeof windowsudah cukup. Guard mencegah crash di server, tetapi tidak menjamin output HTML sama. - Menggunakan nilai default berbeda dari state aktual. Ini memang menghindari crash, tetapi tetap bisa menimbulkan perubahan visual setelah hydration.
- Mengandalkan locale atau timezone default environment. Server produksi sering berbeda dari mesin development Anda.
- Mencampur tanggung jawab SSR dan client-only dalam satu render path. Lebih mudah dirawat jika dipisahkan tegas.
Penutup
Cara paling efektif menghilangkan SSR hydration mismatch bukan menambal gejalanya, tetapi mengganti logika UI yang rapuh. Jika output render bergantung pada waktu saat ini, angka acak, API browser, locale default, atau state yang hanya ada di client, maka server dan browser tidak punya kesempatan untuk sepakat.
Prinsip praktisnya sederhana:
- buat render awal deterministik,
- serialisasikan state awal bila server sudah tahu nilainya,
- pindahkan logika browser-only ke
useEffect, - gunakan
ssr: falsehanya untuk komponen yang memang client-only, - audit komponen seperti Anda mengaudit API rapuh: cari pola yang rawan salah, lalu ganti dengan pola yang konsisten.
Begitu sumber ketidakpastian dihapus, warning hydration biasanya ikut hilang, UI lebih stabil, dan debugging SSR menjadi jauh lebih masuk akal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!