Hydration mismatch pada SSR React terjadi ketika HTML hasil render di server tidak cocok dengan tree yang dihitung ulang di browser saat hydration. Salah satu penyebab paling umum adalah state acak: pemakaian Math.random(), Date.now(), shuffle, pemilihan item acak, atau state turunan lain yang nilainya bisa berbeda antara server dan client.

Masalah ini bukan sekadar warning yang bisa diabaikan. Ketika tree awal berbeda, React bisa mengganti node, mengulang render, menandai atribut tidak sinkron, atau menghasilkan perilaku UI yang membingungkan. Pada kasus tertentu, event handler terasa “salah nempel”, urutan elemen berubah, atau tampilan berkedip karena client memperbaiki DOM yang berbeda dari hasil server.

Artikel ini fokus pada cara mendiagnosis dan memperbaiki debug hydration mismatch yang berasal dari state tidak deterministik pada SSR React atau Next.js, dengan pola yang praktis untuk produksi.

Mengapa state acak memicu hydration mismatch

Pada SSR, alur sederhananya seperti ini:

  1. Server merender komponen React menjadi HTML.
  2. Browser menerima HTML dan menampilkannya lebih dulu.
  3. React di client menjalankan render lagi untuk melakukan hydration dan mengaitkan event handler ke DOM yang sudah ada.

Hydration hanya berjalan mulus jika hasil render di client secara logis sama dengan hasil render di server untuk state awal yang sama. Begitu ada sumber nilai yang berubah di masing-masing sisi, hasilnya bisa berbeda.

Contoh sumber ketidakcocokan yang umum:

  • Math.random() dipanggil saat render.
  • Date.now() atau new Date() dipakai untuk menentukan isi UI.
  • Array di-shuffle saat render.
  • Pemilihan varian A/B dilakukan tanpa seed yang sama.
  • State turunan dari urutan object atau data yang berubah.
  • Kode yang bergantung pada environment browser, locale, timezone, atau ukuran layar.

Konsep yang penting di sini mirip dengan correlated randomness: nilai acak tidak boleh benar-benar lepas di tiap tempat bila hasilnya harus konsisten. Untuk SSR, server dan client harus berbagi sumber keputusan yang sama, atau client harus menunggu sampai hydration selesai sebelum membuat keputusan acak baru.

Gejala di browser dan dampaknya pada UI

Gejala paling jelas biasanya muncul di console browser. Pesannya dapat berbeda, tetapi pola umumnya:

  • Warning bahwa text content tidak cocok antara server dan client.
  • Warning bahwa hydration gagal karena markup berbeda.
  • React membuang hasil server untuk subtree tertentu lalu merender ulang di client.

Di luar console, dampaknya bisa lebih terasa di UI:

  • Flicker: server menampilkan item A, lalu client mengganti jadi item B.
  • Urutan elemen berubah: daftar hasil shuffle berbeda setelah hydration.
  • Class atau atribut berubah: misalnya warna, badge, atau layout varian acak berubah setelah halaman tampil.
  • Event terasa tidak konsisten: saat subtree diganti, elemen yang terlihat pengguna bisa berubah posisi atau identitasnya sehingga interaksi jadi membingungkan.

Catatan: React berusaha memulihkan mismatch, tetapi pemulihan itu bukan jaminan perilaku UI tetap tepat. Jika identitas elemen berubah, state lokal komponen anak juga bisa ikut berubah atau ter-reset.

Contoh bug: Math.random() dipanggil saat render

Contoh berikut tampak sederhana, tetapi bermasalah pada SSR:

function PromoBanner() {
  const variant = Math.random() > 0.5 ? 'diskon' : 'gratis-ongkir';

  return (
    <section>
      {variant === 'diskon' ? (
        <button>Lihat Diskon</button>
      ) : (
        <button>Cek Gratis Ongkir</button>
      )}
    </section>
  );
}

Masalahnya: server bisa memilih diskon, tetapi client saat hydration bisa memilih gratis-ongkir. HTML awal berisi tombol A, sementara render client mengharapkan tombol B. Hasilnya: warning hydration mismatch dan kemungkinan subtree diganti.

Contoh lain yang lebih halus adalah shuffle daftar:

function RecommendationList({ items }) {
  const shuffled = [...items].sort(() => Math.random() - 0.5);

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

Walaupun setiap item punya key, urutan yang berbeda antara server dan client tetap bisa memicu mismatch karena DOM awal yang di-hydrate tidak sama dengan hasil render client.

Mengapa server dan client menghasilkan tree berbeda

React tidak menyimpan hasil keputusan acak dari server secara otomatis, kecuali Anda sendiri mengirimkan state awal tersebut. Bila komponen menghitung nilai acak pada fase render, maka server dan client masing-masing menjalankan logika itu secara independen.

Itu berarti dua proses berbeda membuat dua keputusan berbeda. Pada SSR, ini cukup untuk menghasilkan tree yang berbeda, misalnya:

  • Text node berbeda.
  • Jumlah elemen berbeda.
  • Urutan anak berbeda.
  • Class name berbeda.
  • Cabang komponen berbeda sama sekali.

Masalahnya makin sulit dilacak ketika “acak”-nya bukan literal Math.random(), tetapi state turunan yang diam-diam tidak deterministik, misalnya:

  • Mengambil item pertama dari object yang dibangun dari sumber tidak stabil.
  • Mengurutkan data tanpa comparator yang jelas.
  • Menghitung tampilan dari waktu saat ini.
  • Menggunakan ID acak untuk atribut atau key.

Pola perbaikan yang aman

1. Gunakan seed deterministik

Jika UI memang harus “acak” tetapi konsisten antara server dan client, gunakan pseudo-random generator yang diberi seed sama. Dengan begitu, keputusan di kedua sisi akan identik untuk input yang sama.

Seed bisa berasal dari data yang stabil, misalnya:

  • ID user.
  • ID request atau session.
  • ID konten.
  • Kombinasi tanggal tertentu jika memang diinginkan per hari.

Contoh pendekatan:

function seededRandom(seed) {
  let x = seed % 2147483647;
  if (x <= 0) x += 2147483646;
  return function () {
    x = (x * 16807) % 2147483647;
    return (x - 1) / 2147483646;
  };
}

function PromoBanner({ seed }) {
  const rand = seededRandom(seed)();
  const variant = rand > 0.5 ? 'diskon' : 'gratis-ongkir';

  return (
    <section>
      {variant === 'diskon' ? (
        <button>Lihat Diskon</button>
      ) : (
        <button>Cek Gratis Ongkir</button>
      )}
    </section>
  );
}

Mengapa ini bekerja? Karena server dan client tidak lagi “berjudi” sendiri-sendiri. Keduanya menjalankan generator yang sama dengan seed yang sama, sehingga cabang render yang dipilih tetap sinkron.

Trade-off: Anda harus memastikan seed benar-benar sama dan stabil pada render awal. Jika seed bergantung pada nilai yang hanya tersedia di browser, mismatch tetap mungkin terjadi.

2. Precompute di server lalu kirim state awal

Untuk banyak kasus, cara paling sederhana adalah hitung sekali di server, lalu kirim hasilnya sebagai prop atau bagian dari payload data awal. Client tidak perlu menghitung ulang keputusan acaknya.

// Server-side pseudo code
const initialVariant = choosePromoVariant(userId);
return renderPage({ initialVariant });
function PromoBanner({ initialVariant }) {
  return (
    <section>
      {initialVariant === 'diskon' ? (
        <button>Lihat Diskon</button>
      ) : (
        <button>Cek Gratis Ongkir</button>
      )}
    </section>
  );
}

Pola ini sangat cocok untuk:

  • Eksperimen UI.
  • Rekomendasi awal yang sudah dipilih server.
  • Urutan daftar yang harus konsisten saat first paint.
  • Konten personalisasi yang ingin stabil selama satu request.

Kelebihan: sederhana dan mudah diaudit. Kekurangan: jika Anda ingin perilaku benar-benar dinamis setelah halaman aktif, Anda tetap perlu mekanisme update state lanjutan di client.

3. Kirim state awal, bukan hanya seed, jika transformasinya kompleks

Kadang seed saja tidak cukup praktis. Misalnya Anda men-shuffle daftar besar, memilih beberapa item, lalu menurunkan banyak state lain dari hasil itu. Daripada menghitung ulang seluruh transformasi di client, kirim hasil final yang sudah dihitung server.

function RecommendationList({ initialItems }) {
  return (
    <ul>
      {initialItems.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Ini mengurangi peluang mismatch dari banyak titik sekaligus. Anda juga menghindari risiko implementasi shuffle yang tidak identik antara dua environment.

4. Pindahkan logika acak ke useEffect bila aman

Jika keputusan acak tidak perlu ada pada HTML awal, pindahkan ke useEffect. Karena effect hanya berjalan di browser setelah hydration, server dan client akan sama-sama merender UI awal yang stabil terlebih dahulu.

import { useEffect, useState } from 'react';

function PromoBanner() {
  const [variant, setVariant] = useState(null);

  useEffect(() => {
    setVariant(Math.random() > 0.5 ? 'diskon' : 'gratis-ongkir');
  }, []);

  if (variant === null) {
    return <section>Memuat promo...</section>;
  }

  return (
    <section>
      {variant === 'diskon' ? (
        <button>Lihat Diskon</button>
      ) : (
        <button>Cek Gratis Ongkir</button>
      )}
    </section>
  );
}

Mengapa ini bekerja? Karena HTML dari server dan render hydration awal di client sama-sama menampilkan fallback yang stabil. Keputusan acak baru dibuat setelah hydration selesai.

Trade-off penting:

  • Ada kemungkinan flicker dari fallback ke UI final.
  • Konten final tidak ikut SSR, sehingga bisa berdampak pada UX atau SEO jika kontennya penting.
  • Jika komponen menentukan layout utama, perubahan setelah mount bisa terasa mengganggu.

Pilih pendekatan ini hanya jika hasil acaknya memang tidak wajib tampil sejak first paint.

5. Pastikan state turunan tetap deterministik

Banyak mismatch tidak berasal dari random literal, melainkan dari turunan state yang tidak stabil. Periksa hal-hal berikut:

  • Jangan gunakan sort() tanpa comparator yang jelas untuk data yang sensitif urutan.
  • Jangan bangun key dari nilai acak atau waktu.
  • Jangan hitung class, label, atau struktur layout dari Date.now() saat render SSR.
  • Jangan gunakan environment browser saat render server, misalnya ukuran viewport, tanpa fallback yang konsisten.

Contoh perbaikan yang lebih realistis di Next.js/SSR

Misalkan Anda punya daftar artikel rekomendasi yang ingin diacak per user, tetapi tetap konsisten saat halaman pertama kali dimuat.

// Server-side pseudo code
function shuffleWithSeed(items, seed) {
  const rand = seededRandom(seed);
  const arr = [...items];

  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }

  return arr;
}

const seed = hash(userId + ':' + pageId);
const initialRecommendations = shuffleWithSeed(recommendations, seed);

return {
  props: {
    seed,
    initialRecommendations
  }
};
function Recommendations({ initialRecommendations }) {
  return (
    <aside>
      <h2>Rekomendasi</h2>
      <ul>
        {initialRecommendations.map((item) => (
          <li key={item.id}>
            <a href={item.href}>{item.title}</a>
          </li>
        ))}
      </ul>
    </aside>
  );
}

Pada pola ini, keputusan urutan dibuat sekali di server dan client hanya meng-hydrate hasil yang sama. Jika nanti Anda ingin reshuffle setelah interaksi tertentu, lakukan itu setelah hydration lewat state client yang eksplisit.

Kesalahan umum yang sering terjadi

Menyangka key menyelesaikan mismatch

key membantu React mengidentifikasi elemen dalam list, tetapi tidak membuat hasil render server dan client otomatis sinkron. Jika isi atau urutan list berbeda saat hydration, warning tetap bisa muncul.

Menggunakan useMemo untuk “mengunci” random

useMemo(() => Math.random(), []) tidak menyelesaikan masalah SSR. Server dan client tetap menjalankan render terpisah, sehingga nilai memo awalnya tetap bisa berbeda.

Menghasilkan ID acak di render untuk atribut DOM

ID acak yang dipakai untuk id, htmlFor, aria-describedby, atau target elemen lain bisa menyebabkan mismatch atribut dan relasi aksesibilitas yang tidak konsisten.

Mengandalkan waktu sekarang untuk memilih cabang UI

Perbedaan beberapa milidetik saja sudah cukup membuat server dan client memilih hasil berbeda jika logika cabangnya sensitif terhadap waktu.

Cara mendiagnosis di aplikasi nyata

1. Cari semua sumber non-deterministik saat render

Lakukan pencarian kode untuk pola berikut:

  • Math.random(
  • Date.now(
  • new Date(
  • sort(() => Math.random()
  • crypto yang dipakai untuk nilai render
  • helper yang membuat ID unik saat render

Fokuskan pencarian pada komponen yang ikut SSR, terutama yang tampil di atas lipatan halaman atau yang bercabang berdasarkan data runtime.

2. Bandingkan output server dan client

Tambahkan logging sementara untuk nilai yang menentukan cabang render. Misalnya, log seed, variant, urutan ID item, atau timestamp yang dipakai. Tujuannya bukan logging permanen, tetapi memastikan apakah server dan client memulai dari input yang sama.

Untuk debugging lokal, Anda juga bisa:

  • Mematikan JavaScript sementara untuk melihat HTML server murni.
  • Reload berkali-kali untuk melihat apakah mismatch sporadis.
  • Menguji di timezone atau locale berbeda jika waktu/format tanggal ikut memengaruhi render.

3. Periksa subtree yang pertama kali mismatch

Warning hydration sering memberi petunjuk area DOM yang bermasalah. Mulailah dari komponen terdekat yang:

  • menghasilkan daftar dinamis,
  • menentukan varian tampilan,
  • membuat ID atau atribut unik,
  • bergantung pada browser-only data.

Biasanya akar masalah ada satu atau dua tingkat di atas elemen yang dilaporkan.

4. Audit transformasi data sebelum render

Jangan hanya mencari random eksplisit. Audit juga helper transformasi seperti:

  • fungsi shuffle,
  • fungsi pick-random,
  • sorting berdasarkan field yang kadang kosong,
  • normalisasi object ke array,
  • state turunan yang diinisialisasi dari nilai runtime.

Checklist debugging untuk produksi

Jika hydration mismatch hanya muncul di produksi, gunakan checklist ini:

  1. Identifikasi komponen SSR yang bercabang berdasarkan random, waktu, locale, user agent, atau browser state.
  2. Pastikan semua keputusan first render bisa direproduksi di server dan client.
  3. Kirim seed atau initial state melalui payload SSR, bukan menghitung ulang secara implisit di browser.
  4. Hindari random dalam render path, termasuk di helper yang dipanggil dari JSX.
  5. Log nilai penentu render secara sampling: request ID, seed, variant, urutan item, dan halaman terkait.
  6. Verifikasi bahwa fallback dan UI final konsisten bila logika dipindah ke useEffect.
  7. Periksa data personalisasi apakah server dan client memakai sumber user yang sama pada first render.
  8. Uji pada cache/CDN bila HTML server bisa tercampur antar user tetapi client menghitung state dari user aktif.
  9. Pastikan tidak ada ID acak untuk relasi DOM penting seperti label dan elemen form.
  10. Buat test untuk determinisme pada helper shuffle, seed, dan pemilihan varian.

Kapan memilih tiap solusi

Pilih seed deterministik jika

  • Anda butuh hasil “acak” tetapi stabil.
  • Server dan client sama-sama perlu bisa menghitung hasilnya.
  • Anda ingin variasi per user, per halaman, atau per periode tertentu.

Pilih precompute di server jika

  • Anda ingin implementasi paling aman untuk SSR.
  • Transformasi datanya cukup kompleks.
  • Konsistensi first paint lebih penting daripada fleksibilitas client.

Pilih useEffect jika

  • Nilai acak tidak perlu ada pada HTML awal.
  • Anda menerima adanya fallback atau perubahan setelah mount.
  • Komponen lebih cocok menjadi perilaku client-only.

Penutup

Akar dari hydration mismatch karena state acak di SSR React sederhana: server dan client membuat keputusan render yang berbeda. Penyebabnya bisa jelas seperti Math.random(), atau tersembunyi dalam shuffle, waktu, seed yang tidak sinkron, dan state turunan yang tidak deterministik.

Perbaikan yang paling andal adalah memastikan keputusan first render bersifat deterministik: gunakan seed yang sama, hitung di server, kirim state awal, atau tunda keputusan ke useEffect jika SSR tidak diperlukan untuk bagian itu. Jika Anda menangani masalah ini sebagai persoalan korelasi keputusan antara server dan client, proses debug akan jauh lebih terarah daripada sekadar memburu warning di console.