Hydration mismatch pada aplikasi Laravel + Inertia biasanya terjadi ketika hasil render awal dari server tidak sama dengan render pertama di browser. Kasus yang paling sering: komponen membaca localStorage, sessionStorage, window, atau preferensi browser seperti tema gelap/terang saat inisialisasi state. Akibatnya, UI yang dikirim server menampilkan satu nilai, lalu setelah JavaScript aktif nilainya berubah mendadak.

Gejalanya mudah dikenali: teks berubah setelah mount, toggle tema tampak “meloncat”, komponen tertentu hanya muncul di client, atau muncul warning hydration mismatch di console. Solusinya bukan sekadar menambah pengecekan typeof window !== 'undefined' di mana-mana, tetapi memastikan state awal saat SSR dan state awal saat hydrasi client tetap deterministik.

Kenapa hydration mismatch terjadi di Laravel + Inertia

Pada arsitektur Laravel + Inertia, server mengirim HTML awal dan props untuk halaman. Di browser, framework frontend akan melakukan hydrate, yaitu mengaitkan event, state, dan lifecycle ke markup yang sudah ada. Hydration hanya berjalan mulus jika markup awal dari server identik dengan hasil render awal di client.

Masalah muncul ketika komponen membuat keputusan render berdasarkan data yang hanya tersedia di browser, misalnya:

  • localStorage.getItem('theme')
  • sessionStorage.getItem('dismissed_banner')
  • window.innerWidth
  • window.matchMedia('(prefers-color-scheme: dark)')
  • timezone, locale, atau preferensi browser lain

Saat SSR berjalan, data-data tersebut tidak tersedia atau nilainya berbeda. Server lalu merender fallback tertentu. Setelah hydrate di browser, komponen menghitung state lagi dari environment client dan hasilnya berubah. Jika perubahan itu memengaruhi struktur DOM, atribut, atau teks awal, muncullah mismatch.

Contoh gejala yang umum

  • Teks berubah setelah mount: server menampilkan “Mode terang”, client langsung mengganti ke “Mode gelap”.
  • Toggle tema melompat: posisi switch awalnya off, lalu pindah ke on setelah hydrate.
  • Komponen tampil beda: banner, sidebar, atau tab tertentu hanya muncul di client karena bergantung pada sessionStorage atau ukuran layar.
  • Warning di console: framework frontend memberi peringatan bahwa HTML server tidak cocok dengan hasil render client.

Root cause: state awal yang tidak deterministik

Akar masalahnya bukan Laravel atau Inertia itu sendiri, melainkan state awal yang dihitung dari sumber yang tidak konsisten antara server dan client. Dalam SSR, render pertama harus bersifat deterministik: input yang sama harus menghasilkan output yang sama.

State berikut berisiko tinggi memicu mismatch bila dipakai terlalu dini:

  • State yang diambil langsung dari browser API saat inisialisasi komponen.
  • State yang default-nya berbeda antara server dan client.
  • Conditional rendering yang bergantung pada hasil pembacaan environment browser.
  • Format teks berbasis locale/timezone client tanpa nilai yang disejajarkan dari server.

Prinsip praktis: jangan gunakan data khusus browser untuk menentukan markup SSR utama, kecuali Anda sudah menyelaraskan nilainya dari server atau menunda render bagian tersebut sampai komponen mounted.

Contoh implementasi yang bermasalah

Contoh berikut menggambarkan pola umum yang memicu mismatch pada komponen tema.

// Contoh pola bermasalah (React + Inertia Page/Component)
import { useState } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState(
    localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
  );

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

Masalah pada kode ini:

  • localStorage dan window hanya tersedia di browser.
  • Bahkan jika dibungkus guard, hasil render awal client bisa tetap berbeda dari SSR.
  • Teks tombol langsung bergantung pada state yang tidak stabil di fase awal render.

Pola serupa sering muncul pada banner dismissible:

import { useState } from 'react';

export default function PromoBanner() {
  const [hidden, setHidden] = useState(
    sessionStorage.getItem('promo_closed') === '1'
  );

  if (hidden) return null;

  return (
    <div>
      Promo aktif
      <button onClick={() => {
        sessionStorage.setItem('promo_closed', '1');
        setHidden(true);
      }}>
        Tutup
      </button>
    </div>
  );
}

Server tidak tahu apakah banner pernah ditutup. Bila SSR menampilkan banner, tetapi client langsung menyembunyikannya saat hydrate, struktur DOM awal menjadi berbeda.

Pola perbaikan yang aman

1. Gunakan fallback state yang stabil, lalu baca browser API di client-only hook

Pola paling aman adalah membuat state awal yang sama untuk SSR dan client, lalu melakukan sinkronisasi setelah komponen mounted.

import { useEffect, useState } from 'react';

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

  useEffect(() => {
    setMounted(true);

    const saved = window.localStorage.getItem('theme');
    if (saved === 'dark' || saved === 'light') {
      setTheme(saved);
      return;
    }

    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    setTheme(prefersDark ? 'dark' : 'light');
  }, []);

  useEffect(() => {
    if (!mounted) return;
    document.documentElement.dataset.theme = theme;
    window.localStorage.setItem('theme', theme);
  }, [theme, mounted]);

  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} aria-pressed={theme === 'dark'}>
      {theme === 'dark' ? 'Mode gelap' : 'Mode terang'}
    </button>
  );
}

Kenapa pendekatan ini bekerja:

  • SSR dan render awal client sama-sama memakai nilai awal 'light'.
  • Pembacaan localStorage dan matchMedia hanya terjadi setelah mount.
  • Hydration selesai dulu, baru UI diperbarui sesuai preferensi browser.

Trade-off-nya jelas: pengguna bisa melihat perubahan UI sesaat setelah mount, misalnya tema dari terang ke gelap. Ini aman dari sisi konsistensi render, tetapi bisa menimbulkan flash.

2. Defer render bagian yang benar-benar bergantung pada browser

Jika bagian UI memang tidak bermakna tanpa data browser, Anda bisa menundanya sampai mounted daripada memaksa SSR menebak.

import { useEffect, useState } from 'react';

export default function ClientOnlyBanner() {
  const [mounted, setMounted] = useState(false);
  const [hidden, setHidden] = useState(false);

  useEffect(() => {
    setMounted(true);
    setHidden(window.sessionStorage.getItem('promo_closed') === '1');
  }, []);

  if (!mounted) {
    return null;
  }

  if (hidden) {
    return null;
  }

  return (
    <div>
      Promo aktif
      <button onClick={() => {
        window.sessionStorage.setItem('promo_closed', '1');
        setHidden(true);
      }}>
        Tutup
      </button>
    </div>
  );
}

Ini cocok untuk elemen sekunder seperti banner promo, helper panel, atau preferensi UI non-kritis. Namun untuk konten utama halaman, menunda render bisa mengurangi kualitas SSR dan berpotensi memengaruhi SEO atau persepsi performa.

3. Sinkronkan state dari server props bila memungkinkan

Jika data preferensi sebenarnya bisa diketahui server, lebih baik kirim melalui Inertia props atau shared data Laravel. Ini lebih baik daripada membaca localStorage di render awal karena server dan client memulai dari nilai yang sama.

Contoh kasus yang cocok:

  • Preferensi tema disimpan di database pengguna.
  • Locale aktif berasal dari session Laravel.
  • Banner dismissed disimpan di cookie atau session server-side.

Contoh pembagian data dari Laravel:

// AppServiceProvider atau middleware Inertia share
use Inertia\Inertia;

public function boot(): void
{
    Inertia::share('preferences', function () {
        $user = auth()->user();

        return [
            'theme' => $user?->theme ?? 'light',
        ];
    });
}

Lalu di komponen:

import { useEffect, useState } from 'react';
import { usePage } from '@inertiajs/react';

export default function ThemeToggle() {
  const { preferences } = usePage().props;
  const [theme, setTheme] = useState(preferences.theme ?? 'light');

  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

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

Pendekatan ini paling stabil untuk SSR karena sumber kebenaran sudah tersedia di server. Jika Anda tetap ingin menyimpan preferensi di browser agar responsif, lakukan sinkronisasi setelah state awal disepakati.

4. Pisahkan state visual awal dari state preferensi akhir

Untuk mengurangi “lompatan” UI, gunakan satu nilai fallback yang netral atau aman untuk SSR, lalu perbarui state final setelah mount. Misalnya:

  • Gunakan placeholder atau skeleton untuk komponen yang benar-benar bergantung pada browser.
  • Gunakan kelas CSS default yang tidak mengubah layout drastis.
  • Hindari perubahan struktur DOM besar hanya karena preferensi client.

Strategi ini membantu ketika Anda tidak bisa memperoleh preferensi dari server, tetapi ingin meminimalkan efek visual setelah hydrasi.

Kapan memilih tiap pendekatan

Fallback stabil + effect di client

Pilih ini jika: data hanya tersedia di browser dan perubahan setelah mount masih bisa diterima.

Cocok untuk: tema, tab terakhir yang dibuka, panel yang bisa di-collapse, preferensi UI lokal.

Kelemahan: ada potensi flash atau lompatan UI singkat.

Defer render client-only

Pilih ini jika: komponen tidak penting untuk SSR atau nilainya sama sekali tidak dapat diketahui server.

Cocok untuk: banner promo lokal, widget bantuan, komponen eksperimen, panel debug.

Kelemahan: konten tidak muncul di HTML awal.

Sinkronisasi via server props

Pilih ini jika: preferensi bisa disimpan di server, cookie, session, atau database pengguna.

Cocok untuk: tema akun, locale, preferensi dashboard, feature flag per-user.

Kelemahan: perlu jalur persistensi tambahan dan sinkronisasi update antara client dan server.

Checklist debugging hydration mismatch

Saat mengaudit komponen Inertia yang dicurigai bermasalah, gunakan checklist berikut:

  1. Cari akses browser API di fase render.
    Periksa penggunaan window, document, localStorage, sessionStorage, matchMedia, navigator, atau ukuran viewport langsung di body komponen.
  2. Bandingkan state awal SSR dan client.
    Tanyakan: apakah nilai useState(...) atau computed value identik saat server render dan saat client render pertama?
  3. Periksa conditional rendering.
    Apakah if, ternary, atau pengubahan class memengaruhi struktur DOM berdasarkan data browser?
  4. Audit teks dinamis.
    Teks sederhana seperti label tema, tanggal, locale, atau timezone juga bisa memicu mismatch walau struktur DOM sama.
  5. Lihat warning console dengan teliti.
    Biasanya warning memberi petunjuk elemen mana yang markup-nya berbeda.
  6. Nonaktifkan sementara logika client-only.
    Jika mismatch hilang setelah pembacaan localStorage dihapus, akar masalahnya hampir pasti state awal yang tidak stabil.
  7. Uji dengan hard refresh.
    Masalah hydration sering tidak konsisten ketika navigasi berikutnya sudah sepenuhnya client-side.
  8. Audit layout utama terlebih dahulu.
    Komponen global seperti layout, navbar, tema root, dan sidebar paling sering menyebabkan dampak terbesar.

Kesalahan umum yang sering luput saat code review

  • Guard hanya di akses API, bukan di desain state awal.
    Menulis if (typeof window !== 'undefined') memang mencegah error runtime, tetapi belum tentu mencegah mismatch. Nilai render awal bisa tetap berbeda.
  • Menginisialisasi state dari fungsi yang membaca browser API.
    Initializer useState(() => ...) tetap bermasalah bila hasilnya berbeda antara server dan client.
  • Menyamakan “tidak error” dengan “aman untuk hydration”.
    Komponen bisa berjalan tanpa crash, tetapi tetap menghasilkan warning dan UI melompat.
  • Mengubah atribut root terlalu terlambat.
    Tema yang diterapkan setelah mount sering menyebabkan flash. Jika tema penting, pertimbangkan sumber server-side atau mekanisme awal yang konsisten.
  • Mengandalkan viewport saat SSR.
    Merender desktop/mobile layout penuh berdasarkan window.innerWidth adalah kandidat mismatch klasik. Lebih aman gunakan CSS responsif untuk fase awal.
  • Mencampur preferensi lokal dan server tanpa prioritas yang jelas.
    Jika server mengirim tema light tetapi client memaksa dark dari storage, Anda perlu aturan resolusi yang eksplisit.

Trade-off UX dan SEO

Tidak ada satu solusi yang selalu paling benar. Anda perlu memilih berdasarkan prioritas halaman dan komponen:

  • Fokus konsistensi SSR: gunakan server props atau fallback stabil. Ini paling aman terhadap hydration mismatch.
  • Fokus preferensi personal yang cepat diterapkan: baca dari browser setelah mount. Risiko utamanya adalah flash atau perubahan visual sesaat.
  • Fokus kesederhanaan implementasi: defer render komponen client-only. Cocok untuk elemen non-esensial, tetapi tidak ideal untuk konten utama atau SEO.

Untuk halaman publik yang mengandalkan SSR, hindari menyembunyikan konten utama hanya karena menunggu browser state. Untuk dashboard internal atau area autentikasi, toleransi terhadap render tertunda biasanya lebih besar.

Panduan audit cepat untuk proyek Laravel + Inertia

Jika Anda ingin melakukan audit cepat pada codebase, mulai dari urutan ini:

  1. Cari semua penggunaan localStorage, sessionStorage, dan window.
  2. Prioritaskan komponen layout, navbar, dan theme provider.
  3. Periksa apakah nilai tersebut dipakai saat render awal atau hanya di effect.
  4. Ubah state awal menjadi fallback yang stabil.
  5. Pindahkan pembacaan browser API ke hook client-only.
  6. Untuk preferensi penting, pertimbangkan kirim nilainya dari Laravel melalui shared props.
  7. Uji ulang hard refresh dan perhatikan warning hydration di console.

Penutup

Hydration mismatch pada Laravel + Inertia: audit hydration mismatch dari local storage hampir selalu berakar pada satu hal: state awal tidak deterministik antara server dan client. Membaca localStorage, sessionStorage, window, atau preferensi browser saat render awal adalah pola yang tampak praktis, tetapi berbahaya untuk SSR.

Pola perbaikannya cukup konsisten: gunakan fallback state yang stabil, pindahkan pembacaan browser API ke hook client-only, tunda render bagian tertentu bila memang perlu, dan sinkronkan dari server props jika datanya sebenarnya bisa diketahui server. Dengan pendekatan ini, Anda tidak hanya menghilangkan warning hydration, tetapi juga membuat UI lebih konsisten dan lebih mudah diaudit saat review code.