UI SSR yang tenang berarti tampilan awal dari server sudah cukup benar, cukup stabil, dan tidak membuat pengguna melihat elemen yang berubah fungsi beberapa milidetik kemudian. Masalah yang ingin dicegah di sini adalah hydration flicker: teks, tombol, layout, atau status komponen tampak satu hal saat HTML pertama kali muncul, lalu berubah setelah JavaScript aktif.
Prinsip yang berguna untuk dipinjam dari Windows 2000 bukan nostalgia visualnya, melainkan kejernihan dan prediktabilitas interaksi. Kontrol tidak boleh tampak aktif lalu mendadak nonaktif. Status login tidak boleh terlihat salah lalu dibetulkan. Label waktu tidak boleh berubah liar hanya karena server dan klien menghitung nilai yang berbeda. Pada SSR, ketenangan UI datang dari disiplin menyamakan asumsi render awal server dan klien.
Aturan praktis: jika browser baru bisa mengetahui sesuatu setelah hydrate, jangan berpura-pura sudah tahu saat SSR. Tampilkan state netral, placeholder yang jujur, atau tunda bagian interaktif tertentu sampai datanya benar-benar tersedia.
Mengapa hydration flicker terjadi
Pada SSR, server mengirim HTML awal. Setelah itu, framework seperti Next.js, Nuxt, atau SvelteKit menjalankan JavaScript di browser untuk melakukan hydrate: mengaitkan event handler, membangun state klien, dan memverifikasi hasil render. Jika render awal di klien tidak cocok dengan HTML dari server, Anda bisa mendapat beberapa gejala:
- Render mismatch warning di console.
- Flicker visual karena DOM diperbarui setelah hydrate.
- Kontrol tampak salah, misalnya tombol “Masuk” berubah menjadi avatar pengguna.
- Layout shift karena ukuran konten berubah setelah data klien tersedia.
Penyebab intinya hampir selalu sama: server dan klien merender input yang berbeda untuk komponen yang sama. Input ini bisa berupa waktu saat ini, nilai acak, preferensi yang hanya ada di browser, hasil media query, status autentikasi, feature flag, atau data yang timing pengambilannya tidak sinkron.
Penyebab umum di Next.js, Nuxt, dan SvelteKit
1. Nilai waktu dan nilai acak
Contoh paling klasik adalah memanggil Date.now(), new Date(), atau generator acak langsung saat render. Server merender pukul 10:00:00.123, klien merender pukul 10:00:00.587. Secara logika kecil, tetapi bagi sistem rendering itu adalah output berbeda.
// Buruk: output akan berbeda antara server dan klien
export function Clock() {
return <time>{new Date().toLocaleTimeString()}</time>;
}Masalah yang sama berlaku untuk ID acak, urutan item yang diacak, atau warna yang dipilih secara random saat render. Jika nilai itu penting untuk render pertama, hitung di server lalu kirim sebagai props. Jika tidak penting, tampilkan placeholder netral lalu isi setelah komponen benar-benar berjalan di klien.
2. Akses localStorage atau API browser lain
localStorage, sessionStorage, window, document, dan sebagian besar API browser tidak tersedia saat SSR. Masalah yang lebih halus adalah ketika Anda memberi fallback default di server, lalu setelah hydrate membaca nilai sebenarnya dari browser dan langsung mengubah UI.
// Buruk: state awal SSR dan klien bisa berbeda makna
function ThemeLabel() {
const theme = typeof window === 'undefined'
? 'light'
: localStorage.getItem('theme') || 'light';
return <span>Tema: {theme}</span>;
}Jika pengguna sebenarnya menyimpan tema dark, maka HTML server menunjukkan light lalu berubah. Itu bukan sekadar kosmetik: jika warna, ikon, atau kontras kontrol ikut berubah, persepsi kestabilan UI ikut rusak.
3. Media query dan ukuran viewport
Server tidak tahu viewport nyata pengguna. Jika Anda merender struktur berbeda berdasarkan matchMedia, lebar layar, preferensi prefers-reduced-motion, atau pointer device, maka hasil SSR sering tidak cocok dengan hasil klien.
Solusi terbaik biasanya adalah serahkan responsivitas ke CSS jika memungkinkan, bukan ke cabang render JavaScript. CSS bisa menyesuaikan layout tanpa mengubah struktur DOM secara drastis saat hydrate.
4. Feature flag yang dihitung berbeda
Feature flag sering terlihat sederhana, tetapi sumber nilainya bisa berbeda: cookie di server, SDK di browser, request ke endpoint, atau konfigurasi yang belum terunduh saat klien mulai hydrate. Jika SSR memutuskan varian A dan klien memutuskan varian B, pengguna akan melihat elemen yang berganti bentuk atau menghilang.
Untuk flag yang memengaruhi struktur utama halaman, gunakan keputusan yang konsisten pada request yang sama. Artinya, evaluasi flag sedini mungkin di server dan kirim hasilnya ke klien sebagai bagian dari payload awal.
5. Auth state yang baru diketahui di klien
Ini salah satu sumber flicker paling mengganggu: header awal menampilkan tombol Masuk, lalu setelah hydrate berganti ke menu akun. Atau sebaliknya, UI menampilkan kontrol privat sebentar lalu menghilang setelah state auth ternyata tidak valid.
Jika status auth penting untuk kontrol awal, usahakan server sudah tahu dari cookie, session, atau token request. Jangan jadikan “belum tahu” sebagai “tampilkan keadaan lawannya dulu”. Jika status belum pasti, tampilkan state netral seperti skeleton atau placeholder akun.
6. Perbedaan data server-klien
Flicker juga muncul saat data di-fetch dua kali dengan hasil yang tidak identik: satu kali saat SSR, satu kali lagi segera setelah hydrate. Penyebabnya bisa cache berbeda, waktu respons berbeda, locale berbeda, atau transformasi data yang tidak konsisten.
Jika framework menyediakan mekanisme dehydrate/rehydrate atau payload data awal, gunakan itu agar klien memulai dari data yang sama dengan HTML yang sudah dirender. Refetch tetap boleh, tetapi jangan sampai refetch pertama mengubah tampilan secara mendadak kecuali memang ada perubahan data yang nyata.
Prinsip desain: tenang, jujur, dan prediktif
Konteks desain “ala Windows 2000” di sini bisa diringkas menjadi tiga prinsip:
- Tenang: UI awal tidak banyak bergerak atau berubah arti setelah muncul.
- Jujur: jika status belum diketahui, tampilkan bahwa status memang belum diketahui.
- Prediktif: kontrol tidak tampak siap dipakai jika ternyata belum siap.
Dalam praktik SSR, itu berarti:
- Hindari menebak state klien saat SSR.
- Utamakan struktur DOM yang stabil antara server dan klien.
- Gunakan placeholder yang ukuran dan posisinya mendekati konten akhir.
- Nonaktifkan atau sembunyikan aksi yang bergantung pada state yang belum pasti.
Pola komponen aman untuk UI SSR yang stabil
1. Pisahkan state “unknown” dari “true/false”
Kesalahan umum adalah memodelkan state sebagai boolean padahal pada SSR ada fase ketiga: belum diketahui. Misalnya status auth.
// Lebih aman: gunakan tiga state, bukan boolean semata
function AuthSlot({ initialUser }) {
const [user, setUser] = useState(initialUser); // null atau object
const [resolved, setResolved] = useState(initialUser !== undefined);
useEffect(() => {
if (resolved) return;
let cancelled = false;
fetch('/api/session')
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!cancelled) {
setUser(data?.user ?? null);
setResolved(true);
}
})
.catch(() => {
if (!cancelled) {
setUser(null);
setResolved(true);
}
});
return () => {
cancelled = true;
};
}, [resolved]);
if (!resolved) {
return <div className="account-placeholder" aria-busy="true">Memuat akun...</div>;
}
if (!user) {
return <a href="/login">Masuk</a>;
}
return <a href="/account">{user.name}</a>;
}Poin pentingnya bukan framework tertentu, melainkan model state. unknown harus diperlakukan sebagai state valid, bukan dipaksa menjadi false.
2. Kirim nilai awal dari server jika nilai itu memengaruhi render pertama
Untuk waktu, locale, flag, session, atau preferensi yang bisa diketahui saat request berlangsung, kirim nilainya dari server. Jangan hitung ulang dengan sumber yang berbeda di klien pada render pertama.
// Pola umum: server menyediakan nilai yang dipakai juga oleh klien
function PublishedAt({ isoString, formatted }) {
return <time dateTime={isoString}>{formatted}</time>;
}Di sini server mengirim string ISO dan format yang sudah diputuskan. Klien tidak perlu menebak zona waktu atau locale pada render awal. Jika nanti ingin menyesuaikan tampilan lokal pengguna, lakukan sebagai peningkatan bertahap, bukan koreksi mendadak pada konten utama.
3. Gunakan CSS untuk responsivitas, bukan percabangan render jika tidak perlu
Daripada merender markup desktop dan mobile yang berbeda total berdasarkan viewport klien, pertahankan struktur DOM yang sama lalu ubah layout dengan CSS. Ini menurunkan risiko mismatch dan biasanya lebih mudah diuji.
<nav class="primary-nav">
<button class="nav-toggle" aria-expanded="false">Menu</button>
<ul class="nav-items">
<li><a href="/docs">Docs</a></li>
<li><a href="/blog">Blog</a></li>
</ul>
</nav>Markup tetap, CSS yang mengatur kapan tombol menu terlihat dan bagaimana daftar navigasi ditata.
4. Tunda komponen yang murni klien, tetapi jangan sembunyikan terlalu banyak
Ada komponen yang memang bergantung penuh pada API browser: editor kaya, peta interaktif, visualisasi kompleks, atau panel personalisasi berbasis localStorage. Untuk kasus seperti itu, masuk akal menggunakan strategi client-only atau memuatnya setelah hydrate.
Trade-off-nya jelas: mismatch berkurang, tetapi konten awal bisa lebih kosong. Karena itu, gunakan placeholder yang jujur dan mempertahankan ruang layout agar halaman tidak meloncat.
5. Jangan tampilkan kontrol aktif sebelum precondition siap
Ini sangat penting untuk “rasa tenang” UI. Jika tombol bergantung pada auth, data, atau capability browser, lebih baik tampilkan:
- versi nonaktif dengan label jelas,
- skeleton yang ukurannya sama, atau
- placeholder statis tanpa affordance seolah-olah sudah bisa diklik.
Yang perlu dihindari adalah tombol aktif yang setelah hydrate ternyata hilang, pindah, atau berubah menjadi aksi lain.
Strategi placeholder yang jujur
Placeholder yang baik bukan berarti banyak skeleton di mana-mana. Tujuannya adalah menghindari kebohongan semantik. Jika isi belum diketahui, jangan tampilkan isi yang spesifik. Jika jenis kontrol belum pasti, jangan pamerkan kontrol final dulu.
Pilih placeholder berdasarkan tingkat ketidakpastian
- Data pasti ada, hanya isinya belum datang: gunakan skeleton dengan ukuran mendekati final.
- Status bisa berbeda secara semantik seperti login/logout: gunakan state netral, misalnya “Memuat akun...” atau ikon placeholder akun.
- Komponen hanya tersedia di klien: gunakan kotak placeholder dengan tinggi tetap dan deskripsi singkat.
- Preferensi tampilan pengguna seperti tema: gunakan default yang konsisten dan minim perubahan visual, atau sinkronkan preferensi lewat cookie agar server ikut tahu.
Contoh placeholder jujur untuk auth slot
<div class="account-slot" aria-live="polite">
<div class="account-placeholder" aria-busy="true">
<span class="avatar-skeleton"></span>
<span>Memuat akun...</span>
</div>
</div>Ini lebih baik daripada menampilkan tombol Masuk lalu menggantinya menjadi nama pengguna jika session sebenarnya sudah ada.
Checklist pencegahan hydration flicker
- Audit render path: apakah ada
Date, nilai acak, akses browser API, atau data yang berubah saat render? - Modelkan state unknown untuk auth, flag, dan preferensi pengguna.
- Kirim data awal dari server untuk hal yang memengaruhi tampilan pertama.
- Hindari percabangan render berbasis viewport jika CSS sudah cukup.
- Gunakan placeholder yang mempertahankan ruang layout agar tidak terjadi lompatan.
- Jangan tampilkan aksi final terlalu dini; lebih baik nonaktif atau netral.
- Samakan formatter untuk tanggal, angka, locale, dan zona waktu pada render awal.
- Pastikan feature flag konsisten antara SSR dan bootstrap klien pada request yang sama.
- Gunakan payload hydration data dari framework agar klien memulai dari data yang sama dengan server.
- Uji dengan throttling CPU dan jaringan lambat, karena flicker sering tidak terlihat pada mesin pengembang yang terlalu cepat.
Langkah debugging yang efektif
1. Cari mismatch dari yang paling deterministik
Mulailah dari hal yang mudah diverifikasi:
- apakah ada pemanggilan waktu/acak saat render,
- apakah format tanggal berbeda antara server dan klien,
- apakah data awal di payload sama dengan data yang dipakai komponen saat mount.
Masalah hydration sering terasa acak, padahal sumbernya biasanya deterministik jika jejak input render dilacak dengan rapi.
2. Bandingkan HTML awal dengan DOM setelah hydrate
Lihat View Source atau respons HTML mentah dari server, lalu bandingkan dengan DOM setelah JavaScript aktif. Fokus pada area yang berkedip: teks, atribut, urutan elemen, class, dan status kontrol seperti disabled, checked, atau aria-expanded.
3. Tambahkan logging pada input render, bukan hanya hasil akhir
Daripada hanya mencatat “komponen berubah”, log sumber keputusan render:
- locale yang dipakai,
- flag yang diterima dari server,
- session awal,
- hasil pembacaan localStorage setelah mount,
- media query yang aktif di klien.
Dengan begitu Anda bisa melihat input mana yang berbeda antara SSR dan klien.
4. Simulasikan kondisi lambat
Gunakan throttling jaringan dan CPU di browser devtools. UI yang tampak stabil pada laptop cepat bisa memperlihatkan flicker jelas ketika hydrate tertunda 1-2 detik. Ini sangat penting untuk header, nav, auth slot, dan komponen personalisasi.
5. Isolasi komponen bermasalah
Jika sebuah halaman besar sulit dianalisis, isolasi area yang diduga sebagai komponen tunggal. Render dengan props statis, hilangkan fetch kedua di klien, dan matikan dependensi browser satu per satu. Tujuannya bukan sekadar “menghilangkan warning”, tetapi menemukan input yang menyebabkan dua hasil render berbeda.
Catatan khusus untuk Next.js, Nuxt, dan SvelteKit
Walau implementasi detail tiap framework berbeda, pola amannya sama:
- Next.js: gunakan data dari server untuk render awal komponen yang sensitif terhadap auth, locale, dan flag. Komponen yang benar-benar bergantung pada browser sebaiknya dipisahkan dengan jelas agar tidak memaksa SSR menebak state klien.
- Nuxt: pastikan data yang dipakai saat SSR juga menjadi sumber state awal di klien, bukan diganti oleh fetch kedua tanpa alasan. Hindari membaca browser-only state pada fase yang memengaruhi HTML server.
- SvelteKit: prioritaskan data dari load atau mekanisme setara sebagai sumber kebenaran render pertama, lalu lakukan enhancement di klien secara bertahap.
Jangan terlalu terpaku pada API spesifik framework. Prinsip utamanya tetap: satu request, satu keputusan render awal yang konsisten.
Trade-off yang perlu diterima
Mengurangi hydration flicker sering berarti menerima salah satu dari trade-off berikut:
- Lebih banyak state netral sebelum informasi lengkap tersedia.
- Lebih sedikit personalisasi instan pada frame pertama.
- Komponen client-only untuk fitur tertentu, dengan konsekuensi konten awal lebih minimal.
- Kompleksitas data flow lebih tinggi karena server perlu memasok lebih banyak nilai awal.
Trade-off ini biasanya layak jika hasilnya adalah UI yang lebih stabil, lebih dapat dipercaya, dan lebih mudah dipahami pengguna. Dalam banyak aplikasi, kestabilan visual lebih berharga daripada “cerdas sebentar tapi salah di frame pertama”.
Penutup
UI SSR yang tenang tidak lahir dari trik visual, tetapi dari keputusan arsitektur yang disiplin: render awal harus didasarkan pada data yang benar-benar diketahui server, sedangkan hal yang belum diketahui harus tampil sebagai state netral yang jujur. Dengan pendekatan ini, Anda mencegah hydration flicker, mengurangi mismatch, dan membuat kontrol tidak tampak aktif lalu mendadak berganti.
Jika ingin satu pedoman singkat, gunakan ini: jangan biarkan pengguna melihat koreksi internal sistem Anda. Saat hydration berlangsung, UI seharusnya terasa stabil dan masuk akal sejak frame pertama.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!