Hydration mismatch dari flag tema biasanya terjadi ketika server merender markup dengan asumsi tema tertentu, tetapi client menghitung tema yang berbeda saat hydration. Dampaknya terlihat sebagai warning hydration, UI berkedip dari terang ke gelap atau sebaliknya, dan kadang atribut seperti class atau data-theme berubah tepat setelah JavaScript berjalan.

Masalah ini tidak bisa diselesaikan hanya dengan memindahkan logika tema ke useEffect atau hook serupa. Itu memang menghindari akses API browser saat SSR, tetapi sering tetap menghasilkan markup awal server yang berbeda dari yang akhirnya dipakai client. Solusi yang benar adalah menyamakan sumber state, menentukan tema sedini mungkin, dan memastikan output awal server dan client konsisten.

Kenapa hydration mismatch terjadi pada tema?

Pada aplikasi SSR, ada dua fase penting:

  1. Server render: server menghasilkan HTML awal.
  2. Hydration di client: framework memasang event listener dan mencocokkan tree virtual dengan DOM yang sudah ada.

Hydration berjalan lancar jika DOM hasil SSR sama dengan output render pertama di client. Jika server merender <html class="light"> tetapi client pada render pertamanya memutuskan tema dark, framework akan mendeteksi perbedaan. Hasilnya bisa berupa warning, patch DOM tambahan, atau perilaku UI yang terasa “meloncat”.

Sumber tema yang paling sering memicu mismatch

  • localStorage: hanya tersedia di browser, tidak bisa diakses saat SSR.
  • Cookie: bisa diakses server dan client, sehingga lebih cocok sebagai sumber state bersama.
  • prefers-color-scheme: berasal dari browser/OS client, server biasanya tidak tahu nilainya kecuali diberi hint tambahan.

Masalah muncul ketika ketiga sumber ini digunakan tanpa prioritas yang jelas. Contohnya, server default ke light, lalu client membaca localStorage.theme = 'dark', lalu CSS dan atribut root berubah setelah hydration. Secara visual, pengguna melihat flash atau flicker.

Gejala umum: UI berkedip, warning hydration, dan atribut root berubah

Beberapa gejala yang umum terlihat:

  • Halaman tampil terang sesaat lalu berubah gelap.
  • Muncul warning seperti “hydration failed”, “text content does not match”, atau peringatan atribut berbeda antara server dan client.
  • Nilai class, style, atau data-theme pada elemen html / body berubah segera setelah JavaScript aktif.
  • Ikon toggle tema, logo, atau gambar berbasis tema berubah setelah render awal.

Dalam praktiknya, mismatch sering bukan hanya pada teks, tetapi pada atribut root yang memengaruhi banyak komponen sekaligus. Jika seluruh desain memakai class dark di root, satu perbedaan kecil di elemen html bisa membuat seluruh subtree tampak berbeda.

Root cause: server dan client memakai keputusan tema yang berbeda

Akar masalahnya sederhana: server dan client tidak membuat keputusan tema dari data yang sama pada waktu yang sama.

Berikut pola yang sering salah:

// Pola bermasalah secara umum
// Server tidak bisa membaca localStorage
const initialTheme = typeof window !== 'undefined'
  ? localStorage.getItem('theme') || 'light'
  : 'light';

Saat SSR, hasilnya selalu light. Saat client render pertama, hasilnya bisa dark. Itu sudah cukup untuk memicu mismatch.

Pola lain yang tampak aman tetapi masih menimbulkan flicker:

// Menghindari akses browser saat SSR, tetapi tema baru dipasang setelah mount
const [theme, setTheme] = useState('light');

useEffect(() => {
  const saved = localStorage.getItem('theme');
  if (saved) setTheme(saved);
}, []);

Ini mungkin mengurangi warning pada sebagian kasus, tetapi HTML awal tetap dirender sebagai light. Jika preferensi pengguna sebenarnya dark, UI tetap berkedip karena perubahan baru terjadi setelah mount.

Strategi perbaikan yang paling efektif

1. Sinkronisasi sumber state tema

Jika aplikasi SSR perlu menghasilkan markup yang benar sejak awal, gunakan sumber state yang bisa dibaca oleh server dan client. Pilihan paling praktis adalah cookie.

Prinsipnya:

  • Saat pengguna mengganti tema, simpan nilai ke cookie.
  • Server membaca cookie saat merender halaman.
  • Client memakai nilai yang sama sebagai initial state.

Dengan begitu, keputusan tema tidak lagi bergantung pada localStorage yang hanya tersedia di browser.

Contoh pola umum:

// pseudo-code server
const themeFromCookie = readCookie(request, 'theme');
const theme = themeFromCookie === 'dark' ? 'dark' : 'light';

// render HTML root dengan data yang konsisten
<html data-theme={theme} class={theme === 'dark' ? 'dark' : ''}>

Lalu di client:

// pseudo-code client
const initialTheme = window.__INITIAL_THEME__ || 'light';
const [theme, setTheme] = useState(initialTheme);

Jika framework Anda mendukung pengiriman props dari server ke layout atau root app, gunakan mekanisme itu alih-alih membaca ulang sumber yang berbeda saat client boot.

2. Jalankan inline script awal sebelum hydration

Jika tema tetap perlu ditentukan dari localStorage atau prefers-color-scheme, cara yang umum dipakai adalah menyisipkan inline script kecil di dokumen HTML agar class tema dipasang sebelum CSS dan hydration berjalan.

Tujuannya bukan menghilangkan semua perbedaan secara ajaib, melainkan memastikan DOM root sudah berada pada tema yang benar sedini mungkin sehingga flicker berkurang drastis.

<script>
(function () {
  try {
    var saved = localStorage.getItem('theme');
    var systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    var theme = saved === 'light' || saved === 'dark' ? saved : (systemDark ? 'dark' : 'light');

    var root = document.documentElement;
    root.dataset.theme = theme;
    if (theme === 'dark') root.classList.add('dark');
    else root.classList.remove('dark');
  } catch (e) {
    // fallback diam ke light atau default CSS
  }
})();
</script>

Script seperti ini efektif jika ditempatkan sangat awal di dokumen, idealnya sebelum stylesheet utama selesai diterapkan atau sebelum aplikasi di-hydrate. Di Next.js, Nuxt, atau SSR framework lain, biasanya ada titik injeksi untuk script awal di dokumen/layout. Hindari menaruhnya terlalu terlambat di komponen yang baru dieksekusi setelah mount.

Catatan: inline script membantu mengurangi flicker, tetapi jika server merender markup berbeda dari yang diharapkan client, warning hydration tetap mungkin terjadi. Solusi terbaik tetap menyamakan keputusan tema antara server dan client.

3. Gunakan cookie-based SSR hint

Pendekatan yang paling stabil untuk SSR adalah menjadikan cookie sebagai hint tema pada render awal. Ini sangat berguna saat pengguna pernah memilih tema secara eksplisit.

Alur yang disarankan:

  1. Pengguna memilih dark atau light.
  2. Client menyimpan pilihan ke cookie.
  3. Request berikutnya membawa cookie ke server.
  4. Server merender HTML root dengan tema yang sama.
  5. Client hydration memakai nilai awal yang identik.

Untuk preferensi sistem (prefers-color-scheme), Anda bisa menerapkan kebijakan berikut:

  • Jika ada cookie eksplisit dari pengguna, utamakan cookie.
  • Jika tidak ada cookie, pakai fallback stabil, misalnya light, atau gunakan inline script awal untuk menyesuaikan ke preferensi sistem sebelum hydration.

Pendekatan ini realistis karena server memang tidak selalu tahu preferensi sistem client pada request pertama.

4. Guard client-only untuk bagian UI tertentu

Tidak semua elemen harus ikut SSR. Beberapa bagian yang sangat bergantung pada state browser bisa ditunda render-nya sampai client siap, misalnya:

  • ikon toggle yang berubah sesuai tema aktif,
  • label teks “Dark mode” / “Light mode”,
  • preview gambar yang berbeda per tema.

Pola ini berguna jika bagian tersebut kecil dan tidak kritis untuk SEO atau first paint. Contoh umum:

// pseudo-code umum
function ThemeToggle() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  if (!mounted) {
    return <button aria-label="Ubah tema" disabled>...</button>;
  }

  return <button aria-label="Ubah tema">{currentTheme}</button>;
}

Trade-off-nya jelas: mismatch pada komponen itu berkurang, tetapi Anda sengaja menerima placeholder sementara sampai client mount. Jangan gunakan pola ini untuk seluruh halaman jika sebenarnya masalahnya bisa diselesaikan dengan sinkronisasi state yang benar.

5. Siapkan fallback yang stabil

Jika server tidak tahu tema yang benar, pilih fallback yang konsisten dan minim efek samping. Misalnya:

  • selalu render root sebagai light,
  • gunakan palet netral untuk shell awal,
  • hindari merender teks atau ikon yang eksplisit menyebut tema sebelum client siap.

Fallback yang stabil bukan solusi utama, tetapi penting untuk mengurangi kejutan visual pada request pertama atau saat data preferensi belum tersedia.

Pola implementasi umum untuk Next.js, Nuxt, dan SSR frontend lain

Walaupun detail API tiap framework berbeda, pola arsitekturnya hampir sama.

Dokumen root harus menerima initial theme

Di level dokumen atau layout root, lakukan dua hal:

  1. Baca tema dari sumber yang tersedia di server, biasanya cookie.
  2. Terapkan nilai itu ke atribut root seperti data-theme atau class dark.

Contoh struktur yang umum:

<html data-theme="dark" class="dark">
  <head>
    <script>/* optional early theme sync */</script>
  </head>
  <body>
    <div id="app">...</div>
  </body>
</html>

Jika aplikasi Anda memakai provider tema di sisi client, pastikan initial state provider berasal dari nilai yang sama dengan yang sudah dipasang di root HTML.

Jangan baca localStorage sebagai initial state SSR

Untuk framework seperti Next.js atau Nuxt, kesalahan paling umum adalah mengisi state awal tema langsung dari API browser di modul atau render function. Simpan akses browser untuk script awal atau lifecycle client-only, bukan untuk menentukan hasil SSR.

Utamakan satu kontrak tema di seluruh aplikasi

Tentukan satu format yang dipakai konsisten di semua layer, misalnya:

  • nilai tema hanya light, dark, atau system,
  • atribut root selalu data-theme,
  • cookie memakai nama yang sama, misalnya theme.

Masalah hydration sering membesar karena ada translasi tak konsisten: cookie berisi night, provider mengharapkan dark, CSS membaca class dark, dan komponen lain membaca data-mode.

Contoh pola implementasi yang lebih aman

Berikut contoh pola umum yang tidak bergantung pada framework tertentu:

// server-side pseudo-code
function resolveThemeFromRequest(req) {
  const cookieTheme = readCookie(req, 'theme');

  if (cookieTheme === 'dark' || cookieTheme === 'light') {
    return cookieTheme;
  }

  return 'light'; // fallback stabil
}

const initialTheme = resolveThemeFromRequest(req);

renderDocument({
  htmlAttrs: {
    'data-theme': initialTheme,
    class: initialTheme === 'dark' ? 'dark' : ''
  },
  serializedState: {
    theme: initialTheme
  }
});
// client-side pseudo-code
const initialTheme = window.__APP_STATE__?.theme || document.documentElement.dataset.theme || 'light';

function applyTheme(theme) {
  const root = document.documentElement;
  root.dataset.theme = theme;
  root.classList.toggle('dark', theme === 'dark');
  document.cookie = 'theme=' + theme + '; path=/; max-age=31536000';
}

const [theme, setTheme] = useState(initialTheme);

function onThemeChange(nextTheme) {
  setTheme(nextTheme);
  applyTheme(nextTheme);
}

Kenapa pola ini lebih aman?

  • Server dan client memulai dari nilai yang sama.
  • DOM root sudah sesuai sejak HTML awal.
  • Perubahan tema berikutnya tetap sinkron karena cookie diperbarui.

Kapan memakai localStorage, cookie, atau prefers-color-scheme?

Cookie

Pilih cookie jika Anda butuh SSR yang konsisten. Cookie cocok untuk preferensi yang harus diketahui server pada request berikutnya.

Kelebihan:

  • bisa dibaca server dan client,
  • mengurangi mismatch pada SSR,
  • mudah dijadikan sumber kebenaran tunggal.

Kekurangan:

  • request pertama mungkin belum punya cookie,
  • perlu perhatian pada scope, expiry, dan kebijakan penulisan cookie.

localStorage

Pilih localStorage jika aplikasi murni client-side atau Anda menerima adanya script awal untuk sinkronisasi sebelum hydration.

Kelebihan:

  • sederhana dipakai di browser,
  • tidak ikut terkirim di setiap request.

Kekurangan:

  • tidak tersedia saat SSR,
  • sering memicu flicker jika dijadikan sumber tema pertama.

prefers-color-scheme

Pilih preferensi sistem sebagai fallback cerdas saat pengguna belum memilih tema eksplisit.

Kelebihan:

  • sesuai preferensi OS/browser pengguna,
  • baik untuk pengalaman awal.

Kekurangan:

  • server biasanya tidak tahu nilainya pada render awal,
  • perlu script awal atau fallback SSR yang stabil.

Checklist debugging hydration mismatch tema

  • Periksa HTML hasil SSR di View Source, bukan hanya DOM setelah JavaScript berjalan.
  • Bandingkan atribut root seperti class, data-theme, dan style sebelum dan sesudah hydration.
  • Cek apakah initial state provider tema di client sama dengan nilai yang dipakai server.
  • Pastikan tidak ada akses window, document, localStorage, atau matchMedia di jalur render SSR.
  • Audit apakah ada lebih dari satu mekanisme yang mengubah tema saat startup: provider, inline script, CSS-in-JS, atau plugin pihak ketiga.
  • Uji tiga skenario terpisah: tanpa preferensi tersimpan, dengan cookie light, dan dengan cookie dark.
  • Uji juga kondisi system jika aplikasi mendukung mode tersebut.
  • Matikan JavaScript sementara untuk melihat apakah HTML awal dari server sudah masuk akal.
  • Gunakan throttle jaringan atau CPU lambat di DevTools untuk memperjelas flicker yang biasanya terlalu cepat terlihat.

Anti-pattern yang perlu dihindari

Mengandalkan useEffect sebagai satu-satunya solusi

useEffect hanya berjalan setelah render client. Ini membantu menghindari crash saat SSR, tetapi tidak menjamin kesesuaian HTML awal.

Menentukan tema dua kali dengan aturan berbeda

Contoh: server fallback ke light, inline script memilih dark dari matchMedia, lalu provider client membaca localStorage dengan aturan lain. Tiga sumber keputusan berarti tiga peluang mismatch.

Merender konten yang terlalu bergantung pada tema sebelum state siap

Jika teks, ikon, dan gambar berubah total berdasarkan tema, komponen tersebut lebih aman diberi placeholder atau ditunda sampai client mount, kecuali server benar-benar sudah tahu tema final.

Mengubah root theme di banyak tempat

Jangan biarkan layout root, provider tema, komponen toggle, dan plugin CSS semuanya menulis ke document.documentElement tanpa koordinasi. Tetapkan satu fungsi atau utilitas pusat untuk menerapkan tema.

Rekomendasi praktis

Jika Anda membangun frontend SSR dan ingin mencegah hydration mismatch dari flag tema, urutan keputusan yang paling aman biasanya seperti ini:

  1. Gunakan cookie sebagai sumber tema eksplisit yang dibaca server dan client.
  2. Render atribut root tema langsung dari server.
  3. Jika tidak ada cookie, pakai fallback stabil.
  4. Opsional: tambahkan inline script awal untuk menyelaraskan dengan prefers-color-scheme atau localStorage sebelum hydration.
  5. Untuk komponen kecil yang tetap sensitif, gunakan guard client-only.

Dengan pendekatan ini, Anda tidak hanya mengurangi warning hydration, tetapi juga menghilangkan flicker yang paling mengganggu pengguna. Intinya bukan memilih trik tertentu, melainkan memastikan server dan client memulai dari keputusan tema yang konsisten.