Mismatch hydration terjadi ketika HTML hasil server tidak cocok dengan struktur UI yang dihitung ulang di browser saat proses hydrate. Pada UI yang event-driven, masalah ini sering muncul karena state berubah terlalu cepat akibat listener global, pembacaan window/document, localStorage, media query, ukuran viewport, atau data non-deterministik yang berbeda antara server dan client.

Audit SSR untuk UI event-driven berarti memastikan satu hal sederhana namun sering dilanggar: render pertama di server dan render pertama di client harus menghasilkan output yang konsisten. Setelah itu, barulah perubahan berbasis event dijalankan di fase client. Jika aturan ini dilanggar, Anda akan melihat gejala seperti warning hydration, flicker, tombol yang tidak responsif, state awal yang salah, atau subtree yang dibuang dan dirender ulang oleh framework.

Mengapa UI event-driven rentan terhadap mismatch hydration

UI modern jarang benar-benar statis. Banyak komponen mengambil keputusan render dari lingkungan runtime browser:

  • apakah pengguna memakai pointer halus atau sentuh,
  • apakah viewport kecil atau besar,
  • apakah tema disimpan di localStorage,
  • apakah panel tertentu harus terbuka setelah event global dipulihkan,
  • apakah data async sudah tersedia saat halaman dimuat.

Dalam aplikasi yang arsitekturnya penuh event, state UI sering tidak berasal dari satu sumber saja. Ia bisa dipengaruhi oleh:

  • event bus internal,
  • listener pada resize, matchMedia, visibilitychange, atau pointer events,
  • state persisten dari storage,
  • hasil request async yang selesai sangat cepat di client tetapi tidak ada saat server render.

Masalahnya bukan event itu sendiri, melainkan event atau pembacaan environment yang ikut menentukan markup awal. Begitu markup awal client berbeda dari hasil SSR, hydration menjadi tidak stabil.

Gejala mismatch hydration yang perlu dikenali

Dalam React, Next.js, atau framework sejenis, gejalanya biasanya terlihat sebagai:

  • warning bahwa text content atau atribut tidak cocok,
  • elemen tiba-tiba dirender ulang penuh setelah load,
  • flicker: layout awal muncul lalu berubah cepat,
  • event handler terasa tidak menempel pada node yang diharapkan,
  • state awal komponen berbeda antara server dan client,
  • komponen tertentu hanya rusak pada perangkat, ukuran layar, atau preferensi user tertentu.

Jika bug hanya muncul pada perangkat sentuh, mode gelap, layar kecil, atau saat ada state tersimpan dari sesi sebelumnya, itu sinyal kuat bahwa sumber mismatch terkait environment browser atau state yang dipulihkan setelah hydration.

Sumber bug umum pada SSR event-driven

1. Akses window atau document saat render

Ini pola paling umum. Render server tidak punya akses ke DOM browser. Walaupun Anda melindunginya dengan pengecekan typeof window !== 'undefined', hasil render tetap bisa berbeda jika output UI bergantung pada nilai tersebut.

// Buruk: render awal berbeda antara server dan client
function Sidebar() {
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
  return <aside className={isMobile ? 'drawer' : 'sidebar'} />;
}

Di server, isMobile akan bernilai false. Di client, nilainya bisa true. Akibatnya class atau struktur DOM awal berbeda.

2. Data non-deterministik saat render

Segala sesuatu yang menghasilkan nilai berbeda antara server dan client dapat memicu mismatch, misalnya:

  • Date.now(),
  • new Date() yang langsung ditampilkan,
  • Math.random(),
  • ID acak yang dipakai di atribut atau urutan elemen.
// Buruk: teks hampir pasti berbeda
function Greeting() {
  return <p>Rendered at: {Date.now()}</p>;
}

Jika memang perlu menampilkan nilai runtime browser, render placeholder yang stabil dulu lalu isi nilainya setelah mount.

3. State dari localStorage, sessionStorage, atau IndexedDB

State persisten sering membuat render pertama client berbeda dari hasil SSR. Contoh klasik adalah tema, tab aktif terakhir, mode panel, atau filter yang dipulihkan dari storage.

// Buruk: nilai awal berubah tergantung storage client
function ThemeBanner() {
  const theme = typeof window !== 'undefined'
    ? localStorage.getItem('theme') || 'light'
    : 'light';

  return <div data-theme={theme}>...</div>;
}

Jika client punya theme=dark, markup awalnya berbeda dari SSR yang menganggap light.

4. Media query dan kemampuan perangkat

Keputusan UI berdasarkan matchMedia, ukuran viewport, hover capability, coarse pointer, atau preferensi reduced motion sering memecah konsistensi SSR.

  • (max-width: ...)
  • (prefers-color-scheme: dark)
  • (hover: none)
  • (pointer: coarse)

Hal ini umum pada menu responsif, tooltip, navigasi hover, dan komponen drag-and-drop yang perilakunya berbeda di sentuh vs mouse.

5. Listener global yang mengubah state terlalu dini

Misalnya komponen mendaftarkan resize listener, lalu langsung menghitung state yang memengaruhi struktur markup. Jika perhitungan awal di client berjalan sebelum atau saat hydration, subtree bisa berubah sebelum framework selesai memasang event dan merekonsiliasi node.

6. State async yang dipulihkan setelah load

UI sering membaca dari store global, cache, query client, atau event bus internal setelah halaman dimuat. Jika state ini mengubah struktur utama komponen—misalnya panel terbuka, mode navigasi, atau urutan item—mismatch mudah terjadi bila SSR tidak memakai snapshot awal yang sama.

Cara mereproduksi bug dengan disiplin

Mismatch hydration sering tampak acak karena bergantung pada timing. Audit yang baik dimulai dari reproduksi yang dapat diulang.

1. Jalankan build produksi

Bug hydration kadang tidak muncul sama seperti di mode development. Uji pada build produksi lokal karena urutan render, streaming, dan optimasi bisa berbeda.

# Contoh umum
npm run build
npm run start

2. Uji dengan variasi environment browser

  • ubah ukuran viewport sebelum reload,
  • aktifkan mode gelap di sistem,
  • uji perangkat sentuh atau emulasi mobile,
  • isi dan kosongkan localStorage,
  • uji kondisi login vs logout jika ada state persisten.

3. Matikan JavaScript sementara untuk melihat output SSR murni

Tujuannya bukan menjalankan aplikasi tanpa JS, melainkan memeriksa apa yang benar-benar dikirim server. Bandingkan dengan DOM setelah hydration untuk komponen yang dicurigai.

4. Tambahkan logging pada fase render dan mount

Log sederhana sering cukup untuk melihat apakah nilai pembentuk markup awal berbeda.

function ResponsiveNav() {
  const initial = 'desktop';
  console.log('render ResponsiveNav');

  React.useEffect(() => {
    console.log('mounted on client', {
      width: window.innerWidth,
      theme: localStorage.getItem('theme')
    });
  }, []);

  return <nav data-mode={initial}>...</nav>;
}

Jika render awal mengasumsikan satu mode tetapi effect langsung mengubahnya, Anda sudah punya arah audit.

Checklist audit komponen SSR

Gunakan checklist ini pada komponen yang dicurigai:

  1. Apakah output render bergantung pada API browser?
    Cari penggunaan window, document, navigator, matchMedia, ResizeObserver, atau ukuran viewport di badan komponen.
  2. Apakah ada nilai non-deterministik saat render?
    Periksa timestamp, random, ID acak, dan format waktu yang bergantung locale client.
  3. Apakah state awal berasal dari storage atau cache client?
    Jika ya, apakah render server memakai snapshot yang sama, atau minimal fallback yang tidak mengubah struktur?
  4. Apakah event global mengubah state yang menentukan markup utama?
    Contoh: menu mobile vs desktop, open vs closed, tab A vs tab B.
  5. Apakah komponen memakai kondisi perangkat untuk memilih subtree berbeda?
    Jika desktop merender elemen A sementara mobile merender elemen B, SSR harus berhati-hati karena server tidak tahu viewport sebenarnya.
  6. Apakah ada store global yang direhidrasi setelah load?
    Pastikan SSR dan client memulai dari nilai awal yang sinkron.
  7. Apakah bug hanya terjadi pada route tertentu?
    Komponen layout, portal, overlay, dan navigasi global sering menjadi sumber masalah karena hidup lintas halaman.

Strategi isolasi bug

Kurangi area masalah dari luar ke dalam

Mulai dari komponen besar yang memunculkan warning, lalu nonaktifkan bagian-bagian yang paling bergantung pada event atau environment browser. Tujuannya mencari batas subtree yang menyebabkan perbedaan.

Bekukan state awal

Ganti sementara semua pembacaan runtime dengan nilai statis. Jika warning hilang, sumber masalah hampir pasti berasal dari state yang tidak deterministik atau tidak sinkron.

Pisahkan “render awal” dan “enhancement setelah mount”

Ini prinsip paling penting. Banyak bug selesai ketika komponen dibagi menjadi:

  • output SSR yang stabil, dan
  • enhancement client yang menambahkan perilaku setelah mount.

Jika dua tahap ini tercampur, hydration mudah rusak.

Pola perbaikan yang aman

1. Render fallback yang stabil, lalu sinkronkan setelah mount

Untuk state yang hanya diketahui di browser, gunakan nilai awal netral yang tidak membuat struktur DOM berubah drastis.

// Sebelum
function DeviceMenu() {
  const isTouch = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
  return isTouch ? <MobileMenu /> : <DesktopMenu />;
}

// Sesudah
function DeviceMenu() {
  const [isTouch, setIsTouch] = React.useState(null);

  React.useEffect(() => {
    const mq = window.matchMedia('(pointer: coarse)');
    const update = () => setIsTouch(mq.matches);
    update();
    mq.addEventListener?.('change', update);
    return () => mq.removeEventListener?.('change', update);
  }, []);

  // Fallback SSR/client awal yang stabil
  if (isTouch === null) {
    return <MenuSkeleton />;
  }

  return isTouch ? <MobileMenu /> : <DesktopMenu />;
}

Mengapa ini bekerja? Karena server dan client sama-sama merender MenuSkeleton pada fase awal. Pergantian ke mode final terjadi setelah hydration.

2. Inisialisasi state persisten di effect, bukan saat render

// Sebelum
function Filters() {
  const [open, setOpen] = React.useState(
    typeof window !== 'undefined' && localStorage.getItem('filters-open') === '1'
  );

  return <aside aria-expanded={open}>...</aside>;
}

// Sesudah
function Filters() {
  const [open, setOpen] = React.useState(false);

  React.useEffect(() => {
    setOpen(localStorage.getItem('filters-open') === '1');
  }, []);

  React.useEffect(() => {
    localStorage.setItem('filters-open', open ? '1' : '0');
  }, [open]);

  return <aside aria-expanded={open}>...</aside>;
}

Trade-off-nya: mungkin ada sedikit perubahan visual setelah mount. Namun ini jauh lebih aman daripada mismatch hydration. Jika perubahan visual mengganggu, gunakan skeleton, placeholder, atau kirim snapshot state dari server bila memungkinkan.

3. Kirim snapshot awal yang sama dari server ke client

Jika state awal memang diketahui saat request—misalnya dari cookie, header, atau data server—lebih baik gunakan snapshot yang sama untuk SSR dan client. Ini cocok untuk tema, locale, eksperimen fitur, atau preferensi yang bisa dibaca server.

Pendekatan ini lebih baik daripada membaca ulang dari storage client jika Anda ingin menghindari flicker sekaligus mempertahankan konsistensi render awal.

4. Gunakan CSS untuk responsivitas, bukan percabangan markup bila memungkinkan

Sering kali masalah muncul karena desktop dan mobile dirender sebagai subtree yang berbeda. Jika perbedaannya hanya tata letak atau visibilitas, lebih aman memakai CSS daripada mengganti struktur HTML berdasarkan viewport saat render.

// Lebih aman: markup sama, perilaku responsif lewat CSS
function Nav() {
  return (
    <nav className="nav">
      <button className="nav__toggle">Menu</button>
      <ul className="nav__list">...</ul>
    </nav>
  );
}

Pilih percabangan markup hanya jika benar-benar perlu, misalnya perilaku interaksi yang sangat berbeda dan sulit disatukan.

5. Tunda pemasangan listener global sampai mount

Listener seperti resize, scroll, visibilitychange, atau event bus global sebaiknya dipasang di effect. Jangan biarkan hasil event tersebut menentukan output awal sebelum hydrate selesai.

function ViewportPanel() {
  const [compact, setCompact] = React.useState(false);

  React.useEffect(() => {
    const update = () => setCompact(window.innerWidth < 768);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return <section data-compact={compact}>...</section>;
}

Jika atribut atau class ini mengubah banyak struktur, pertimbangkan fallback awal yang lebih netral.

6. Gunakan client-only boundary bila komponen memang tidak layak di-SSR

Tidak semua komponen pantas dipaksa SSR. Jika sebuah komponen sangat bergantung pada API browser dan tidak memiliki representasi server yang stabil, client-only boundary bisa menjadi pilihan yang benar.

Contoh kasus:

  • editor berbasis DOM yang berat,
  • kanvas interaktif,
  • widget drag-and-drop kompleks,
  • komponen yang sepenuhnya bergantung pada viewport atau device capability,
  • integrasi library pihak ketiga yang membaca DOM saat inisialisasi.

Trade-off:

  • konten komponen tidak tersedia penuh pada HTML awal,
  • potensi dampak SEO jika isinya penting untuk indeksasi,
  • render pertama mungkin menunggu JavaScript client.

Gunakan boundary ini bila biaya menjaga konsistensi SSR lebih tinggi daripada manfaat SSR untuk komponen tersebut.

Kapan memakai client-only boundary, kapan tetap SSR

Tetap SSR jika:

  • konten penting untuk SEO atau first paint,
  • Anda bisa menyediakan state awal yang deterministik,
  • perbedaan browser hanya menambah enhancement, bukan mengganti struktur utama.

Pakai client-only boundary jika:

  • markup awal tidak bisa ditentukan tanpa environment browser,
  • library pihak ketiga memerlukan DOM saat import atau inisialisasi,
  • komponen sering memicu mismatch walau sudah dipisah fase render dan mount,
  • nilai bisnis SSR untuk komponen itu rendah.

Contoh audit singkat pada komponen nyata

Kasus: header berubah menurut viewport, tema, dan state panel tersimpan

Misalkan sebuah header:

  • menampilkan menu desktop atau drawer mobile,
  • membaca tema dari localStorage,
  • membuka panel notifikasi berdasarkan event bus yang dipulihkan setelah load.

Risikonya tinggi karena ada tiga sumber perbedaan render awal. Audit praktisnya:

  1. hapus dulu pembacaan viewport saat render; ganti dengan markup tunggal dan CSS bila mungkin,
  2. ganti pembacaan tema dari storage menjadi snapshot server atau fallback stabil,
  3. pastikan panel notifikasi default tertutup saat SSR dan baru disinkronkan di effect,
  4. pasang listener event bus hanya setelah mount,
  5. uji reload di mobile, desktop, dark mode, dan dengan storage yang sudah terisi.

Sering kali perbaikan terbesar datang bukan dari satu baris kode, melainkan dari keputusan arsitektur: jangan jadikan event runtime sebagai penentu markup awal.

Tips debugging tambahan di React/Next.js atau framework serupa

  • Periksa node yang pertama kali berbeda. Warning hydration sering memberi petunjuk subtree awal yang rusak, meski bukan akar masalah sebenarnya.
  • Waspadai conditional render yang membelah struktur. isMobile ? <A /> : <B /> lebih berisiko daripada satu struktur dengan variasi class.
  • Jangan membaca storage di initializer state jika output UI bergantung padanya. Secara teknis bisa berjalan di client, tetapi sering membuat output awal tidak sinkron dengan SSR.
  • Audit custom hook. Banyak bug tersembunyi di hook seperti useMediaQuery, useDevice, useTheme, atau usePersistentState.
  • Uji dengan throttling CPU. Perubahan timing bisa memperjelas race condition antara hydration dan event awal.
  • Periksa import library pihak ketiga. Beberapa library menyentuh DOM pada saat module dievaluasi, bahkan sebelum komponen mount.

Kesalahan umum saat memperbaiki mismatch hydration

Menyembunyikan warning tanpa memperbaiki penyebab

Menghilangkan gejala bukan berarti menyelesaikan akar masalah. Jika output awal tetap berbeda, Anda masih berisiko mendapat UI flicker atau event binding yang tidak konsisten.

Terlalu banyak client-only

Memindahkan semua komponen bermasalah ke client-only memang cepat, tetapi bisa mengorbankan manfaat SSR. Gunakan pendekatan ini selektif.

Memakai fallback yang tidak stabil

Fallback harus sama antara server dan client. Jika fallback sendiri masih membaca environment browser, mismatch tetap terjadi.

Ringkasan audit yang bisa langsung dipakai

  1. Identifikasi komponen yang markup awalnya dipengaruhi event, storage, viewport, atau capability perangkat.
  2. Pastikan server render dan client render pertama menghasilkan output yang sama.
  3. Pindahkan pembacaan API browser dan pemasangan listener ke effect.
  4. Gunakan fallback SSR yang stabil untuk state yang hanya diketahui di client.
  5. Utamakan CSS untuk responsivitas jika perbedaannya tidak harus mengubah struktur.
  6. Kirim snapshot state dari server bila memang tersedia saat request.
  7. Gunakan client-only boundary hanya untuk komponen yang memang tidak punya representasi SSR yang stabil atau tidak memberi banyak nilai saat di-SSR.

Audit SSR untuk UI event-driven pada dasarnya adalah latihan memisahkan dua fase dengan tegas: markup awal yang deterministik dan reaksi runtime terhadap event. Begitu pemisahan ini disiplin diterapkan, mismatch hydration jauh lebih mudah dicegah, direproduksi, dan diperbaiki.