Hydration mismatch pada React SSR biasanya terlihat lewat warning di console, UI flicker, atau tampilan awal yang berubah sesaat setelah aplikasi selesai mount. Dalam setup Bun + Vite, masalah ini umumnya bukan karena runtime tertentu, melainkan karena output HTML dari server tidak identik dengan render pertama di browser.

Kalau Anda sedang mencari cara debug hydration SSR React dengan Bun dan Vite tanpa UI flicker, fokus utama Anda seharusnya adalah menemukan bagian render yang menghasilkan nilai berbeda antara server dan client. Artikel ini membahas gejala, penyebab umum, cara reproduksi, teknik isolasi bug, dan pola perbaikan yang aman agar output SSR tetap stabil.

Apa yang Sebenarnya Terjadi Saat Hydration

Pada SSR, server mengirim HTML awal agar halaman bisa tampil cepat. Setelah itu, React di browser melakukan hydration: React mengaitkan tree komponen dengan HTML yang sudah ada, bukan langsung merender ulang dari nol.

Masalah muncul ketika hasil render pertama di browser berbeda dari HTML server. Saat mismatch terjadi, React dapat menampilkan warning, membuang sebagian DOM yang sudah ada, lalu merender ulang bagian tertentu. Inilah yang sering memicu flicker atau perubahan konten setelah halaman terlihat.

  • Server render: menghasilkan HTML awal.
  • Client hydration: React mengharapkan struktur dan isi DOM yang sama.
  • Mismatch: jika ada teks, atribut, atau struktur yang berbeda, React perlu melakukan koreksi.

Intinya sederhana: render pertama di server dan render pertama di browser harus deterministik dan identik.

Gejala Nyata Hydration Mismatch

1. Warning hydration di console

Gejala paling jelas adalah warning seperti teks tidak cocok, atribut berbeda, atau tree DOM yang tidak sesuai. Pesan pastinya dapat berbeda, tetapi polanya sama: hasil render server dan client tidak identik.

2. UI flicker

Halaman awal terlihat benar, lalu berubah sesaat setelah JavaScript aktif. Ini sering terjadi ketika server merender satu nilai, lalu browser langsung menggantinya dengan nilai lain saat hydration selesai.

3. Konten awal berubah setelah mount

Contohnya, server menampilkan "Masuk", tetapi setelah mount berubah menjadi "Halo, Budi". Secara visual terlihat seperti lompatan state.

4. State awal berbeda antara server dan browser

Kasus ini umum ketika state diambil dari localStorage, waktu saat ini, locale browser, atau data async yang tidak diserialisasi dengan benar dari server ke client.

Penyebab Umum pada React SSR

Akses window atau localStorage saat render

Server tidak punya API browser seperti window, document, atau localStorage. Kadang masalahnya bukan hanya error, tetapi juga nilai fallback di server berbeda dengan nilai aktual di browser.

Contoh bermasalah:

function ThemeLabel() {
  const theme = localStorage.getItem('theme') || 'light';
  return <span>Tema: {theme}</span>;
}

Jika dipaksa diberi guard sederhana seperti typeof window !== 'undefined', mismatch tetap bisa terjadi:

function ThemeLabel() {
  const theme = typeof window !== 'undefined'
    ? localStorage.getItem('theme') || 'light'
    : 'light';

  return <span>Tema: {theme}</span>;
}

Server selalu menghasilkan light. Browser bisa langsung menghasilkan dark. HTML awal jadi berbeda.

Nilai berbasis waktu, locale, atau random

Render yang memakai Date.now(), new Date(), Math.random(), atau format locale browser sering tidak stabil.

function Greeting() {
  const hour = new Date().getHours();
  return <h2>{hour < 12 ? 'Pagi' : 'Sore'}</h2>;
}

Jika server dan browser dieksekusi pada waktu berbeda, output dapat berubah. Hal serupa berlaku untuk:

  • toLocaleString() dengan locale default berbeda
  • Intl dengan timezone berbeda
  • Math.random() di dalam render

Conditional render tergantung environment

Render yang bercabang berdasarkan apakah kode berjalan di server atau browser sering memicu perbedaan struktur DOM.

function Navigation() {
  if (typeof window === 'undefined') {
    return <div>Loading menu...</div>;
  }

  return <nav>...menu interaktif...</nav>;
}

Di server Anda mengirim <div>, di browser render pertama mengharapkan <nav>. Ini contoh mismatch struktural yang mudah menimbulkan warning.

Data async tidak sinkron

Server mungkin merender dengan data A, tetapi client saat hydration langsung mengambil data B atau state kosong terlebih dahulu. Jika data awal tidak diserialisasi dengan benar, browser tidak memulai dari snapshot yang sama dengan server.

Gejala umum:

  • Server sudah menampilkan daftar item, client mulai dari loading.
  • Server merender user login, client mulai sebagai guest.
  • Server memakai cache lama, client segera memuat data baru sebelum hydration stabil.

Contoh Minimal untuk Reproduksi Bug

Berikut contoh komponen yang sering menyebabkan UI flicker karena membaca state dari browser saat inisialisasi render:

import { useState } from 'react';

export function Counter() {
  const [count] = useState(() => {
    if (typeof window === 'undefined') return 0;
    return Number(localStorage.getItem('count') || 0);
  });

  return <p>Count: {count}</p>;
}

Apa yang terjadi:

  1. Server merender Count: 0.
  2. Browser punya localStorage.count = 5.
  3. Render pertama di browser menghasilkan Count: 5.
  4. React mendeteksi mismatch, lalu UI dapat berubah sesaat.

Ini contoh klasik mismatch tanpa perlu struktur aplikasi yang rumit.

Cara Isolasi Bug Secara Praktis

1. Mulai dari komponen yang paling terlihat berubah

Jangan langsung menelusuri seluruh aplikasi. Cari komponen yang:

  • menampilkan waktu
  • membaca preferensi user dari browser
  • bergantung pada auth client-side
  • merender placeholder berbeda di server dan browser

2. Cari semua sumber nilai non-deterministik

Lakukan pencarian di codebase untuk pola berikut:

Date.now(
new Date(
Math.random(
window.
document.
localStorage
sessionStorage
navigator.
toLocaleString(
Intl.

Jika pola ini ada di dalam fungsi komponen atau jalur render, periksa apakah outputnya bisa berbeda antara server dan browser.

3. Bandingkan HTML server dengan render awal browser

Tujuannya bukan hanya melihat tampilan akhir, tetapi memeriksa apakah markup awal benar-benar sama. Cara praktis:

  • Buka source HTML respons server.
  • Bandingkan dengan DOM setelah hydration dimulai.
  • Lihat node teks, atribut, dan urutan elemen yang berubah.

Jika perubahannya terjadi sebelum interaksi user, kemungkinan besar itu mismatch hydration, bukan bug event handler biasa.

4. Nonaktifkan sementara bagian yang dicurigai

Ganti isi komponen kompleks dengan output statis sementara:

function ProblematicWidget() {
  return <div>static</div>;
}

Jika warning hilang, Anda sudah mempersempit lokasi bug. Lalu kembalikan logika sedikit demi sedikit sampai mismatch muncul lagi.

5. Tambahkan log terpisah untuk server dan browser

Sangat membantu untuk mencetak nilai yang dipakai saat render pertama.

function DebugValue({ value }) {
  console.log(
    typeof window === 'undefined' ? '[server]' : '[client]',
    'value =',
    value
  );

  return <span>{value}</span>;
}

Jika log server dan client berbeda untuk render yang sama, Anda sudah menemukan akar masalahnya.

Pola Perbaikan yang Aman

1. Guard kode client-only, tetapi jangan ubah output render awal

Guard seperti typeof window !== 'undefined' hanya aman jika tidak membuat output render pertama berbeda. Untuk data browser-only, lebih aman melakukan update setelah mount.

Perbaikan:

import { useEffect, useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const next = Number(localStorage.getItem('count') || 0);
    setCount(next);
  }, []);

  return <p>Count: {count}</p>;
}

Server dan client sama-sama mulai dari 0, sehingga hydration stabil. Setelah mount, nilai boleh berubah melalui effect.

Trade-off: masih mungkin ada perubahan visual setelah mount. Jika perubahan itu mengganggu, gunakan initial state dari server, bukan membaca browser saat render pertama.

2. Serialisasi initial state dari server ke browser

Untuk data yang memang sudah diketahui server, kirim snapshot state ke client agar keduanya mulai dari nilai yang sama.

Contoh pola umum:

// server-side pseudo code
const initialState = {
  user: { name: 'Budi' },
  theme: 'dark'
};

const appHtml = renderApp(initialState);
const stateJson = JSON.stringify(initialState)
  .replace(/</g, '\\u003c');
<script>
  window.__INITIAL_STATE__ = {"user":{"name":"Budi"},"theme":"dark"}
</script>
// client-side
const initialState = window.__INITIAL_STATE__;
hydrateApp(initialState);

Dengan pola ini, server dan browser merender data yang sama pada pass pertama.

Catatan penting: serialisasi harus aman agar tidak membuka celah XSS. Hindari menyuntikkan JSON mentah tanpa escaping yang benar.

3. Tunda efek samping ke useEffect

Semua interaksi dengan API browser sebaiknya dipindahkan ke useEffect atau mekanisme yang memang hanya berjalan di client.

Cocok untuk:

  • membaca localStorage
  • mengakses ukuran viewport
  • mengikat listener ke window
  • membaca preferensi perangkat

Yang perlu diingat: useEffect tidak menyelesaikan mismatch jika Anda tetap merender konten berbeda pada pass pertama. Ia hanya aman bila output awal tetap sama.

4. Stabilkan output SSR

Jika render membutuhkan waktu, locale, atau nilai acak, buat nilainya eksplisit dan konsisten.

Contoh yang lebih aman:

function FormattedDate({ iso, locale }) {
  const text = new Date(iso).toLocaleString(locale, {
    timeZone: 'UTC'
  });

  return <time dateTime={iso}>{text}</time>;
}

Dibanding mengandalkan locale default browser dan timezone mesin server, Anda mengendalikan inputnya sendiri.

Untuk nilai acak, hasilkan di server lalu kirim sebagai props atau initial state, jangan memanggil Math.random() langsung di render yang harus cocok di kedua sisi.

5. Hindari conditional render yang mengubah struktur DOM pada pass pertama

Jika komponen hanya bisa berjalan di client, lebih aman merender fallback yang sama di server dan client, lalu mengganti isinya setelah mount.

import { useEffect, useState } from 'react';

function ClientOnlyMenu() {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return <div>Loading menu...</div>;
  }

  return <nav>...menu interaktif...</nav>;
}

Pendekatan ini menjaga render awal tetap konsisten. Kekurangannya, ada fallback sementara sampai mount selesai.

Strategi Mengurangi atau Menghilangkan UI Flicker

Tujuan idealnya bukan sekadar menghapus warning, tetapi mencegah perubahan visual yang terasa oleh pengguna. Beberapa strategi yang paling efektif:

  • Gunakan initial state dari server untuk data yang memengaruhi tampilan awal.
  • Jangan render nilai browser-only pada pass pertama.
  • Render placeholder yang stabil jika data client memang belum tersedia.
  • Samakan locale, timezone, dan format output bila ada formatting teks/tanggal.
  • Pastikan tree DOM identik antara server dan browser sebelum effect berjalan.

Jika Anda harus memilih antara konten salah sesaat dan placeholder netral yang stabil, placeholder sering lebih baik karena tidak menyebabkan koreksi hydration yang agresif.

Contoh Perbaikan dari Kasus Nyata

Kasus: tema dari localStorage

Versi bermasalah:

function ThemeBadge() {
  const theme = typeof window !== 'undefined'
    ? localStorage.getItem('theme') || 'light'
    : 'light';

  return <span>Tema: {theme}</span>;
}

Versi lebih aman jika server belum tahu tema:

import { useEffect, useState } from 'react';

function ThemeBadge() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    setTheme(localStorage.getItem('theme') || 'light');
  }, []);

  return <span>Tema: {theme}</span>;
}

Versi terbaik jika server bisa mengetahui tema dari cookie atau request:

function ThemeBadge({ initialTheme }) {
  const [theme] = useState(initialTheme);
  return <span>Tema: {theme}</span>;
}

Pola terbaik adalah server dan browser memulai dari nilai yang sama, bukan mengandalkan koreksi setelah mount.

Checklist Debugging Khusus Bun untuk SSR Lokal

Dalam konteks Bun, fokus verifikasi Anda tetap sama: pastikan server SSR lokal dan browser menghasilkan output identik. Berikut checklist yang praktis:

1. Jalankan SSR lokal dalam mode development dan amati log server

Pastikan Anda menjalankan entry SSR yang benar melalui Bun, lalu buka console server dan browser secara bersamaan. Tujuannya untuk membandingkan nilai render pertama di kedua sisi.

bun run dev

Atau jika proyek memisahkan entry server:

bun run server

Nama script tergantung proyek Anda, tetapi prinsipnya sama: jalankan server SSR lokal melalui Bun dan perhatikan output log render.

2. Verifikasi HTML respons mentah sebelum hydration

Buka source halaman dari browser atau ambil respons HTML langsung dari endpoint SSR. Pastikan nilai teks awal sama dengan yang Anda harapkan dari render server, bukan hasil setelah JavaScript berjalan.

3. Nonaktifkan cache yang membingungkan selama debugging

Saat memeriksa mismatch, gunakan kondisi yang meminimalkan kebingungan:

  • hard reload browser
  • matikan cache di DevTools
  • ulangi skenario dengan tab incognito
  • hapus localStorage atau set nilai yang jelas sebelum tes

4. Uji dengan state browser yang sengaja berbeda

Untuk membuktikan akar masalah, set nilai eksplisit di browser, misalnya:

localStorage.setItem('count', '5')

Lalu reload halaman SSR. Jika server merender 0 dan client langsung ingin 5, mismatch harus bisa direproduksi konsisten.

5. Periksa perbedaan environment server dan browser

Jika Anda memakai format tanggal, locale, atau timezone, cek apakah hasilnya bergantung pada environment. Dalam debugging lokal, masalah sering tersembunyi jika mesin server dan browser kebetulan memiliki konfigurasi serupa, lalu baru muncul di deployment.

6. Log apakah kode berjalan di server atau client

Tambahkan penanda sederhana di komponen yang dicurigai:

const side = typeof window === 'undefined' ? 'server' : 'client';
console.log('[render]', side);

Jika nilai render berbeda antara dua log pertama, Anda sedang melihat sumber mismatch.

7. Uji hasil build production, bukan hanya development

Beberapa perilaku debugging lebih mudah terlihat di development karena warning lebih jelas. Namun tetap verifikasi hasil build SSR lokal yang mendekati produksi, karena urutan timing dan bundling bisa membuat gejala lebih nyata.

bun run build
bun run start

Sesuaikan nama script dengan proyek Anda. Tujuannya adalah membandingkan apakah warning atau flicker hanya muncul di dev, atau juga pada jalur build yang lebih mirip produksi.

Kesalahan yang Sering Terjadi

  • Mengira guard typeof window selalu menyelesaikan masalah. Guard hanya mencegah akses API browser di server, bukan menjamin output render sama.
  • Memformat tanggal dengan locale default. Hasil bisa berbeda antar environment.
  • Menginisialisasi state dari browser saat render pertama. Ini sumber mismatch yang sangat umum.
  • Client langsung refetch dan merender state baru sebelum hydration stabil. Jika server sudah punya data, kirim snapshot awalnya.
  • Mengabaikan perubahan struktur DOM. Bukan hanya teks; elemen yang berbeda juga menyebabkan masalah.

Ringkasan Praktis

Untuk debug hydration SSR React dengan Bun dan Vite tanpa UI flicker, jangan mulai dari bundler atau runtime. Mulailah dari prinsip inti SSR: HTML server harus sama dengan render pertama di browser.

Periksa sumber nilai yang tidak deterministik seperti window, localStorage, waktu, locale, random, conditional render berbasis environment, dan data async yang tidak sinkron. Setelah itu, terapkan perbaikan yang tepat:

  • pindahkan akses browser-only ke useEffect
  • serialisasikan initial state dari server
  • jaga output SSR tetap stabil
  • hindari perbedaan struktur DOM pada pass pertama

Kalau warning hydration hilang tetapi UI masih flicker, berarti Anda mungkin sudah memperbaiki error kasar namun belum menyamakan state awal. Fokus akhir Anda adalah menghilangkan perbedaan render awal, bukan sekadar menunda gejalanya.