Pada aplikasi Laravel SSR yang memakai komponen frontend interaktif, UI berkedip saat halaman dimuat biasanya bukan sekadar masalah kosmetik. Gejala seperti teks berubah beberapa milidetik setelah render, tombol terasa aktif lalu tidak aktif, daftar item meloncat, atau event handler terasa tidak konsisten sering mengarah ke satu sumber utama: hasil render server tidak sama dengan state awal yang dipakai saat hydration di browser.

Masalah ini makin sering muncul saat render awal bergantung pada data async, waktu saat ini, nilai acak, localStorage, cookie, status autentikasi, atau kondisi yang hanya tersedia di browser seperti ukuran layar dan tema sistem. Solusinya bukan sekadar “matikan SSR”, tetapi memastikan HTML dari server, payload data awal, dan state pertama di client benar-benar sinkron. Di bawah ini kita bahas cara mendiagnosis dan memperbaikinya secara praktis.

Gejala umum pada Laravel SSR saat hydration bermasalah

Tanda paling umum adalah tampilan awal terlihat benar, lalu berubah segera setelah JavaScript aktif. Dalam praktiknya, gejala bisa muncul dalam beberapa bentuk:

  • UI berkedip: elemen muncul lalu hilang, atau class CSS berubah setelah hydration.
  • Teks berubah: misalnya “Selamat pagi” menjadi “Selamat malam”, format tanggal berubah, atau angka hitungan tidak sama.
  • Daftar item re-order: item hasil loop tampak pindah posisi setelah client selesai mount.
  • Event terasa aneh: klik pertama tidak berefek, state toggle meloncat, atau input tampak “di-reset”.
  • Warning hydration di console browser dari framework frontend yang dipakai.

Kalau gejalanya muncul hanya di production atau hanya pada koneksi lambat, itu justru menguatkan dugaan bahwa ada perbedaan antara render server dan render awal client.

Akar masalah: server render satu nilai, client mulai dari nilai lain

Pada SSR, alurnya sederhana:

  1. Server merender HTML awal.
  2. Browser menampilkan HTML itu.
  3. JavaScript di client melakukan hydration pada markup yang sudah ada.
  4. Framework frontend mengasumsikan bahwa output render pertamanya sama dengan HTML dari server.

Kalau asumsi itu gagal, framework harus “memperbaiki” DOM agar sesuai dengan state client. Dari sinilah muncul kedipan, perubahan teks, atau perilaku event yang tidak stabil.

Penyebab mismatch paling umum pada proyek Laravel SSR:

  • Data async diambil dua kali dan hasilnya berbeda antara server dan client.
  • Nilai waktu seperti now(), Date.now(), atau format tanggal bergantung timezone berbeda.
  • Nilai acak seperti Math.random() atau ID yang dibuat saat render.
  • Browser-only API seperti window, document, localStorage, matchMedia.
  • Cookie atau auth state tidak dipropagasikan konsisten ke renderer SSR dan ke browser.
  • Conditional rendering berdasarkan ukuran layar, tema, locale, atau capability browser.

Checklist investigasi yang bisa langsung dipakai

1. Bandingkan HTML hasil SSR dengan state awal client

Langkah pertama adalah memastikan apa yang dikirim server. Buka View Source atau simpan response HTML mentah dari endpoint yang bermasalah. Lalu bandingkan dengan DOM setelah JavaScript selesai jalan.

Fokus pada bagian ini:

  • teks yang berubah,
  • atribut class,
  • nilai input,
  • jumlah item dalam list,
  • atribut yang merepresentasikan auth/user/theme.

Jika server mengirim “Guest” tetapi client langsung merender “Aldi”, mismatch sudah jelas terjadi sebelum interaksi pengguna apa pun.

2. Log payload data yang dipakai SSR dan payload yang dipakai client

Pada aplikasi Laravel, sering kali data halaman dikirim dari backend ke frontend melalui props atau payload bootstrap. Simpan snapshot dari data yang dipakai saat SSR, lalu bandingkan dengan data yang dibaca di client sebelum komponen mount penuh.

Yang perlu dicek:

  • Apakah field yang sama ada di kedua sisi?
  • Apakah nilainya identik, termasuk null vs string kosong?
  • Apakah tanggal dikirim dalam format stabil, misalnya ISO string, bukan hasil format lokal?
  • Apakah auth/user di SSR memakai request yang sama dengan request browser?

3. Cari kode yang membaca browser API saat render awal

Ini salah satu sumber mismatch paling sering. Jika komponen membaca localStorage atau window.matchMedia langsung di fase render awal, server tidak punya nilai yang sama.

Tanya pada diri sendiri:

  • Apakah state awal dibentuk dari window atau document?
  • Apakah tampilan bergantung pada lebar layar?
  • Apakah tema gelap/terang diambil dari localStorage saat render pertama?

4. Audit semua nilai non-deterministik

Render SSR harus sedapat mungkin deterministik. Hindari menghasilkan output awal dari nilai yang berubah tiap eksekusi.

  • time(), microtime(), now()
  • Date.now()
  • Math.random()
  • ID acak untuk key list atau elemen form

Kalau nilai itu wajib ada, hasilkan sekali di server dan kirim sebagai bagian dari payload awal, bukan dihitung ulang di client saat hydration.

5. Periksa fetch async yang berjalan ulang di client

Pola yang sering menimbulkan kedipan adalah:

  1. Server sudah merender data A.
  2. Client mount, lalu langsung fetch lagi.
  3. Response kedua berisi data B atau urutan B.
  4. DOM berubah dan terlihat seperti flicker.

Ini tidak selalu salah, tetapi kalau fetch kedua hanya untuk mendapatkan data yang seharusnya sudah ada dari SSR, Anda sedang membayar biaya dobel sekaligus membuka risiko mismatch.

Contoh pola kode yang memicu masalah

State awal diambil dari localStorage

Contoh ini umum pada tema, preferensi tampilan, atau mode filter.

const theme = localStorage.getItem('theme') || 'light'
const state = reactive({ theme })

Di server, localStorage tidak tersedia. Akibatnya server mungkin merender light, sementara client langsung memulai dari dark. Hasilnya: class berubah saat hydration.

Perbaikan: render awal pakai nilai yang sama di kedua sisi, lalu sinkronkan preferensi browser setelah mount.

const state = reactive({ theme: initialThemeFromServer ?? 'light' })

onMounted(() => {
  const saved = localStorage.getItem('theme')
  if (saved && saved !== state.theme) {
    state.theme = saved
  }
})

Lebih baik lagi jika backend juga membaca cookie tema dan mengirim initialThemeFromServer, sehingga HTML awal sudah sesuai preferensi pengguna.

Tampilan bergantung pada waktu saat ini

<p>{{ new Date().toLocaleTimeString() }}</p>

Masalahnya bukan hanya detik yang terus berjalan. Timezone server dan browser bisa berbeda, locale bisa berbeda, dan hasil format bisa tidak identik.

Perbaikan:

  • Jangan render jam berjalan sebagai bagian dari markup SSR jika tidak perlu.
  • Jika harus ada, kirim timestamp mentah dari server lalu format dengan aturan yang konsisten.
  • Untuk elemen yang memang dinamis setelah load, render placeholder stabil saat SSR.
<p>{{ initialTimeLabel }}</p>

// initialTimeLabel dibuat di server dan dipakai juga sebagai state awal client

Random value dipakai untuk key atau ID

const items = data.map(item => ({
  ...item,
  key: Math.random().toString(36).slice(2)
}))

Kalau server dan client menghitung key berbeda, list bisa dianggap elemen baru. Ini bisa memicu re-render, state form hilang, atau event terasa meloncat.

Perbaikan: gunakan ID stabil dari data backend. Jika tidak ada, buat sekali di server lalu kirim ke client.

Auth state tidak konsisten

Misalnya server merender navbar sebagai guest, tetapi client membaca token lokal dan langsung menampilkan nama user. Ini sering terjadi ketika SSR memakai session/cookie, sementara client memakai token di storage yang tidak sinkron.

Perbaikan:

  • Tentukan satu sumber kebenaran untuk render awal.
  • Jika SSR bergantung pada user login, pastikan request SSR menerima cookie/auth context yang sama.
  • Hindari membaca auth dari storage untuk mengubah markup awal sebelum state dari server dipakai.

Kondisi browser-only dipakai untuk conditional rendering

const isMobile = window.innerWidth < 768
return isMobile ? MobileMenu : DesktopMenu

Server tidak tahu ukuran viewport sebenarnya. Akibatnya HTML awal bisa selalu desktop, lalu client menggantinya ke mobile.

Perbaikan: hindari memutuskan struktur DOM utama dari viewport saat SSR. Gunakan CSS responsif untuk layout bila memungkinkan. Jika perilaku berbeda memang wajib, tunda keputusan ke sisi client setelah mount.

Strategi perbaikan yang paling efektif

1. Samakan initial state antara server dan client

Prinsip paling penting: state pertama di client harus dibangun dari payload yang sama dengan yang dipakai server untuk merender HTML.

Praktiknya pada Laravel SSR:

  • Hitung data awal di backend sekali.
  • Serialisasikan data itu ke halaman sebagai payload bootstrap.
  • Gunakan payload tersebut sebagai satu-satunya sumber state awal komponen.
  • Jangan lakukan recompute yang berbeda di client sebelum hydration selesai.

Contoh data yang sebaiknya dikirim dari server secara eksplisit:

  • user saat ini,
  • locale aktif,
  • timezone yang dipilih aplikasi,
  • tema dari cookie,
  • timestamp yang dipakai untuk tampilan awal,
  • fitur/flag yang memengaruhi conditional rendering.

2. Tunda browser API ke lifecycle client

Kalau suatu nilai hanya tersedia di browser, jangan jadikan ia penentu markup SSR. Pindahkan pembacaan ke fase setelah komponen terpasang.

Pola umumnya:

const state = reactive({
  hydrated: false,
  theme: initialThemeFromServer
})

onMounted(() => {
  state.hydrated = true
  const saved = localStorage.getItem('theme')
  if (saved) state.theme = saved
})

Dengan cara ini, HTML awal tetap stabil. Perubahan setelah mount menjadi eksplisit dan bisa dikendalikan, misalnya dengan placeholder, transisi halus, atau sinkronisasi class yang tidak mengubah struktur DOM utama.

3. Stabilkan payload SSR

Jika data async menjadi penyebab, masalah sering bukan pada hydration itu sendiri, tetapi pada payload yang tidak stabil. Pastikan hasil fetch untuk SSR:

  • berasal dari query yang deterministik,
  • memiliki urutan data yang eksplisit,
  • tidak bergantung pada state mutable yang berubah cepat tanpa snapshot,
  • tidak memformat nilai berbeda antara backend dan frontend.

Contoh kesalahan klasik adalah daftar tanpa ORDER BY yang jelas. Server bisa mengembalikan urutan A, client fetch berikutnya dapat urutan B. Di UI, ini terlihat seperti item “berkedip” atau berpindah.

4. Hindari fetch ulang yang tidak perlu saat mount

Jika SSR sudah menyediakan data yang cukup untuk tampilan pertama, jangan langsung melakukan fetch ulang hanya karena kebiasaan pola SPA murni.

Pertimbangkan strategi ini:

  • Gunakan data SSR sebagai cache awal.
  • Lakukan revalidate hanya bila perlu.
  • Jika refresh data harus dilakukan, pertahankan UI lama sampai data baru siap agar tidak terjadi state kosong sesaat.

Dengan kata lain, masalah sering terjadi bukan karena ada request kedua, tetapi karena request kedua mengosongkan state atau mengganti struktur UI terlalu cepat.

5. Gunakan fallback yang stabil untuk elemen yang memang client-only

Ada kasus ketika sebagian UI memang tidak masuk akal dirender penuh di server, misalnya widget yang sangat bergantung pada browser API. Dalam kondisi seperti ini, lebih aman menampilkan placeholder SSR yang tetap, lalu memuat komponen interaktif setelah mount.

Prinsipnya: lebih baik placeholder yang stabil daripada HTML SSR yang tampak lengkap tetapi pasti mismatch saat hydration.

Langkah debug praktis di proyek Laravel

Tambah snapshot data di response

Saat mencari sumber mismatch, sangat membantu bila Anda bisa melihat data apa yang dipakai saat SSR. Simpan payload awal di halaman, lalu log di client sebelum komponen aktif penuh.

// Backend Laravel menyiapkan payload awal
return view('app', [
    'page' => [
        'user' => $user,
        'theme' => $themeFromCookie,
        'generated_at' => now()->toISOString(),
    ],
]);

Lalu di client, cek apakah nilai awal yang dipakai benar-benar berasal dari payload itu, bukan dari pembacaan ulang yang berbeda.

Gunakan marker sederhana untuk membedakan render server vs update client

Tambahkan atribut atau teks debug sementara untuk mengetahui apakah elemen berubah karena hydration atau karena update data berikutnya.

<div data-debug-source="ssr">{{ initialLabel }}</div>

Setelah mount, Anda bisa melihat apakah elemen yang sama diganti total atau hanya diperbarui isinya.

Matikan sementara sumber non-deterministik

Saat debugging, nonaktifkan satu per satu hal berikut:

  • pembacaan localStorage,
  • fetch ulang saat mount,
  • format tanggal lokal,
  • nilai random,
  • conditional rendering berdasarkan viewport.

Jika flicker hilang setelah satu sumber dimatikan, Anda sudah menemukan kandidat utama.

Periksa console warning hydration

Framework frontend modern biasanya memberi warning ketika text node, atribut, atau struktur DOM tidak cocok saat hydration. Jangan abaikan warning ini. Walau tampilan masih “jalan”, warning tersebut sering tepat menunjukkan node mana yang mismatch.

Uji dengan user state yang berbeda

Masalah auth dan cookie sering lolos jika hanya dites sebagai satu user. Coba skenario berikut:

  • guest vs authenticated user,
  • cookie tema ada vs tidak ada,
  • locale berbeda,
  • timezone berbeda,
  • tab pertama setelah login vs refresh halaman.

Hydration mismatch kerap hanya muncul pada kombinasi state tertentu.

Pola perbaikan yang aman untuk kasus umum

Tema dari cookie, sinkronisasi browser setelah mount

Untuk tema gelap/terang, pendekatan yang stabil adalah:

  1. Baca cookie di Laravel untuk SSR.
  2. Render class tema berdasarkan cookie itu.
  3. Setelah mount, baru cocokkan dengan localStorage bila aplikasi memang menyimpan preferensi di sana.

Ini mencegah flash dari tema default ke tema pilihan user.

Auth navbar dari server payload

Navbar sebaiknya mengambil user awal dari payload server, bukan langsung dari token storage. Jika sesi user berubah, lakukan refresh state secara eksplisit setelah mount, bukan mengganti markup awal diam-diam saat hydration.

Waktu relatif dirender setelah hydration

Label seperti “5 menit lalu” sangat mudah mismatch. Untuk SSR, tampilkan timestamp absolut atau placeholder stabil, lalu ubah ke waktu relatif di client setelah mount. Pendekatan ini mengurangi warning dan mencegah teks berubah tepat saat hydration.

Kesalahan umum yang sering memperburuk masalah

  • Menggunakan default state berbeda di server dan client.
  • Memformat data dua kali dengan aturan locale/timezone yang berbeda.
  • Mengosongkan state saat fetch ulang di client, sehingga UI berkedip ke loading padahal SSR sudah punya data.
  • Menggunakan key list tidak stabil, terutama dari index yang berubah atau random.
  • Mengandalkan viewport untuk struktur DOM awal alih-alih CSS responsif.
  • Menganggap mismatch kecil aman; sering kali ini memicu bug interaksi yang sulit dilacak.

Penutup

Debug Laravel SSR: Debug UI Berkedip akibat Hydration dan Data Async pada dasarnya adalah pekerjaan menyamakan kenyataan di dua tempat: apa yang dirender server, dan apa yang diyakini client saat mulai hidup. Jika UI berkedip, teks berubah, atau event terasa aneh, hampir selalu ada state awal yang tidak identik.

Mulailah dari inspeksi HTML SSR dan payload awal, lalu audit semua sumber non-deterministik: data async, waktu, random value, localStorage, cookie, auth state, dan kondisi browser-only. Setelah itu, terapkan tiga strategi inti: samakan initial state, tunda browser API ke lifecycle client, dan stabilkan payload SSR. Dengan pendekatan ini, sebagian besar flicker dan hydration mismatch bisa dilokalisasi dan diperbaiki tanpa menurunkan manfaat SSR.