SSR tanpa kejutan berarti HTML yang dirender server harus semirip mungkin dengan hasil render pertama di browser. Masalah muncul ketika UI SSR bergantung pada preferensi lokal yang hanya diketahui client, seperti theme dari localStorage, timezone, locale, prefers-reduced-motion, atau pilihan layout density. Jika server menebak berbeda dari browser, React bisa memunculkan hydration warning, mengganti DOM, atau menampilkan flicker yang terlihat pengguna.
Solusi utamanya bukan “membungkam warning”, tetapi membuat sumber kebenaran lebih konsisten. Praktiknya biasanya jatuh ke tiga strategi: defer ke client untuk preferensi yang tidak kritikal saat render awal, persist preferensi ke cookie/header agar server bisa merender nilai yang sama, dan gunakan fallback UI yang stabil saat nilai asli belum diketahui. Ini sejalan dengan gagasan software yang dapat diprediksi: lebih baik perilaku sederhana dan konsisten daripada UI “pintar” yang sering berubah bentuk di antara server dan client.
Pengingat yang relevan dari tren seperti “AI-free Vim”: pengguna sering lebih menghargai software yang predictable daripada fitur tambahan yang membuat perilaku sulit ditebak. Dalam konteks SSR, prediktabilitas berarti output server dan client tidak saling bertentangan.
Apa sebenarnya yang menyebabkan render mismatch?
Hydration pada React bekerja dengan asumsi bahwa markup awal dari server mewakili hasil render pertama di client. Browser lalu “mengaitkan” event handler dan state React ke DOM yang sudah ada. Jika pada render pertama di browser React menghasilkan struktur atau teks yang berbeda, akan terjadi mismatch.
Penyebab paling umum saat membaca preferensi lokal adalah sebagai berikut:
- Server tidak punya akses ke API browser seperti
window,localStorage,matchMedia, atau timezone runtime pengguna. - Nilai default server berbeda dengan nilai nyata di client. Contoh: server merender mode terang, client langsung memilih mode gelap dari
localStorage. - Nilai berbasis lingkungan berbeda, misalnya format tanggal tergantung locale/timezone browser.
- Cabang render bergantung pada kondisi client-only, misalnya
if (window.matchMedia(...))yang mengubah struktur komponen. - Race condition antara script inisialisasi awal, hydration, dan update state setelah mount.
Efeknya tidak selalu sekadar warning. Dalam kasus tertentu, React akan membuang subtree dan merender ulang di client. Itu bisa menyebabkan:
- flash/flicker pada theme atau layout,
- event handler sempat tidak sinkron,
- CLS atau perubahan visual yang mengganggu,
- bug yang hanya muncul di production karena alur streaming, caching, atau timing berbeda.
Jenis preferensi lokal dan tingkat risikonya
1. Theme dan layout density
Ini paling sering memicu mismatch karena memengaruhi class, atribut, atau struktur layout. Jika server mengirim data-theme="light" tetapi client langsung berpindah ke dark, pengguna melihat kilatan visual.
2. Locale dan timezone
Locale sering bisa diperkirakan dari header seperti Accept-Language, tetapi timezone biasanya tidak tersedia secara andal pada request awal. Jika Anda memformat waktu “lokal pengguna” saat SSR tanpa sumber data yang konsisten, teks bisa berbeda saat hydration.
3. Reduced motion dan preferensi media lain
prefers-reduced-motion berasal dari matchMedia di browser. Jika kondisi ini menentukan apakah elemen dirender atau tidak, mismatch mudah terjadi. Lebih aman menjadikannya pengendali perilaku animasi di client, bukan pembeda struktur HTML utama.
Anti-pattern yang sering menyebabkan hydration bug
Membaca localStorage saat inisialisasi render
function ThemeLabel() {
const theme = typeof window !== 'undefined'
? localStorage.getItem('theme') || 'light'
: 'light';
return <span>Tema: {theme}</span>;
}Potongan di atas terlihat “aman” karena memeriksa window, tetapi tetap berisiko. Server akan merender light, sementara browser bisa langsung merender dark. Hasil render pertama berbeda.
Mengubah struktur UI berdasarkan matchMedia saat render awal
function MotionAwarePanel() {
const reduced = typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return reduced ? <StaticPanel /> : <AnimatedPanel />;
}Jika server tidak tahu nilai reduced, subtree yang dirender bisa berbeda total.
Memformat waktu lokal pengguna saat SSR tanpa sumber yang sama
function TimeLabel({ date }) {
return <span>{new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date)}</span>;
}undefined berarti locale dan timezone diambil dari environment saat itu. Di server dan browser, hasilnya bisa berbeda.
Menyelesaikan masalah dengan suppressHydrationWarning tanpa perbaikan arsitektur
suppressHydrationWarning kadang berguna untuk teks kecil yang memang tak bisa disamakan, tetapi itu bukan solusi umum. Warning hilang, sumber ketidakselarasan tetap ada.
Strategi yang aman: pilih berdasarkan jenis preferensi
1. Defer ke client untuk preferensi yang tidak kritikal pada HTML awal
Prinsipnya: server merender fallback yang stabil, lalu client membaca preferensi setelah mount. Ini cocok untuk informasi yang tidak perlu akurat pada first paint, atau yang tidak layak memengaruhi struktur SSR.
import { useEffect, useState } from 'react';
function ClientThemeLabel() {
const [theme, setTheme] = useState(null);
useEffect(() => {
setTheme(localStorage.getItem('theme') || 'light');
}, []);
return <span>Tema: {theme ?? 'memuat...'}</span>;
}Mengapa ini bekerja? Karena HTML dari server dan render pertama client sama-sama menampilkan fallback memuat.... Perubahan ke nilai asli terjadi setelah hydration, jadi bukan mismatch.
Trade-off:
- Ada fase placeholder atau update setelah mount.
- Jika preferensi memengaruhi CSS global, pengguna mungkin melihat flicker.
- Tidak ideal untuk theme utama halaman jika ingin pengalaman visual mulus sejak byte pertama.
2. Gunakan cookie agar SSR dan client membaca nilai yang sama
Untuk preferensi yang memengaruhi tampilan awal secara signifikan, cookie biasanya pilihan paling praktis. Client menyimpan preferensi ke cookie, lalu request berikutnya membawa nilai itu ke server sehingga SSR dapat merender HTML yang konsisten.
Contoh alur:
- Pengguna memilih dark mode.
- Client menyimpan
theme=darkke cookie. - Pada request berikutnya, server membaca cookie dan menambahkan atribut
data-theme="dark"ke root HTML. - Client hydration memakai nilai awal yang sama, sehingga tidak ada mismatch.
Contoh pola umum di React/Next.js
// server-side: baca dari cookie/request
export function getInitialThemeFromRequest(cookieTheme) {
return cookieTheme === 'dark' ? 'dark' : 'light';
}
// shared component
import { useEffect, useState } from 'react';
export function ThemeProviderLike({ initialTheme, children }) {
const [theme, setTheme] = useState(initialTheme);
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.cookie = `theme=${theme}; path=/; max-age=31536000; samesite=lax`;
try {
localStorage.setItem('theme', theme);
} catch {}
}, [theme]);
return children;
}Kunci utamanya adalah initialTheme berasal dari request server, bukan dari tebakan client-only. localStorage boleh tetap dipakai sebagai cache lokal, tetapi jangan dijadikan satu-satunya sumber kebenaran jika SSR harus konsisten.
Kapan memilih cookie?
- Theme global.
- Layout density yang mengubah class atau spacing besar.
- Locale pilihan pengguna jika tidak hanya mengandalkan negosiasi browser.
3. Gunakan header untuk bootstrap preferensi yang bisa diperkirakan
Beberapa preferensi dapat didekati dari header request, misalnya locale dari Accept-Language. Ini berguna untuk render awal, meskipun tetap ada keterbatasan:
- Locale: relatif mudah diperkirakan, tetapi belum tentu sama dengan pilihan eksplisit pengguna di aplikasi.
- Timezone: umumnya tidak tersedia secara andal pada request awal biasa.
- Reduced motion: tidak aman diasumsikan kecuali Anda punya mekanisme eksplisit dari client.
Karena itu, header cocok sebagai best effort default, lalu bisa dipertegas oleh cookie jika pengguna pernah memilih preferensi tertentu.
4. Pisahkan “nilai render” dan “nilai perilaku”
Kesalahan umum adalah memakai preferensi client-only untuk menentukan apakah elemen dirender sama sekali. Sering kali yang benar adalah tetap merender struktur yang sama, lalu menyesuaikan perilaku setelah mount.
Contoh untuk prefers-reduced-motion:
- Kurang aman: pilih antara dua subtree berbeda saat render.
- Lebih aman: render subtree yang sama, tetapi matikan animasi via class/CSS atau state efek setelah mount.
import { useEffect, useState } from 'react';
function Panel() {
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
setReducedMotion(media.matches);
}, []);
return <div className={reducedMotion ? 'no-motion' : 'motion'}>Konten</div>;
}Markup tetap sama, sehingga risiko mismatch jauh lebih rendah.
Pola implementasi yang disarankan di Next.js
Detail API bisa berbeda tergantung router dan struktur aplikasi, tetapi prinsipnya tetap sama: baca preferensi stabil dari request di server, kirim sebagai nilai awal, dan hindari pembacaan browser-only pada render pertama jika output harus cocok.
Theme tanpa flicker
Untuk theme, target terbaik biasanya adalah menempelkan atribut atau class pada elemen root sedini mungkin. Ada dua pendekatan utama:
- SSR dari cookie: paling konsisten untuk request berikutnya.
- Inline bootstrap script: dipakai untuk membaca preferensi client sebelum hydration jika Anda belum punya cookie. Ini mengurangi flicker, tetapi tetap perlu hati-hati agar output SSR dan state React tidak saling bertabrakan.
Jika Anda memakai bootstrap script, pastikan script itu hanya menyetel class/atribut visual awal, dan state React awal tetap disejajarkan dengan nilai yang sama pada saat hydration. Jika tidak, Anda hanya memindahkan masalah ke tempat lain.
Locale yang konsisten
Untuk locale, urutan prioritas yang umum dan masuk akal:
- pilihan eksplisit pengguna dari cookie atau profil,
- header
Accept-Languagesebagai fallback, - default aplikasi.
Hindari memformat teks berdasarkan locale browser secara implisit di komponen yang ikut SSR jika server tidak memakai locale yang sama.
Timezone: jangan pura-pura tahu
Timezone adalah contoh bagus kapan harus memilih fallback. Jika aplikasi benar-benar butuh waktu dalam zona pengguna, ada beberapa opsi:
- render waktu dalam UTC atau timezone server pada SSR, lalu perbarui di client,
- minta client mengirim timezone ke server pada kunjungan berikutnya dan simpan di cookie/profil,
- tampilkan tanggal absolut terlebih dahulu, lalu tingkatkan ke format lokal setelah mount.
Yang penting: jangan merender teks “jam lokal pengguna” di server jika server tidak punya data yang sama.
Fallback UI yang baik tidak memicu kejutan
Fallback yang aman harus memenuhi dua sifat:
- stabil saat SSR dan hydration awal,
- cukup netral sehingga transisi ke nilai asli tidak terasa seperti bug.
Contoh fallback yang baik:
- placeholder singkat untuk label preferensi,
- theme default yang juga paling umum di pengguna baru,
- format waktu netral seperti ISO singkat atau UTC sebelum client meningkatkan tampilan,
- struktur layout yang sama, hanya style minor yang berubah setelah mount.
Contoh fallback yang buruk:
- server merender sidebar padat, client langsung mengganti ke layout longgar dengan tinggi elemen jauh berbeda,
- server menampilkan teks tanggal dalam bahasa A, client langsung ganti bahasa B,
- server merender komponen animasi kompleks, client membuangnya total karena reduced motion aktif.
Contoh praktis: theme, locale, timezone, reduced motion
Theme: terbaik jika dari cookie
// pseudo server code
const themeFromCookie = readCookie(req, 'theme');
const initialTheme = themeFromCookie === 'dark' ? 'dark' : 'light';
// pada HTML root
<html data-theme={initialTheme}>import { useState, useEffect } from 'react';
function ThemeToggle({ initialTheme }) {
const [theme, setTheme] = useState(initialTheme);
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.cookie = `theme=${theme}; path=/; max-age=31536000; samesite=lax`;
}, [theme]);
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? 'Gunakan Light' : 'Gunakan Dark'}
</button>
);
}Locale: pilih sumber eksplisit
// prioritas: cookie => header => default
function resolveLocale({ cookieLocale, acceptLanguage }) {
if (cookieLocale) return cookieLocale;
if (acceptLanguage?.startsWith('id')) return 'id-ID';
return 'en-US';
}Lalu gunakan locale yang sama saat SSR dan pada provider i18n di client.
Timezone: render netral, upgrade di client
import { useEffect, useState } from 'react';
function LocalTime({ isoString }) {
const [text, setText] = useState(() => {
return new Date(isoString).toISOString();
});
useEffect(() => {
const formatted = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(isoString));
setText(formatted);
}, [isoString]);
return <time dateTime={isoString}>{text}</time>;
}Server dan hydration awal sama-sama menampilkan ISO. Setelah mount, teks dinaikkan ke format lokal pengguna.
Reduced motion: ubah perilaku, bukan struktur
import { useEffect, useState } from 'react';
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
setReduced(media.matches);
const onChange = () => setReduced(media.matches);
media.addEventListener?.('change', onChange);
return () => media.removeEventListener?.('change', onChange);
}, []);
return reduced;
}Gunakan hook ini untuk mengatur class animasi atau durasi transisi, bukan untuk memilih dua pohon DOM yang berbeda jauh.
Kapan boleh sepenuhnya client-only?
Tidak semua komponen harus ikut SSR. Jika preferensi lokal sangat menentukan isi komponen dan tidak penting untuk SEO atau first paint, Anda bisa menjadikannya client-only. Misalnya panel pengaturan personal yang baru berguna setelah aplikasi interaktif.
Pilih client-only jika:
- komponen tidak penting untuk konten awal,
- preferensi sulit diketahui di server,
- fallback SSR justru membingungkan,
- Anda lebih memilih konsistensi daripada menebak di server.
Trade-off: TTI bisa terasa lebih lambat untuk komponen itu, dan konten tidak tersedia saat HTML awal dikirim.
Checklist debugging render mismatch
Periksa apakah output server dan render pertama client benar-benar sama
- Apakah ada akses
window,document,localStorage, ataumatchMediadi jalur render? - Apakah ada
new Date(),Intl, random value, atau ID yang berubah di server dan client? - Apakah locale/timezone implisit berbeda antar environment?
- Apakah class root seperti
darkataudata-themediubah sebelum hydration tetapi state React awal tidak tahu perubahan itu?
Cari anti-pattern tersembunyi di dependency atau helper
Sering kali bug bukan di komponen utama, melainkan di utility seperti:
- helper format tanggal yang memakai locale default environment,
- hook media query yang mengembalikan nilai berbeda saat SSR,
- provider theme yang membaca
localStorageterlalu awal, - komponen pihak ketiga yang mengukur viewport saat render.
Verifikasi di development dan production
Jangan berhenti di mode development. Hydration bug bisa lebih terlihat atau justru lebih samar di production.
- Di development: buka console dan cari hydration warning React.
- Lihat HTML server mentah: gunakan View Source atau inspeksi response awal, bukan hanya DOM setelah script berjalan.
- Bandingkan dengan DOM setelah hydration: cek teks, atribut, dan class penting seperti theme atau locale marker.
- Uji hard refresh: terutama untuk kasus cookie vs localStorage.
- Uji first visit dan returning visit: first visit sering tidak punya cookie; returning visit punya.
- Uji dengan JS lambat atau throttling: flicker dan mismatch visual lebih mudah terlihat.
- Jalankan build production lokal: verifikasi alur SSR sebenarnya, bukan hanya hot reload development.
Langkah verifikasi praktis
# contoh alur umum
# 1. jalankan aplikasi dalam mode production
# 2. buka halaman dengan DevTools aktif
# 3. cek console untuk hydration warning
# 4. hard refresh beberapa kali
# 5. ubah cookie/localStorage/theme lalu ulangi
# 6. throttle CPU/network untuk melihat flickerJika memungkinkan, tambahkan logging sementara untuk nilai yang dipakai server dan client pada render awal, misalnya initialTheme, locale terpilih, atau apakah komponen memakai fallback.
Panduan keputusan singkat
- Theme global? Gunakan cookie untuk SSR konsisten; localStorage hanya pelengkap.
- Locale? Utamakan pilihan eksplisit pengguna, fallback ke
Accept-Language. - Timezone? Jangan asumsikan pada SSR; render netral lalu upgrade di client, atau simpan timezone dari kunjungan sebelumnya.
- Reduced motion? Gunakan untuk perilaku/style setelah mount, bukan pembeda struktur HTML utama.
- Density/layout preference? Jika mengubah layout besar, perlakukan seperti theme: simpan di cookie agar SSR tahu nilainya.
Penutup
Mencegah mismatch dari preferensi lokal bukan soal trik React semata, tetapi soal desain aliran data yang jujur. Server hanya boleh merender berdasarkan data yang benar-benar ia miliki. Jika preferensi hanya ada di browser, pilih antara menunda keputusan ke client atau mengirim preferensi itu ke server lewat cookie/header untuk request berikutnya.
Hasil akhirnya adalah SSR tanpa kejutan: HTML awal stabil, hydration tenang, UI tidak berkedip tanpa alasan, dan perilaku aplikasi lebih mudah dipahami. Dalam banyak kasus, ini lebih berharga daripada menambahkan lapisan “kepintaran” yang membuat output sulit diprediksi. Untuk software yang dipakai setiap hari, konsistensi hampir selalu menang.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!