Pada aplikasi React/Next.js dengan SSR, bug yang paling membingungkan sering bukan crash, melainkan UI yang tampak berasal dari dua semesta berbeda: server merender satu versi, lalu saat hydration di browser, React membangun versi lain. Akibatnya, pengguna melihat teks berubah sesaat, layout bergeser, tombol tiba-tiba aktif/nonaktif, atau event handler terasa tidak konsisten.
Masalah ini biasanya bukan karena SSR itu sendiri, tetapi karena state awal yang tidak stabil. Jika input render pertama di server berbeda dengan input render pertama di client, maka markup awal tidak lagi deterministik. Untuk mencegah UI SSR bercabang, kuncinya adalah memastikan bahwa render awal di kedua sisi memakai data, kondisi, dan fallback yang sama.
Mengapa UI SSR bisa “bercabang” saat hydration
SSR bekerja dengan dua tahap yang harus selaras:
- Server render: HTML awal dibuat di server.
- Client hydration: React di browser mengambil alih HTML yang sudah ada dan menghubungkannya dengan komponen serta event handler.
Hydration berjalan baik bila hasil render pertama di client secara logika sama dengan hasil render server. Jika tidak, React harus memperbaiki perbedaan tersebut. Efeknya bisa berupa:
- peringatan hydration mismatch di console,
- konten yang berganti sesaat,
- layout shift,
- komponen yang di-remount,
- event handler yang terpasang ulang.
Analogi “multiverse” cocok secara editorial: satu komponen tampak punya dua realitas awal. Namun secara teknis, akar masalahnya sederhana: render awal tidak deterministik.
Penyebab umum state awal yang tidak stabil
1. Mengakses window atau localStorage terlalu dini
Server tidak punya window dan localStorage. Masalah yang lebih halus muncul saat komponen membuat keputusan render berdasarkan nilai browser-only pada render pertama.
Pola salah:
function ThemeLabel() {
const theme = localStorage.getItem('theme') || 'light';
return <span>Tema: {theme}</span>;
}Kode ini gagal di server, atau jika dibungkus pengecekan, tetap berpotensi mismatch karena server dan client membaca nilai berbeda pada render awal.
Pola lebih aman:
import { useEffect, useState } from 'react';
function ThemeLabel() {
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = window.localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return <span>Tema: {theme}</span>;
}Mengapa ini bekerja? Karena server dan client sama-sama merender light pada render awal. Setelah hydration selesai, browser boleh memperbarui state ke nilai aktual.
Jika perubahan setelah mount menyebabkan flash yang mengganggu, gunakan placeholder yang stabil atau kirim nilai tema dari server lewat cookie agar render awal tetap konsisten.
2. Nilai waktu, locale, atau data acak
Pemanggilan seperti Date.now(), new Date(), Math.random(), atau formatting locale yang bergantung pada environment bisa menghasilkan output berbeda antara server dan client.
Pola salah:
function Greeting() {
const hour = new Date().getHours();
return <p>{hour < 12 ? 'Selamat pagi' : 'Selamat sore'}</p>;
}Jika server merender pada jam berbeda, zona waktu berbeda, atau locale berbeda dari browser pengguna, UI awal bisa berubah saat hydration.
Pola benar:
- Hitung nilai itu di server lalu kirim sebagai prop yang dipakai juga oleh client pada render awal.
- Atau tampilkan placeholder stabil, lalu hitung ulang di client setelah mount jika memang harus sangat personal.
function Greeting({ initialGreeting }) {
return <p>{initialGreeting}</p>;
}Prinsipnya: jangan menghasilkan nilai non-deterministik langsung di fungsi render jika nilai itu memengaruhi markup awal.
3. Feature flag asinkron datang terlambat
Feature flag sering diambil dari SDK client, API terpisah, atau remote config. Jika server merender tombol baru sebagai mati, tetapi client setelah hydration langsung mengaktifkannya, pengguna melihat UI “meloncat”.
Pola salah: komponen utama langsung bercabang berdasarkan flag yang baru diketahui di browser.
function CheckoutButton() {
const enabled = useFeatureFlag('new_checkout');
return enabled ? <NewCheckout /> : <OldCheckout />;
}Masalahnya ada pada implementasi useFeatureFlag. Jika pada server nilainya fallback false, tetapi di client cepat berubah ke true, markup awal berbeda.
Pendekatan lebih aman:
- Resolusi flag di server jika memungkinkan.
- Gunakan bootstrap data yang sama untuk server dan client.
- Jika flag memang hanya tersedia di client, render placeholder netral sampai statusnya siap.
function CheckoutEntry({ initialFlag }) {
const [enabled, setEnabled] = useState(initialFlag);
useEffect(() => {
// sinkronkan dengan SDK/client source jika perlu
}, []);
if (enabled == null) {
return <button disabled>Memuat checkout...</button>;
}
return enabled ? <NewCheckout /> : <OldCheckout />;
}4. Pembacaan URL atau query yang berbeda
State awal sering bercabang berdasarkan location.search, hash, atau path yang dibaca langsung di browser. Pada SSR, sumber URL biasanya berasal dari request server. Jika logikanya tidak memakai sumber yang sama, hasil render dapat berbeda.
Kesalahan umum:
- membaca
window.location.searchlangsung di render, - mengandalkan hash fragment untuk menentukan UI awal,
- menganggap query param di client selalu identik dengan yang diproses server.
Pendekatan yang lebih stabil:
- Gunakan parameter URL yang sudah diparsing di layer SSR lalu kirim ke komponen.
- Jika nilai hanya tersedia di browser, jangan jadikan penentu markup awal; jadikan enhancement setelah mount.
5. Auth state datang belakangan
Skenario klasik: server belum tahu pengguna login atau tidak, sehingga merender tombol Masuk. Setelah hydration, client membaca token atau memanggil endpoint sesi, lalu navbar berubah menjadi avatar pengguna.
Ini bukan sekadar perubahan kosmetik. Jika ukuran komponen berbeda, layout juga ikut bergeser.
Pola yang lebih baik:
- Jika memungkinkan, tentukan auth state di server dari cookie atau sesi request.
- Jika belum bisa, render shell yang netral dan ukurannya stabil, misalnya placeholder avatar/nav.
- Hindari percabangan tajam pada render pertama jika sumber kebenaran auth baru tersedia setelah mount.
function UserNav({ initialSession }) {
const [session, setSession] = useState(initialSession);
useEffect(() => {
// refresh session jika diperlukan
}, []);
if (session === undefined) {
return <div style={{ width: 120, height: 32 }} aria-hidden="true" />;
}
return session ? <UserMenu user={session.user} /> : <LoginButton />;
}6. Conditional rendering yang tidak deterministik
Kadang akar masalahnya bukan API browser, melainkan kondisi render yang tidak stabil, misalnya:
- branch berdasarkan hasil perhitungan yang berubah,
- branch berdasarkan urutan object yang tidak dijamin,
- branch berdasarkan state global yang belum selesai diinisialisasi,
- branch berdasarkan data yang kadang
undefineddi server tetapi tersedia di client.
Contoh gejalanya: server merender daftar kosong, client langsung merender tiga item; server merender tombol disabled, client langsung enabled.
Aturan praktisnya: jika sebuah kondisi menentukan bentuk DOM awal, maka sumber datanya harus stabil pada server dan client.
Pola salah vs benar untuk mencegah UI SSR bercabang
Salah: menjadikan browser sebagai sumber kebenaran render awal
function Sidebar() {
const collapsed = window.innerWidth < 768;
return collapsed ? <MobileSidebar /> : <DesktopSidebar />;
}Selain tidak aman di server, pola ini menyebabkan perbedaan markup awal berdasarkan ukuran viewport client.
Benar: gunakan CSS untuk responsivitas, bukan percabangan DOM awal
function Sidebar() {
return <aside className="sidebar">...</aside>;
}Biarkan CSS menangani perbedaan tampilan. Pilih percabangan DOM hanya jika benar-benar perlu dan datanya deterministik.
Salah: membuat nilai awal dari ekspresi non-deterministik
const [id] = useState(() => Math.random().toString(36));Jika nilai itu tampil di DOM, mismatch sangat mungkin terjadi.
Benar: terima nilai dari server atau buat setelah mount bila tidak memengaruhi SSR
function StableBox({ initialId }) {
return <div data-id={initialId}>Konten</div>;
}Strategi placeholder dan loading yang tidak memicu mismatch
Sering kali data memang belum tersedia secara sinkron saat SSR. Solusinya bukan memaksa branch berbeda, tetapi memakai placeholder yang stabil.
Kapan placeholder lebih tepat
- Auth state baru diketahui setelah request tambahan.
- Feature flag baru tersedia dari SDK client.
- Preference pengguna hanya ada di storage browser.
- Konten personal bergantung pada locale/timezone client.
Ciri placeholder yang baik
- Markup awal sama di server dan client.
- Ukuran visual mendekati konten final untuk mengurangi layout shift.
- Tidak menipu pengguna, misalnya dengan menampilkan aksi final yang belum valid.
function PriceBlock({ initialPrice }) {
const [price, setPrice] = useState(initialPrice);
useEffect(() => {
// ambil harga final bila bergantung pada client context
}, []);
return (
<div style={{ minHeight: 24 }}>
{price ? <span>{price}</span> : <span>Memuat harga...</span>}
</div>
);
}Trade-off-nya jelas: placeholder mengurangi mismatch, tetapi bisa menambah satu fase visual. Karena itu, jika data bisa dipastikan di server, lebih baik kirim data final sejak awal.
Memisahkan client-only boundary dengan sengaja
Tidak semua komponen layak di-SSR. Widget yang sangat bergantung pada browser, ukuran viewport, storage lokal, atau API yang hanya ada di client sering lebih aman ditempatkan di client-only boundary.
Kapan pendekatan ini cocok
- Komponen pihak ketiga tidak aman untuk SSR.
- UI sangat bergantung pada state browser lokal.
- Biaya menjaga determinisme lebih besar daripada manfaat SSR untuk bagian tersebut.
Namun ini juga ada trade-off:
- konten bagian itu tidak muncul penuh di HTML awal,
- SEO untuk area tersebut bisa berkurang,
- interaktivitas menunggu JavaScript client.
Gunakan boundary ini untuk area yang memang tidak kritis untuk konten awal, bukan sebagai pelarian untuk seluruh halaman.
Kapan memakai suppressHydrationWarning dengan hati-hati
suppressHydrationWarning bisa berguna untuk elemen yang memang diketahui akan berbeda antara server dan client, misalnya timestamp lokal atau nilai kecil yang tidak memengaruhi struktur UI. Tetapi ini bukan solusi untuk akar masalah.
Gunakan hanya jika semua kondisi berikut terpenuhi:
- perbedaannya kecil dan disengaja,
- tidak mengubah struktur DOM besar,
- tidak memengaruhi event handler atau alur interaksi,
- Anda memahami mengapa perbedaannya terjadi.
Contoh penggunaan terbatas:
<time suppressHydrationWarning>
{formattedLocalTime}
</time>Yang perlu dihindari: memakai atribut ini untuk menutupi navbar, form, daftar produk, atau blok UI besar yang sebenarnya seharusnya deterministik. Jika dilakukan, bug bisa tetap ada walau warning hilang.
Checklist debugging saat terjadi hydration mismatch
Jika Anda melihat UI seperti berubah dari satu “semesta” ke semesta lain, periksa daftar berikut:
- Bandingkan render awal server dan client
Cari ekspresi yang bergantung pada browser, waktu, random, locale, atau request yang berbeda. - Cek semua percabangan render
Apakah adaif, ternary, atau early return yang bergantung pada data yang belum stabil? - Audit inisialisasi state
ApakahuseState(initializer)membacawindow,document,localStorage,Date, atauMath.random()? - Periksa auth dan feature flag
Apakah server punya nilai awal yang sama dengan client? Jika tidak, apakah Anda sudah memakai placeholder netral? - Tinjau formatting tanggal, angka, dan locale
Perbedaan environment bisa menghasilkan string yang berbeda. - Lihat sumber URL
Pastikan query/path yang dipakai saat SSR sama dengan yang dipakai untuk render awal di browser. - Amati layout shift
Kadang warning tidak jelas, tetapi gejala terlihat dari ukuran komponen yang berubah setelah mount. - Uji dengan JavaScript lambat
Throttle jaringan/CPU untuk memperjelas fase antara HTML server dan hydration client.
Pendekatan implementasi yang paling aman
Untuk sebagian besar kasus React/Next.js, urutan prioritas berikut cukup efektif:
- Usahakan data penentu UI awal tersedia di server
Contohnya sesi pengguna, feature flag penting, parameter URL, atau preferensi yang bisa dibaca dari cookie. - Jika tidak bisa, render fallback yang sama di server dan client
Jangan langsung bercabang ke UI final yang berbeda. - Pindahkan pembacaan browser-only ke effect
Biarkan update terjadi setelah hydration, bukan saat render awal. - Gunakan CSS untuk perbedaan presentasional
Jangan ubah struktur DOM awal hanya untuk responsivitas sederhana. - Isolasi komponen client-only bila memang perlu
Terutama untuk widget yang tidak realistis dibuat deterministik saat SSR.
Kesalahan umum yang sering terlewat
- Menganggap
typeof window !== 'undefined'otomatis menyelesaikan masalah. Ini hanya mencegah crash, bukan mismatch. - Menggunakan nilai default berbeda antara server dan client.
- Menyusun UI berdasarkan data global yang diisi terlambat setelah aplikasi boot.
- Mencampur sumber kebenaran, misalnya server pakai cookie tetapi client pakai localStorage dengan isi berbeda.
- Menutup warning tanpa memperbaiki determinisme render.
Penutup
Mencegah UI SSR bercabang karena state awal yang tidak stabil berarti menjaga agar server dan client memulai dari realitas yang sama. Begitu render awal menjadi deterministik, hydration berhenti terasa seperti “multiverse”: tidak ada teks yang meloncat, layout yang berubah mendadak, atau komponen yang seolah hidup di dua versi sekaligus.
Untuk React/Next.js, prinsip praktisnya sederhana: jangan biarkan render awal ditentukan oleh data yang hanya diketahui browser atau data yang berubah-ubah secara non-deterministik. Bawa nilai awal dari server jika memungkinkan; jika tidak, gunakan placeholder stabil, pisahkan boundary client-only untuk kasus khusus, dan gunakan suppressHydrationWarning hanya untuk perbedaan kecil yang memang disengaja.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!