Akar Masalah: Tabrakan SSR Props dan window.history.state

Inertia.js menyediakan hook useRemember untuk mempertahankan form state di dalam window.history.state saat pengguna berpindah halaman via navigasi browser (popstate). Masalah muncul ketika Server-Side Rendering (SSR) diaktifkan.

Siklus SSR merender markup HTML di server menggunakan props awal dari backend (misalnya nilai form kosong atau data awal database). Saat browser menerima markup ini, engine frontend (seperti React atau Vue) menjalankan proses hidrasi (hydration reconciliation). Jika useRemember membaca dan mengembalikan state dari window.history.state secara sinkron pada render pertama di sisi client, Virtual DOM client akan langsung berbeda dari DOM statis kiriman server.

Hasilnya adalah hydration drift: browser memunculkan peringatan hydration mismatch, dan input form mengalami flickering atau bahkan ter-reset paksa ke kondisi SSR awal.

Reproduksi Bug: Hydration Mismatch dan Flickering

Masalah ini terjadi ketika useRemember dieksekusi secara langsung pada level teratas komponen halaman yang dirender via SSR.

import { useRemember } from '@inertiajs/react';

export default function LeadEditForm({ lead }) {
  // Bug: Di server nilainya adalah lead.email.
  // Di client saat navigasi kembali, useRemember langsung mengambil cache dari history.state.
  const [form, setForm] = useRemember({
    email: lead.email,
    notes: '',
  }, 'LeadEditForm');

  return (
    <form>
      <input
        type="email"
        value={form.email}
        onChange={(e) => setForm({ ...form, email: e.target.value })}
      />
      <textarea
        value={form.notes}
        onChange={(e) => setForm({ ...form, notes: e.target.value })}
      />
    </form>
  );
}

Console browser akan mencatat error rekonsiliasi:

Warning: Text content did not match. Server: "" Client: "[email protected]"
    at textarea
    at form
    at LeadEditForm

Solusi 1: Tunda Sinkronisasi ke Lifecycle Post-Mount

Hindari pembacaan state history secara langsung pada fase hidrasi pertama. Biarkan client merender DOM identik dengan server terlebih dahulu, lalu sinkronisasikan state dari history setelah komponen ter-mount sepenuhnya.

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

export function useSafeRemember(initialState, key) {
  // 1. Inisialisasi useRemember untuk sinkronisasi history
  const [rememberedState, setRememberedState] = useRemember(initialState, key);
  
  // 2. Gunakan local state yang selalu cocok dengan SSR pada first-pass render
  const [state, setState] = useState(initialState);
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
    // Terapkan data dari cache history hanya setelah mount selesai
    if (rememberedState !== initialState) {
      setState(rememberedState);
    }
  }, []);

  const updateState = (newState) => {
    const resolved = typeof newState === 'function' ? newState(state) : newState;
    setState(resolved);
    setRememberedState(resolved);
  };

  return [state, updateState, isMounted];
}

Penggunaan di komponen:

export default function LeadEditForm({ lead }) {
  const [form, setForm, isHydrated] = useSafeRemember({
    email: lead.email,
    notes: '',
  }, `LeadEditForm/${lead.id}`);

  return (
    <form>
      <input
        type="email"
        value={form.email}
        disabled={!isHydrated} // Cegah interaksi sebelum sinkronisasi tuntas
        onChange={(e) => setForm({ ...form, email: e.target.value })}
      />
      <textarea
        value={form.notes}
        disabled={!isHydrated}
        onChange={(e) => setForm({ ...form, notes: e.target.value })}
      />
    </form>
  );
}

Solusi 2: Client-Only State Guard dan Serialisasi Bersih

Browser memberlakukan batasan kuota pada window.history.state (biasanya berkisar beberapa megabyte). Menyimpan seluruh objek form secara mentah tanpa pembersihan dapat merusak serialisasi popstate.

Gunakan Serialize Transform

Pastikan hanya field non-sensitif dan serializable yang masuk ke dalam memory history:

import { useEffect } from 'react';
import { useRemember } from '@inertiajs/react';

// ponytail: naive serializer, ceiling 64KB history state, upgrade ke IndexedDB jika form multi-step kompleks
const serializeForm = (data) => JSON.stringify({
  email: data.email,
  notes: data.notes,
});

const deserializeForm = (raw, fallback) => {
  try {
    return raw ? JSON.parse(raw) : fallback;
  } catch {
    return fallback;
  }
};

Konfigurasi preserveState pada Inertia Visits

Banyak developer salah mengombinasikan useRemember dengan opsi visit Inertia. Saat melakukan submit via form, gunakan opsi preserveState secara terukur agar history cache lokal tidak saling timpa dengan props baru hasil response server:

import { router } from '@inertiajs/react';

function submit(data) {
  router.post('/leads', data, {
    preserveState: (page) => Object.keys(page.props.errors).length > 0,
    preserveScroll: true,
  });
}
Catatan: Jika response berhasil (redirect 303 tanpa validation errors), hindari preserveState: true absolut. Hal ini bertujuan agar state form yang telah di-persist via useRemember di-reset ke nilai baru dan tidak memulihkan draft usang saat pengguna menekan tombol Back.

Checklist Verifikasi Navigasi Popstate

Jalankan prosedur validasi berikut untuk memastikan desinkronisasi form telah teratasi sepenuhnya:

  1. Hard Refresh (Ctrl+F5 / Cmd+Shift+R): Buka halaman dengan SSR aktif. Pastikan console bersih dari peringatan Hydration failed because the initial UI does not match what was rendered on the server.
  2. Navigasi Popstate (Browser Back/Forward): Isi sebagian input form, navigasi ke halaman lain via link internal Inertia, lalu tekan tombol Back browser. Form harus terisi kembali dengan draft terakhir tanpa layout flash.
  3. Bypass SSR Cache di Navigasi Client: Pastikan ID unik disertakan pada key useRemember (misalnya LeadEditForm/${lead.id}) agar draft entitas A tidak bocor ke entitas B.
  4. Inspeksi history.state Payload: Buka DevTools Console dan jalankan window.history.state. Pastikan ukuran payload data proporsional (< 50 KB) dan tidak mengandung objek sirkular atau referensi DOM node.