Pada aplikasi Laravel + Inertia dengan SSR, gejala seperti UI berkedip, teks berubah sesaat setelah halaman tampil, checkbox atau form kembali ke nilai lain, hingga flash message yang muncul lalu hilang hampir selalu mengarah ke satu masalah inti: hasil render di server tidak identik dengan hasil render awal di client saat hydration.
Masalah ini sering terasa acak, padahal sumbernya biasanya cukup konkret: data berbasis waktu, locale yang berbeda, auth atau session yang berubah di antara request, flash message yang dikonsumsi satu kali, default form state yang dihitung ulang di browser, atau komponen yang memakai nilai non-deterministik saat render. Kuncinya bukan sekadar “mematikan SSR”, tetapi memastikan HTML awal dan state awal konsisten.
Apa itu hydration mismatch pada Laravel + Inertia
Dengan SSR, server lebih dulu mengirim HTML yang sudah dirender. Setelah itu, JavaScript di browser melakukan hydration: framework front-end mengikat event, state, dan struktur virtual component ke HTML yang sudah ada. Jika render pertama di browser menghasilkan output berbeda dari HTML yang dikirim server, Anda akan melihat beberapa gejala berikut:
- UI kedip: konten awal tampil lalu berubah cepat setelah JavaScript aktif.
- Teks atau angka berubah: misalnya waktu “baru saja” berubah menjadi “1 menit lalu”.
- State form salah: checkbox, radio, selected option, atau input default tidak sama dengan yang dirender server.
- Flash message tidak stabil: muncul di HTML awal lalu hilang setelah hydration.
- Hydration warning di console browser, tergantung adapter dan framework yang dipakai.
Dalam konteks Inertia, mismatch biasanya bukan karena Inertia sendiri “salah”, melainkan karena props yang dikirim server, shared data, atau logika render komponen menghasilkan nilai berbeda di server dan client.
Sumber mismatch yang paling sering terjadi
1. Data berbasis waktu
Ini penyebab paling umum. Contoh klasik:
- menampilkan relative time seperti “5 detik lalu” saat render,
- memanggil
now()di server dannew Date()di client, - menghitung countdown atau status aktif/kedaluwarsa langsung di template.
Jika server render pukul 10:00:00 dan client hydrate pukul 10:00:02, hasil render awal bisa berbeda meski hanya selisih dua detik. Bagi hydration, itu tetap mismatch.
Pola aman: kirim nilai mentah yang stabil dari server, misalnya timestamp ISO, lalu format relatifnya setelah mounted atau pada area yang memang dibiarkan berubah setelah hydration.
// Controller Laravel
return inertia('Posts/Show', [
'post' => [
'title' => $post->title,
'published_at' => optional($post->published_at)?->toIso8601String(),
],
]);<script setup>
import { computed, ref, onMounted } from 'vue'
const props = defineProps({
post: Object,
})
const hydrated = ref(false)
onMounted(() => {
hydrated.value = true
})
const publishedAtText = computed(() => {
if (!props.post.published_at) return '-'
if (!hydrated.value) {
// Stabil saat SSR dan render awal client
return new Date(props.post.published_at).toLocaleString('id-ID')
}
// Boleh dinamis setelah hydration
return formatRelativeTime(props.post.published_at)
})
</script>Kenapa ini bekerja? Karena HTML awal di server dan render awal di client sama-sama memakai representasi yang stabil. Perubahan dinamis baru dilakukan setelah hydration selesai.
2. Locale dan formatting berbeda antara server dan browser
Server dan browser bisa memakai locale, timezone, atau aturan formatting yang berbeda. Dampaknya:
- tanggal di server:
10/03/2025, di client:3/10/2025, - angka ribuan dan desimal berubah format,
- nama bulan atau hari tidak sama,
- string yang dipengaruhi timezone bergeser satu hari.
Jika komponen merender tanggal/angka langsung dengan API formatting masing-masing runtime, mismatch mudah terjadi.
Pola aman:
- tentukan locale secara eksplisit,
- hindari render nilai yang bergantung timezone lokal browser pada fase awal,
- kirim nilai yang sudah diformat final dari server bila memang harus identik.
// Laravel: format final di server bila tidak perlu interaksi dinamis
return inertia('Invoice/Show', [
'invoice' => [
'total' => $invoice->total,
'total_text' => number_format($invoice->total, 0, ',', '.'),
'issued_at_text' => $invoice->issued_at?->locale('id')->translatedFormat('d M Y H:i'),
],
]);Trade-off-nya: formatting di server membuat output stabil, tetapi mengurangi fleksibilitas client jika nanti pengguna ingin mengganti locale secara dinamis. Jika kebutuhan UI dinamis penting, gunakan nilai mentah plus locale yang eksplisit, lalu tunda format dinamis sampai hydration selesai.
3. Auth, session, dan shared props berubah di antara render
Pada aplikasi Inertia, data seperti user login, permissions, tenant aktif, atau locale sering dibagikan lewat middleware HandleInertiaRequests. Masalah muncul ketika data ini tidak stabil atau bergantung pada kondisi yang dapat berubah di antara render server dan request berikutnya.
Contoh umum:
- shared prop membaca session yang juga dimodifikasi middleware lain,
- auth user berubah karena token/session direfresh,
- tenant aktif ditentukan dari header/cookie yang tidak konsisten,
- locale diset berdasarkan request tapi browser memuat dengan kondisi berbeda.
Pastikan semua shared props yang dipakai saat render awal benar-benar berasal dari sumber yang sama dan sudah final.
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user()
? [
'id' => $request->user()->id,
'name' => $request->user()->name,
]
: null,
],
'locale' => app()->getLocale(),
'flash' => [
'success' => fn () => $request->session()->get('success'),
],
]);
}Catatan penting: shared props harus diperlakukan sebagai bagian dari kontrak render awal. Jika nilainya berubah-ubah tanpa kendali, hydration akan ikut goyah.
4. Flash message yang dikonsumsi satu kali
Flash message sering memicu gejala “muncul lalu hilang”. Penyebabnya biasanya bukan bug visual, melainkan karena data flash memang hanya tersedia satu kali dan akses ke session dilakukan dengan cara yang tidak konsisten.
Masalah umum:
- mengakses flash langsung di beberapa tempat sehingga nilainya habis lebih cepat,
- menggunakan helper yang mengonsumsi data saat SSR, lalu client tidak mendapat nilai yang sama,
- menyalin flash ke state lokal dengan default yang berbeda.
Pola aman: baca flash satu kali di layer share, kirim ke halaman sebagai prop stabil, lalu render apa adanya pada initial view. Jika ingin auto-dismiss, lakukan setelah hydration, bukan dengan mengubah kondisi render awal.
<script setup>
import { computed, ref, onMounted } from 'vue'
import { usePage } from '@inertiajs/vue3'
const page = usePage()
const visible = ref(true)
const message = computed(() => page.props.flash?.success || null)
onMounted(() => {
if (message.value) {
setTimeout(() => {
visible.value = false
}, 3000)
}
})
</script>
<template>
<div v-if="message && visible" class="alert alert-success">
{{ message }}
</div>
</template>Di sini, HTML awal tetap konsisten karena kondisi awalnya sama di server dan client: pesan ada, visible bernilai true. Perubahan baru terjadi setelah komponen mounted.
5. Default form state dihitung ulang di client
Form sering mengalami state salah saat hydration, misalnya:
- checkbox awalnya tercentang lalu tidak,
- select berpindah option setelah load,
- input bernilai default dari props, tetapi di browser dihitung ulang dengan fallback lain,
useFormdiinisialisasi dari props yang tidak stabil.
Kesalahan umumnya adalah membuat default value dari logika non-deterministik, misalnya:
// Buruk: tergantung browser saat render awal
const form = useForm({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
remember: window.innerWidth > 768,
})Server tidak punya hasil yang sama untuk ekspresi semacam ini. Akibatnya, HTML awal dan state client pasti berbeda.
Pola aman:
- inisialisasi form dari props yang dikirim server,
- jika nilai harus berasal dari browser, isi setelah mounted,
- hindari menjadikan nilai browser-only sebagai bagian dari render awal.
<script setup>
import { useForm } from '@inertiajs/vue3'
import { onMounted } from 'vue'
const props = defineProps({
defaults: Object,
})
const form = useForm({
name: props.defaults.name || '',
timezone: props.defaults.timezone || '',
remember: !!props.defaults.remember,
})
onMounted(() => {
if (!form.timezone) {
form.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
}
})
</script>Dengan pendekatan ini, render awal tetap konsisten. Nilai browser-specific baru ditambahkan setelah hydration.
6. Nilai non-deterministik saat render
Selain waktu dan locale, beberapa sumber mismatch lain juga sering luput:
Math.random()untuk key atau ID elemen,Date.now()di template/computed awal,- akses
window,document,localStoragesaat render, - sorting/filtering data dengan aturan berbeda di server dan client,
- kondisi render berdasarkan ukuran layar pada initial render.
Aturan praktisnya sederhana: render pertama harus deterministik. Jika sebuah nilai bergantung pada lingkungan browser atau waktu berjalan, jangan jadikan itu penentu HTML awal.
Reproduksi sederhana agar masalah mudah dipahami
Berikut contoh yang sengaja membuat mismatch:
// Controller
return inertia('Demo/Hydration', [
'generated_at' => now()->toIso8601String(),
]);<script setup>
const props = defineProps({
generated_at: String,
})
</script>
<template>
<div>
Server: {{ props.generated_at }}<br>
Client now: {{ new Date().toISOString() }}
</div>
</template>Gejalanya jelas: HTML server memuat satu timestamp, sementara render awal client membuat timestamp lain. Pada aplikasi nyata, bentuknya biasanya lebih tersamar, misalnya label status, urutan data, atau state button yang berubah sesaat.
Versi aman:
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
generated_at: String,
})
const clientNow = ref(null)
onMounted(() => {
clientNow.value = new Date().toISOString()
})
</script>
<template>
<div>
Server: {{ props.generated_at }}<br>
<span v-if="clientNow">Client now: {{ clientNow }}</span>
</div>
</template>Perbedaan pentingnya: nilai browser-only tidak ikut menentukan HTML awal.
Strategi debugging yang terstruktur
Daripada menebak-nebak, gunakan langkah debugging yang sistematis.
1. Tentukan apakah masalah benar-benar hydration mismatch
Periksa gejalanya:
- apakah tampilan awal benar lalu berubah setelah JavaScript aktif?
- apakah warning hydration muncul di console?
- apakah jika JavaScript dimatikan, HTML server terlihat “benar”?
Jika ya, fokuskan investigasi pada perbedaan render server dan render client, bukan pada CSS atau transisi terlebih dahulu.
2. Isolasi komponen yang berubah
Temukan bagian UI yang benar-benar berkedip atau nilainya berbeda. Jangan mulai dari seluruh halaman. Biasanya cukup satu komponen kecil, misalnya header auth, flash alert, badge status, atau form filter.
Tanyakan untuk setiap binding yang ditampilkan:
- asal nilainya dari mana?
- apakah nilai itu murni dari props server?
- apakah ada computed yang memakai waktu, locale, browser API, atau state lokal?
3. Log props awal dari server dan state client
Bandingkan nilai yang digunakan saat render.
<script setup>
import { usePage } from '@inertiajs/vue3'
import { onMounted } from 'vue'
const page = usePage()
console.log('props before mount', JSON.parse(JSON.stringify(page.props)))
onMounted(() => {
console.log('props after mount', JSON.parse(JSON.stringify(page.props)))
})
</script>Jika props sama tetapi UI tetap berubah, masalahnya kemungkinan ada pada logika komponen. Jika props berbeda, periksa middleware share, session, locale, atau transformasi data di controller.
4. Bandingkan output server dan aturan render client
Periksa apakah server mengirim string final, sementara client memformat ulang menjadi bentuk lain. Contoh:
- server mengirim
status_text, tetapi client tetap menghitung status dariexpires_at, - server mengirim boolean, tetapi client menurunkannya lagi dari local state,
- server sudah menyortir data, tetapi client menyortir ulang dengan locale berbeda.
Pilih satu sumber kebenaran untuk render awal.
5. Nonaktifkan sementara bagian dinamis
Teknik sederhana yang sangat efektif: komentari satu per satu computed, watcher, atau blok conditional yang mencurigakan. Jika flicker hilang, Anda sudah menemukan area masalah.
Tip debugging: jika sebuah komponen tidak bisa dirender dengan output yang sama tanpa akses ke browser API, kemungkinan komponen itu memang tidak aman untuk SSR pada render awal.
Pola perbaikan yang aman
1. Render awal harus berbasis data deterministik
Gunakan props dari server sebagai input utama. Hindari menghitung ulang nilai yang sama dengan algoritme berbeda di client.
Pilih ini jika: data penting untuk SEO, aksesibilitas, atau pengalaman pertama pengguna.
2. Tunda data atau UI yang memang client-only
Jika sebuah nilai hanya masuk akal di browser, jangan paksakan masuk ke SSR. Tampilkan placeholder netral atau sembunyikan bagian itu sampai mounted.
<template>
<div>
<span v-if="hydrated">{{ browserOnlyValue }}</span>
<span v-else>-</span>
</div>
</template>Trade-off: aman dari mismatch, tetapi ada biaya UX kecil karena sebagian UI baru tampil setelah hydration. Gunakan hanya untuk bagian yang memang bergantung pada browser.
3. Gunakan lazy data ketika data tidak dibutuhkan untuk HTML awal
Jika suatu data mahal dihitung atau tidak penting untuk render pertama, pertimbangkan lazy data. Intinya, jangan masukkan ke payload awal bila justru membuka peluang mismatch atau membebani SSR.
Cocok untuk:
- panel sekunder, statistik tambahan, widget samping,
- data yang tidak memengaruhi struktur utama halaman,
- bagian yang boleh muncul setelah navigasi/page load.
Jangan gunakan lazy data untuk nilai yang menentukan state awal elemen utama seperti status auth, selected filter utama, atau pesan validasi awal.
4. Gunakan defer untuk bagian yang boleh datang belakangan
Defer berguna ketika Anda ingin halaman utama stabil lebih dulu, lalu data tambahan dimuat menyusul. Dari sudut pandang hydration, ini membantu karena HTML awal menjadi lebih sederhana dan konsisten.
Pilih defer jika:
- bagian tersebut bukan penentu HTML utama,
- Anda ingin menghindari perhitungan server/client yang kompleks pada render pertama,
- placeholder atau skeleton untuk area itu dapat diterima.
Hindari defer untuk elemen yang jika kosong akan mengubah layout atau makna utama halaman secara drastis.
5. Guard render untuk komponen yang tidak SSR-safe
Jika sebuah komponen pihak ketiga atau komponen internal jelas bergantung pada browser, bungkus dengan guard render.
<script setup>
import { ref, onMounted } from 'vue'
const ready = ref(false)
onMounted(() => ready.value = true)
</script>
<template>
<ClientOnlyWidget v-if="ready" />
<div v-else class="widget-placeholder">Memuat...</div>
</template>Ini bukan solusi universal. Jika dipakai terlalu banyak, manfaat SSR berkurang. Gunakan untuk area yang memang tidak bisa dibuat deterministik.
6. Simpan transformasi final di server jika konsistensi lebih penting
Untuk label status, teks badge, formatted total, atau permission final, sering kali lebih aman jika server yang menentukan output akhir.
Contoh: daripada client menghitung sendiri apakah invoice sudah jatuh tempo dari waktu lokal browser, lebih baik server kirim is_overdue dan status_text.
return inertia('Invoices/Show', [
'invoice' => [
'id' => $invoice->id,
'status' => $invoice->status,
'is_overdue' => $invoice->due_at ? $invoice->due_at->isPast() : false,
'status_text' => $invoice->due_at && $invoice->due_at->isPast()
? 'Terlambat'
: 'Aktif',
],
]);Client tetap boleh menambahkan perilaku interaktif, tetapi tidak perlu menduplikasi logika penentu render awal.
Kapan memakai lazy data, defer, atau guard render
Pakai lazy data jika
- data tidak dibutuhkan untuk HTML awal,
- pengguna tidak harus melihatnya segera,
- bagian itu tidak menentukan state awal komponen penting.
Pakai defer jika
- Anda ingin halaman utama stabil lebih dulu,
- data tambahan boleh datang setelah initial render,
- placeholder yang konsisten bisa disediakan.
Pakai guard render jika
- komponen hanya bisa hidup di browser,
- akses ke
window, ukuran layar, local storage, atau library DOM-only tidak bisa dihindari, - Anda rela area itu tidak ikut SSR penuh.
Aturan praktisnya:
- Jika nilai penting untuk HTML awal, buat deterministik dan render dari server.
- Jika nilai penting tapi hanya tersedia di browser, tampilkan fallback stabil lalu isi setelah mounted.
- Jika nilai tidak penting untuk initial view, pertimbangkan lazy atau defer.
Checklist review PR untuk mencegah UI kedip saat hydration
- Apakah ada penggunaan
now(),Date.now(),new Date(), atau relative time pada render awal? - Apakah tanggal/angka diformat dengan locale atau timezone yang bisa berbeda antara server dan client?
- Apakah komponen membaca
window,document,localStorage, ukuran layar, atau browser API saat render? - Apakah ada
Math.random()atau ID/keys yang tidak stabil? - Apakah default
useFormberasal dari props server yang stabil, bukan dari kondisi browser? - Apakah flash message dibaca satu kali secara konsisten dari shared props?
- Apakah auth/session/locale di shared props benar-benar final dan tidak berubah selama initial render?
- Apakah server dan client sama-sama menggunakan satu sumber kebenaran untuk status, label, dan formatting awal?
- Apakah komponen pihak ketiga yang DOM-only sudah dibungkus guard render?
- Apakah data yang tidak penting untuk HTML awal sudah dipertimbangkan untuk lazy atau defer?
Kesalahan umum yang sering terjadi
- Menganggap semua flicker adalah masalah CSS. Jika sumbernya hydration mismatch, memperhalus transisi tidak menyelesaikan akar masalah.
- Menghitung ulang nilai yang sudah diputuskan server. Ini sering membuat status, badge, dan form state berubah sesaat.
- Menaruh logika browser-only di computed awal. Walau tidak error, hasil render awal jadi tidak konsisten.
- Memakai guard render terlalu agresif. Aman, tetapi bisa mengurangi manfaat SSR dan membuat konten penting hilang di initial view.
- Mencampur nilai mentah dan nilai terformat tanpa aturan jelas. Pilih mana yang menjadi kontrak render awal.
Penutup
Debug UI kedip dan state salah saat hydration pada Laravel + Inertia pada dasarnya adalah pekerjaan menyamakan dua hal: apa yang dirender server, dan apa yang ingin dirender client pada detik pertama. Jika keduanya identik, hydration akan tenang. Jika tidak, browser akan “mengoreksi” DOM dan muncullah flicker atau state yang terasa salah.
Mulailah dari komponen yang berubah, audit semua nilai non-deterministik, pastikan shared props stabil, dan putuskan dengan sadar mana yang harus dirender final di server, mana yang boleh ditunda dengan lazy/defer, dan mana yang harus dijaga dengan guard render. Pendekatan ini lebih tahan lama daripada sekadar menambal gejala.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!