Debug SSR dan hydration dari state awal yang tidak deterministik pada dasarnya adalah pekerjaan menyamakan dua hal: HTML yang dirender di server dan state awal yang dipakai browser saat melakukan hydration. Jika keduanya tidak identik, UI bisa berkedip, teks berubah sendiri, event handler terasa aneh, atau framework menampilkan peringatan mismatch.

Masalah ini sering terlihat sepele pada sistem kecil, tetapi justru di situlah jebakannya. Dari banyak wawancara developer independen, ada pola yang konsisten: aplikasi kecil tetap membutuhkan model render yang jelas. Tanpa aturan sederhana tentang apa yang boleh dihitung di server, apa yang harus ditunda ke client, dan data mana yang harus stabil selama satu siklus render, bug SSR akan muncul dalam bentuk yang membingungkan dan sulit direproduksi.

Memahami akar masalah: server render dan client hydration harus menghasilkan output awal yang sama

Pada SSR, server mengirim HTML awal ke browser. Setelah itu, JavaScript di client melakukan hydration, yaitu menghubungkan state, event handler, dan logika interaktif ke markup yang sudah ada. Agar proses ini aman, render pertama di client harus menghasilkan struktur dan nilai yang sama dengan hasil render di server.

Masalah muncul ketika state awal bersifat non-deterministic, yaitu nilainya bisa berubah tergantung waktu, lingkungan, atau urutan eksekusi. Contohnya:

  • Date.now() menghasilkan timestamp yang berbeda di server dan client.
  • Math.random() menghasilkan angka acak yang tidak sama.
  • window atau localStorage hanya tersedia di browser, sehingga state awal di server tidak bisa dihitung dengan cara yang sama.
  • Format tanggal/angka bergantung pada locale atau timezone yang berbeda.
  • Data async berubah di antara render server dan hydration client.

Ketika salah satu dari nilai itu mempengaruhi output awal, mismatch menjadi sangat mungkin terjadi.

Gejala UI yang membingungkan saat terjadi render mismatch

Tidak semua hydration issue muncul sebagai error fatal. Banyak yang terlihat seperti bug UI biasa. Beberapa gejala yang umum:

  • Teks berubah sesaat setelah halaman tampil, misalnya jam, angka, label, atau status.
  • Komponen berkedip karena client merender ulang subtree tertentu.
  • Peringatan seperti text content does not match server-rendered HTML atau pesan hydration mismatch lainnya.
  • Nilai input, checkbox, atau selected option berbeda dari yang terlihat saat HTML awal dimuat.
  • Class CSS atau atribut ARIA berubah setelah hydration, membuat layout atau aksesibilitas terlihat tidak konsisten.
  • Bug hanya muncul di production, bukan di local development, karena perbedaan timezone, data, atau timing jaringan.

Jika sebuah bug terlihat seperti “UI berubah sendiri setelah load pertama”, curigai dulu perbedaan state awal antara server dan client sebelum menyalahkan styling atau event handler.

Penyebab umum state awal tidak deterministik pada SSR

1. Menggunakan Date.now() atau waktu saat render

Ini salah satu sumber mismatch paling sering. Jika waktu dipakai langsung saat render, server dan client hampir pasti mendapatkan nilai yang berbeda.

// Pola salah: waktu dihitung saat render awal
function Clock() {
  const label = new Date(Date.now()).toLocaleTimeString();
  return <span>{label}</span>;
}

Server merender satu waktu, lalu beberapa milidetik kemudian client menghitung waktu lain. Hasil teks berbeda, hydration pun bermasalah.

Pola yang lebih aman adalah mengirim nilai waktu yang sudah ditentukan dari server sebagai props, lalu memperbaruinya setelah hydration jika memang perlu menjadi live.

// Pola lebih benar: nilai awal disuntik dari server
function Clock({ initialTimestamp }) {
  const label = new Date(initialTimestamp).toLocaleTimeString('id-ID', { timeZone: 'UTC' });
  return <span>{label}</span>;
}

Jika jam harus terus bergerak, lakukan pembaruan setelah komponen benar-benar berjalan di client.

2. Memakai Math.random() untuk ID, urutan, atau konten

Math.random() di render awal hampir selalu salah untuk SSR kecuali nilainya dihasilkan sekali di server lalu diteruskan apa adanya ke client. Random yang berbeda dapat mengubah:

  • isi teks,
  • key list,
  • ID elemen,
  • kelas atau variasi layout.
// Pola salah
function PromoBanner() {
  const variant = Math.random() > 0.5 ? 'A' : 'B';
  return <div>Promo {variant}</div>;
}

Solusi praktis:

  • hasilkan variant di server dan kirim sebagai data awal, atau
  • render placeholder stabil terlebih dulu, lalu pilih variant di client setelah hydration jika memang harus acak.

3. Mengakses window atau localStorage terlalu dini

Browser API seperti window, document, matchMedia, dan localStorage tidak tersedia saat render di server. Bug sering terjadi ketika fallback server berbeda dari hasil pembacaan di client.

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

Di server, kode ini tidak bisa berjalan. Walaupun dibungkus pengecekan typeof window !== 'undefined', mismatch tetap mungkin terjadi jika server menampilkan light tetapi client langsung membaca dark saat render pertama.

Pola yang lebih aman:

  • gunakan nilai awal yang sama di server dan client,
  • baru sinkronkan dengan localStorage setelah hydration, atau
  • jika preferensi harus benar sejak awal, baca dari cookie di server agar kedua sisi punya sumber data yang sama.
// Pola lebih aman
function ThemeLabel({ initialTheme }) {
  const [theme, setTheme] = useState(initialTheme);

  useEffect(() => {
    const stored = window.localStorage.getItem('theme');
    if (stored && stored !== theme) setTheme(stored);
  }, [theme]);

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

4. Perbedaan locale dan timezone

Render tanggal, waktu, angka, dan mata uang bisa berbeda walaupun input timestamp-nya sama. Server mungkin berjalan di timezone UTC, sementara browser pengguna berada di Asia/Jakarta. Format default locale juga bisa berbeda.

// Berisiko jika locale/timezone implisit
const text = new Date(initialTimestamp).toLocaleString();

Perbaikan:

  • tentukan locale secara eksplisit,
  • tentukan timezone secara eksplisit jika output harus identik,
  • atau jangan render format final di server jika hasilnya memang harus mengikuti environment browser.
const text = new Intl.DateTimeFormat('id-ID', {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: 'UTC'
}).format(new Date(initialTimestamp));

Jika tujuan Anda adalah menampilkan waktu lokal pengguna, sering kali lebih aman menampilkan placeholder netral saat SSR lalu format final dilakukan di client.

5. Data async berubah antara server dan client

Kasus ini terjadi ketika server merender data versi A, tetapi client saat hydration langsung melakukan fetch lagi dan menerima data versi B. Jika render awal client memakai hasil fetch baru itu, struktur DOM bisa berubah sebelum hydration selesai atau sesaat setelahnya.

Contoh situasi:

  • daftar notifikasi bertambah satu item,
  • stok produk berubah,
  • fitur eksperimen atau personalisasi mengembalikan payload yang berbeda.

Prinsip perbaikannya adalah re-use payload server untuk render pertama di client. Fetch ulang boleh dilakukan setelah hydration, tetapi jangan mengganti state awal sebelum render pertama selesai.

Alur debugging SSR dan hydration yang praktis

Saat bug muncul, jangan langsung mengubah banyak hal sekaligus. Ikuti alur yang sempit dan terukur.

1. Konfirmasi bahwa ini benar-benar hydration mismatch

Perhatikan warning di console browser dan bandingkan dengan gejala visual. Jika teks atau atribut berubah tepat setelah load, besar kemungkinan sumbernya adalah perbedaan output render awal.

2. Cari nilai yang dihitung saat render, bukan setelah mount

Audit komponen yang terlibat dan cari:

  • Date.now(), new Date(), Math.random()
  • window, document, localStorage, navigator
  • formatter tanggal/angka tanpa locale/timezone eksplisit
  • pemanggilan API async yang bisa menghasilkan data baru pada render pertama client

Fokus pada ekspresi yang mempengaruhi markup awal: teks, atribut, key, urutan list, class, dan kondisi render.

3. Log state mentah di server dan client

Jangan hanya melihat output akhir. Log input yang membentuk output tersebut. Misalnya:

// Pseudocode: log di server saat render
console.log('SSR initial state', {
  timestamp: initialTimestamp,
  locale,
  timezone,
  theme,
  itemsCount: items.length
});

// Log di client sebelum hydration selesai atau saat render awal
console.log('Client initial state', {
  timestamp: initialTimestamp,
  locale,
  timezone,
  theme,
  itemsCount: items.length
});

Tujuannya bukan sekadar menemukan perbedaan, tetapi memastikan apakah perbedaan itu berasal dari sumber data, formatter, atau urutan hidup komponen.

4. Isolasi subtree yang bermasalah

Jika halaman besar, matikan sementara bagian-bagian tertentu atau ganti komponen kompleks dengan output statis. Saat warning hilang, Anda sudah mempersempit area investigasi. Pendekatan ini jauh lebih efektif daripada menebak-nebak seluruh halaman.

5. Bekukan input yang berubah-ubah

Untuk memastikan penyebab, paksa nilai acak atau waktu menjadi tetap:

  • ganti Date.now() dengan timestamp hard-coded,
  • ganti Math.random() dengan konstanta,
  • nonaktifkan fetch ulang di client untuk sementara.

Jika mismatch hilang, Anda sudah mengidentifikasi kelas bug-nya.

6. Bandingkan HTML server dengan render client awal

Bila tooling Anda mendukung, lihat HTML hasil SSR dan bandingkan dengan DOM setelah hydration. Perhatikan:

  • teks yang berubah,
  • node yang bertambah atau hilang,
  • atribut seperti class, checked, selected, value,
  • urutan elemen list.

Checklist isolasi bug yang bisa dipakai berulang

  1. Apakah komponen menghitung waktu saat render?
  2. Apakah ada angka acak, UUID, atau variant yang dibuat saat render?
  3. Apakah ada akses browser API sebelum komponen benar-benar berjalan di client?
  4. Apakah format tanggal/angka bergantung pada locale atau timezone default?
  5. Apakah server dan client memakai payload data awal yang sama?
  6. Apakah ada fetch ulang yang mengubah state sebelum atau tepat saat hydration?
  7. Apakah key list stabil dan berasal dari data, bukan dari random/index yang tidak konsisten?
  8. Apakah conditional rendering bergantung pada ukuran layar, media query, atau preferensi browser sejak render pertama?

Pola salah vs benar yang sering ditemui

Salah: menentukan state awal dari browser API di render

function Sidebar() {
  const isCollapsed = window.innerWidth < 768;
  return <aside data-collapsed={isCollapsed} />;
}

Masalahnya, server tidak tahu ukuran viewport pengguna. Client akan menghitung nilai berbeda.

Benar: render layout netral di server, lalu sesuaikan setelah hydration, atau gunakan pendekatan CSS responsif jika yang dibutuhkan hanya presentasi.

Salah: menggunakan formatter implisit

function PublishedAt({ ts }) {
  return <time>{new Date(ts).toLocaleString()}</time>;
}

Benar: tetapkan locale dan timezone, atau format final hanya di client bila harus mengikuti setting pengguna.

Salah: fetch ulang langsung mengganti state awal

function Notifications({ initialItems }) {
  const [items, setItems] = useState(initialItems);

  // Langsung menimpa saat komponen aktif
  useEffect(() => {
    fetch('/api/notifications')
      .then(r => r.json())
      .then(setItems);
  }, []);

  return <ul>{items.map(item => <li key={item.id}>{item.title}</li>) }</ul>;
}

Kode ini tidak selalu salah, tetapi bisa memicu UI terasa “loncat” bila hasil fetch berbeda jauh dari payload SSR. Jika perubahan besar tidak bisa dihindari, pertimbangkan indikator sinkronisasi atau strategi cache agar transisinya jelas.

Benar: gunakan payload server sebagai sumber state awal tunggal

function Notifications({ initialItems }) {
  const [items, setItems] = useState(initialItems);
  const [isRefreshing, setIsRefreshing] = useState(false);

  useEffect(() => {
    let cancelled = false;
    setIsRefreshing(true);

    fetch('/api/notifications')
      .then(r => r.json())
      .then(data => {
        if (!cancelled) setItems(data);
      })
      .finally(() => {
        if (!cancelled) setIsRefreshing(false);
      });

    return () => {
      cancelled = true;
    };
  }, []);

  return (
    <section>
      {isRefreshing && <small>Memperbarui...</small>}
      <ul>{items.map(item => <li key={item.id}>{item.title}</li>) }</ul>
    </section>
  );
}

Poin pentingnya bukan fetch itu sendiri, tetapi memastikan render pertama di client menggunakan data yang sama dengan server.

Strategi perbaikan yang paling efektif

1. Bedakan state render-kritis dan state client-only

Tanyakan untuk setiap nilai: apakah nilai ini mempengaruhi markup awal? Jika ya, nilainya harus deterministik antara server dan client. Jika tidak, pindahkan perhitungannya ke fase setelah hydration.

2. Serialisasi state awal dari server

Jika server sudah tahu nilainya, kirimkan ke client sebagai bagian dari payload SSR. Ini lebih aman daripada menghitung ulang dari nol di browser.

Cocok untuk:

  • timestamp referensi,
  • variant eksperimen,
  • preferensi pengguna yang tersedia di cookie/session,
  • hasil query yang dipakai untuk markup awal.

3. Gunakan placeholder stabil untuk nilai yang memang hanya diketahui di browser

Contohnya locale lokal pengguna, ukuran viewport, atau preferensi dari localStorage. Daripada memaksakan SSR yang tidak akurat, tampilkan fallback netral lalu update setelah hydration.

Trade-off-nya adalah ada sedikit perubahan UI setelah load, tetapi perubahan itu disengaja, eksplisit, dan tidak memicu mismatch.

4. Tetapkan locale dan timezone secara eksplisit saat output harus identik

Jangan bergantung pada default environment. Ini penting untuk tanggal, jam, angka desimal, dan mata uang.

5. Pastikan key dan struktur list stabil

Hydration akan lebih rapuh jika struktur node berubah. Gunakan ID data yang stabil, bukan random atau indeks yang dapat bergeser karena kondisi berbeda di server dan client.

6. Kurangi logika bercabang saat render awal

Semakin banyak keputusan diambil saat render pertama, semakin besar peluang server dan client mengambil cabang yang berbeda. Untuk sistem kecil sekalipun, model render yang sederhana jauh lebih mudah di-debug.

Pelajaran praktis dari banyak proyek independen: bukan ukuran aplikasinya yang menentukan kompleksitas SSR, tetapi ketidakjelasan batas antara data stabil dan data yang hanya tersedia di browser.

Kapan sebaiknya menunda render ke client saja

Tidak semua komponen perlu SSR penuh. Dalam beberapa kasus, memilih render di client adalah keputusan teknis yang lebih bersih.

Pertimbangkan client-only rendering jika komponen:

  • sangat bergantung pada window, ukuran viewport, atau API browser lain,
  • menampilkan data yang memang personal dan hanya tersedia di perangkat pengguna,
  • tidak penting untuk SEO atau paint awal,
  • terlalu mahal untuk dibuat deterministik dibanding manfaat SSR-nya.

Contoh umum:

  • widget preferensi tema berbasis localStorage,
  • grafik interaktif yang bergantung pada ukuran kontainer,
  • komponen eksperimen visual yang tidak mempengaruhi konten utama halaman.

Trade-off client-only rendering:

  • konten tidak langsung tersedia dari HTML server,
  • bisa menunda interaktivitas atau tampilan komponen,
  • tidak ideal untuk elemen yang penting untuk SEO dan aksesibilitas awal.

Gunakan pendekatan ini secara selektif, bukan sebagai jalan pintas untuk seluruh halaman.

Penutup

Bug SSR dan hydration dari state awal yang tidak deterministik jarang selesai hanya dengan menambahkan pengecekan typeof window. Akar masalahnya hampir selalu sama: render pertama di server dan client tidak berbagi input yang identik.

Untuk mendiagnosisnya, fokuslah pada nilai yang berubah terhadap waktu, environment, atau fetch. Audit semua state yang mempengaruhi markup awal, bekukan input yang tidak stabil, lalu bedakan mana yang harus deterministik dan mana yang memang layak ditunda ke client. Bahkan pada sistem kecil, model render yang jelas akan menghemat banyak waktu debugging dan mencegah UI yang terasa “aneh” setelah load pertama.