Debug hydration SSR saat client store mengubah UI terlalu dini pada React/Next.js pada dasarnya adalah pekerjaan menyamakan dua hal: HTML awal yang dibuat server dan render pertama di browser. Jika client store membaca localStorage, preferensi tema, atau state persisten lain sebelum hydration selesai, UI awal di klien bisa berbeda dari markup server. Itulah sumber utama hydration mismatch.

Solusi praktisnya bukan sekadar menambah banyak guard, tetapi membangun model mental yang sederhana: render pertama harus deterministik dan aman dijalankan di server maupun klien. Setelah hydration selesai, barulah state yang benar-benar bergantung pada browser diterapkan secara terkontrol. Dari sudut pandang engineer pembuat tooling, masalah ini penting karena kualitas debugging, observabilitas, dan desain state yang jelas jauh lebih berharga daripada trik cepat yang sulit dirawat.

Model mental sederhana untuk memahami hydration SSR

Pada SSR React/Next.js, alurnya kira-kira seperti ini:

  1. Server merender HTML awal berdasarkan data yang tersedia di server.
  2. Browser menerima HTML tersebut dan menampilkannya.
  3. React melakukan hydration, yaitu memasang event handler dan menyelaraskan tree React dengan DOM yang sudah ada.
  4. Setelah itu, pembacaan state yang hanya tersedia di browser bisa memperbarui UI.

Masalah muncul ketika langkah ke-3 belum selesai, tetapi komponen sudah merender berdasarkan state browser yang tidak diketahui server. Akibatnya, render pertama di klien tidak cocok dengan HTML dari server.

Aturan praktis: jika suatu nilai tidak tersedia saat SSR, jangan jadikan nilai itu penentu struktur atau teks render pertama tanpa fallback yang identik di server dan klien.

Gejala hydration mismatch yang sering muncul

Ketika client store mengubah UI terlalu dini, gejalanya biasanya terlihat seperti:

  • Peringatan seperti Text content does not match server-rendered HTML.
  • Tema gelap/terang berubah cepat setelah halaman tampil.
  • Label tombol, ikon, jumlah item, atau class CSS berbeda antara server dan klien.
  • Komponen tertentu berkedip (flash) pada render awal.
  • UI hasil SSR terlihat benar sesaat, lalu langsung berubah saat store terinisialisasi.

Gejala ini kadang tidak selalu muncul di lingkungan development dengan cara yang konsisten. Karena itu, observabilitas sederhana sangat membantu: log fase render, asal nilai state, dan momen kapan store selesai memuat state persisten.

Root cause: state browser bocor ke render awal

Sumber masalah umumnya salah satu dari berikut:

  • Komponen membaca localStorage langsung saat inisialisasi state.
  • Zustand atau Redux Persist melakukan rehydration dan hasilnya langsung memengaruhi tree yang sedang di-hydrate.
  • Preferensi browser seperti tema, locale, atau media query dipakai untuk menentukan markup awal tanpa fallback server-safe.
  • Komponen kondisional bergantung pada window, document, atau API browser lain saat render.

Secara konseptual, ini bukan semata masalah library. Ini adalah masalah boundary: state mana yang aman untuk SSR, state mana yang baru valid di klien, dan kapan masing-masing boleh memengaruhi UI.

Contoh kode bermasalah

Membaca localStorage saat inisialisasi state

import { useState } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState(() => {
    if (typeof window === 'undefined') return 'light';
    return localStorage.getItem('theme') || 'light';
  });

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Tema: {theme}
    </button>
  );
}

Kode di atas terlihat aman karena memeriksa window. Namun tetap berpotensi mismatch. Saat SSR, server merender Tema: light. Saat klien pertama kali merender, nilai bisa langsung menjadi dark dari localStorage. React lalu melihat teks awal berbeda dari HTML server.

Store persisten langsung mengontrol render pertama

import { useAppStore } from './store';

export default function Sidebar() {
  const collapsed = useAppStore((s) => s.sidebarCollapsed);

  return (
    <aside className={collapsed ? 'w-16' : 'w-64'}>
      ...
    </aside>
  );
}

Jika sidebarCollapsed direstorasi dari storage dan nilai itu berbeda dari default SSR, ukuran sidebar pada render klien pertama bisa berbeda dari hasil server.

Pola perbaikan yang aman dan mudah dirawat

1. Gunakan default state yang server-safe

Render pertama harus memakai nilai default yang bisa dipakai di server dan klien secara konsisten. Nilai ini tidak harus final, tetapi harus aman untuk menghasilkan markup yang sama.

import { useEffect, useState } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState('light');

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

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Tema: {theme}
    </button>
  );
}

Mengapa ini bekerja? Karena server dan render awal klien sama-sama menggunakan light. Setelah hydration selesai, useEffect berjalan dan boleh memperbarui UI.

Trade-off: pengguna mungkin melihat perubahan setelah halaman tampil. Ini lebih baik daripada mismatch, tetapi tetap perlu dikelola agar tidak menimbulkan flash yang mengganggu.

2. Tambahkan guard mounted untuk state yang benar-benar bergantung pada browser

Jika perubahan UI awal terlalu sensitif, gunakan mounted guard agar bagian tertentu baru dirender setelah komponen terpasang di klien.

import { useEffect, useState } from 'react';

export default function ClientThemeLabel() {
  const [mounted, setMounted] = useState(false);
  const [theme, setTheme] = useState('light');

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

  if (!mounted) {
    return <span aria-hidden="true">Tema: ...</span>;
  }

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

Pola ini berguna jika label atau struktur UI akan sangat sering berbeda antara server dan klien. Namun jangan memakainya secara berlebihan. Jika terlalu banyak komponen menunggu mounted, manfaat SSR berkurang dan halaman terasa kosong sesaat.

3. Pisahkan state persisten dari state render awal

Ini adalah pola desain yang paling penting. Jangan biarkan state persisten langsung menjadi sumber kebenaran untuk render pertama bila nilainya tidak tersedia di server.

Contoh pendekatan:

  • Initial render state: default yang deterministik dan aman untuk SSR.
  • Persistent preference state: nilai dari storage yang diterapkan setelah hydration.
import { useEffect, useState } from 'react';

export default function Sidebar() {
  const [collapsed, setCollapsed] = useState(false); // server-safe default
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const saved = localStorage.getItem('sidebar:collapsed');
    if (saved === 'true') setCollapsed(true);
    if (saved === 'false') setCollapsed(false);
    setReady(true);
  }, []);

  return (
    <aside
      data-ready={ready}
      className={collapsed ? 'w-16 transition-all' : 'w-64 transition-all'}
    >
      ...
    </aside>
  );
}

Dengan desain ini, Anda sadar bahwa collapsed dari storage adalah enhancement setelah hydration, bukan syarat render SSR.

4. Untuk Zustand atau Redux Persist, jangan anggap rehydration identik dengan SSR-safe

Library state management sering menyediakan persistensi, tetapi persistensi tidak otomatis aman untuk hydration SSR. Prinsip yang sama tetap berlaku: komponen SSR sebaiknya tidak bergantung pada nilai yang baru valid setelah storage dibaca.

Pola yang aman biasanya salah satu dari berikut:

  • Store punya default yang sama untuk server dan klien.
  • Komponen menunggu flag seperti hasHydrated atau status siap lain sebelum memakai state persisten untuk bagian UI yang sensitif.
  • State persisten hanya dipakai untuk preferensi non-kritis, bukan struktur render awal.

Nama API untuk mengecek status hydration bisa berbeda antar library atau implementasi. Jika ragu, pegang prinsip dasarnya: render awal jangan bergantung pada data browser-only.

Contoh yang lebih konkret dan dapat diuji di Next.js

Misalkan ada komponen profil yang menampilkan mode tampilan kartu berdasarkan preferensi pengguna di storage. Versi bermasalah:

'use client';

import { useState } from 'react';

export default function ProfileViewMode() {
  const [mode] = useState(() => {
    if (typeof window === 'undefined') return 'compact';
    return localStorage.getItem('profile:view') || 'compact';
  });

  return (
    <div>
      {mode === 'compact' ? <p>Mode ringkas</p> : <p>Mode lengkap</p>}
    </div>
  );
}

Perbaikan yang lebih aman:

'use client';

import { useEffect, useState } from 'react';

export default function ProfileViewMode() {
  const [mode, setMode] = useState('compact');
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    const saved = localStorage.getItem('profile:view');
    if (saved === 'compact' || saved === 'full') {
      setMode(saved);
    }
  }, []);

  return (
    <div>
      {!mounted ? (
        <p>Mode ringkas</p>
      ) : mode === 'compact' ? (
        <p>Mode ringkas</p>
      ) : (
        <p>Mode lengkap</p>
      )}
    </div>
  );
}

Cara menguji:

  1. Set localStorage.setItem('profile:view', 'full') di browser.
  2. Muat ulang halaman.
  3. Pastikan tidak ada warning hydration mismatch.
  4. Amati bahwa server dan render awal klien tetap menampilkan Mode ringkas, lalu berubah ke Mode lengkap setelah hydration.

Jika perubahan visual ini tidak diinginkan, pertimbangkan memindahkan preferensi ke cookie atau sumber data yang bisa dibaca server, sehingga SSR dapat menghasilkan output yang benar sejak awal.

Observabilitas: cara mendiagnosis tanpa menebak-nebak

Dalam wawancara engineer pembuat tooling, kandidat yang kuat biasanya tidak langsung menambal bug. Mereka membangun observabilitas kecil agar penyebabnya jelas. Untuk kasus hydration SSR, pendekatan ini sangat efektif.

Tambahkan logging fase render dan sumber state

'use client';

import { useEffect, useState } from 'react';

export default function DebugTheme() {
  const [theme, setTheme] = useState('light');

  console.log('[render] theme =', theme, 'window?', typeof window !== 'undefined');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    console.log('[effect] localStorage theme =', saved);
    if (saved === 'light' || saved === 'dark') {
      setTheme(saved);
    }
  }, []);

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

Log seperti ini membantu menjawab pertanyaan penting:

  • Nilai apa yang dipakai saat render pertama?
  • Kapan nilai dari storage mulai masuk?
  • Apakah komponen berubah sebelum hydration selesai?

Bandingkan HTML awal dengan DOM setelah hydration

Gunakan DevTools untuk melihat HTML awal dari server dan periksa apakah teks, atribut, atau class berbeda segera setelah JavaScript berjalan. Fokus pada:

  • className yang mengubah layout atau tema
  • teks hasil kondisi
  • jumlah elemen anak
  • atribut seperti data-theme, aria-*, dan nilai input

Pastikan masalahnya benar-benar hydration, bukan efek lain

Beberapa bug mirip hydration mismatch tetapi sebenarnya berasal dari:

  • Efek yang berjalan dua kali di development
  • Race condition saat fetch data klien
  • Conditional rendering berdasarkan ukuran viewport
  • CSS yang terlambat dimuat

Karena itu, jangan berhenti pada gejala visual. Cari titik pertama saat output server dan render klien mulai berbeda.

Kapan memakai guard mounted, kapan tidak

Pakai guard mounted jika:

  • nilai hanya ada di browser dan langsung mengubah teks/struktur DOM;
  • komponen kecil dan tidak kritis untuk SEO;
  • lebih baik menampilkan placeholder singkat daripada warning hydration.

Hindari guard mounted jika:

  • komponen utama halaman menjadi kosong saat SSR;
  • data sebenarnya bisa disediakan dari server;
  • masalah bisa diselesaikan dengan default state yang konsisten.

Intinya, mounted adalah alat pembatas akses ke state browser-only, bukan solusi universal.

Kapan menonaktifkan SSR untuk komponen tertentu

Ada kasus di mana menonaktifkan SSR untuk komponen tertentu memang masuk akal, misalnya:

  • komponen sepenuhnya bergantung pada API browser;
  • komponen tidak penting untuk SEO atau waktu tampilan awal;
  • markup awal hampir pasti salah tanpa state klien;
  • biaya menjaga kompatibilitas SSR lebih besar daripada manfaatnya.

Contohnya: editor visual berat, panel preferensi personal, atau widget yang hanya relevan setelah interaksi pengguna.

Trade-off:

  • hydration mismatch hilang untuk komponen itu;
  • tetapi Anda kehilangan manfaat SSR pada area tersebut;
  • bisa muncul loading state tambahan;
  • ukuran dan waktu eksekusi JavaScript klien tetap perlu diperhatikan.

Pilih opsi ini secara sadar, bukan sebagai reaksi otomatis setiap kali melihat warning hydration.

Pemisahan state yang baik: server state, persistent preference, dan ephemeral UI

Desain state yang jelas biasanya mencegah bug sejak awal. Untuk aplikasi SSR React/Next.js, pembagian berikut cukup berguna:

1. Server state

Data yang idealnya tersedia saat SSR, misalnya hasil fetch dari backend, locale dari request, atau preferensi yang disimpan di cookie dan dapat dibaca server.

2. Persistent preference

Data yang bertahan di browser, seperti tema, mode sidebar, atau urutan tampilan. State ini perlu fallback SSR yang aman jika tidak tersedia di server.

3. Ephemeral UI state

State sesaat seperti modal terbuka, tab aktif lokal, atau input sementara. Biasanya tidak perlu dipersistenkan dan tidak perlu memengaruhi markup SSR.

Kesalahan umum adalah mencampur ketiganya dalam satu store global, lalu semua state tersebut ikut menentukan render awal. Dari sudut pandang tooling dan maintainability, ini membuat bug hydration lebih sulit dilacak.

Checklist review PR untuk mencegah hydration mismatch

Gunakan checklist ini saat meninjau PR yang menyentuh SSR dan client store:

  • Apakah render pertama komponen deterministik di server dan klien?
  • Apakah ada pembacaan window, document, localStorage, atau media query saat render?
  • Apakah state persisten langsung mengubah teks, class, atau struktur DOM sebelum hydration selesai?
  • Apakah sudah ada server-safe default state?
  • Apakah mounted guard dipakai hanya pada bagian yang memang browser-only?
  • Apakah state persisten dipisahkan dari state render awal?
  • Jika preferensi penting untuk render awal, apakah sebaiknya dipindah ke cookie agar server bisa membacanya?
  • Apakah ada logging atau cara verifikasi untuk membedakan mismatch dari bug efek biasa?
  • Jika SSR dimatikan untuk suatu komponen, apakah alasannya jelas dan trade-off-nya diterima?

Kesalahan umum yang sering terjadi

  • Mengira pengecekan typeof window selalu cukup. Ini mencegah error di server, tetapi tidak menjamin output render awal sama.
  • Mengandalkan persist middleware tanpa desain SSR. Persistensi bukan sinonim dari hydration-safe.
  • Menyimpan terlalu banyak state di global store. Akibatnya, preferensi browser ikut memengaruhi area render yang sebenarnya harus stabil saat SSR.
  • Menggunakan mounted di seluruh halaman. Warning mungkin hilang, tetapi manfaat SSR ikut hilang.
  • Tidak membedakan mismatch markup dengan flash visual. Keduanya terkait, tetapi penanganannya bisa berbeda.

Pendekatan yang paling robust jika preferensi harus benar sejak render awal

Jika tema, locale, atau preferensi tertentu wajib benar sejak SSR, simpan sumber kebenaran di tempat yang bisa dibaca server, misalnya cookie yang dikirim bersama request. Dengan begitu, server dapat merender markup yang sama dengan yang nantinya dipakai klien.

Ini bukan berarti semua preference harus dipindah dari localStorage. Tetapi jika preference tersebut mengubah struktur, teks, atau atribut penting pada render awal, penyimpanan yang server-readable biasanya lebih tepat.

Penutup

Debug hydration SSR saat client store mengubah UI terlalu dini bukan terutama soal hafalan API, melainkan soal model mental yang benar. Render awal harus stabil dan konsisten antara server dan klien. State browser-only boleh masuk setelah hydration, tetapi harus dilakukan secara sadar melalui default state yang aman, guard mounted bila perlu, serta pemisahan yang jelas antara state persisten dan state untuk render awal.

Jika Anda ingin mengurangi bug seperti ini secara sistematis, fokuslah pada tiga hal: desain state yang jelas, observabilitas sederhana, dan review PR yang disiplin. Dengan pendekatan itu, warning hydration tidak lagi terasa acak, tetapi menjadi masalah yang bisa dijelaskan, direproduksi, dan diperbaiki dengan langkah yang konsisten.