Saat CodeIgniter 4 merender HTML di server lalu Alpine.js mengambil alih interaksi di browser, masalah yang paling sering muncul adalah UI meloncat, teks berubah sesaat setelah halaman tampil, atau elemen muncul-hilang setelah Alpine aktif. Akar masalahnya hampir selalu sama: state awal yang dipakai server berbeda dengan state awal yang dipakai client.

Artikel ini membahas cara membuat SSR aman untuk Alpine.js agar hydration tidak terasa “lompat”. Fokusnya bukan SSR penuh seperti framework besar, tetapi integrasi ringan yang umum dipakai di view CodeIgniter 4: server mengirim HTML awal yang stabil, lalu Alpine menghidupkan perilaku interaktif tanpa mengubah tampilan awal secara mendadak.

Memahami alur render: server dulu, browser kemudian

Sebelum memperbaiki bug, penting memahami urutan kerjanya:

  1. CodeIgniter 4 merender view di server dan menghasilkan HTML final.
  2. Browser menerima HTML lalu menampilkannya secepat mungkin.
  3. JavaScript dimuat, termasuk Alpine.js.
  4. Alpine menginisialisasi komponen, membaca x-data, menjalankan ekspresi, lalu memperbarui DOM bila perlu.

Jika HTML dari server menampilkan satu kondisi, tetapi x-data Alpine memulai dengan kondisi lain, browser akan menampilkan dua fase yang berbeda:

  • fase awal dari server,
  • fase kedua setelah Alpine aktif.

Perbedaan inilah yang terlihat sebagai flicker, layout shift, atau UI yang “meloncat”.

Gejala yang biasanya terlihat pengguna

  • Tombol atau panel tampil tertutup di awal, lalu tiba-tiba terbuka.
  • Teks “Masuk” berubah menjadi nama pengguna setelah JavaScript jalan.
  • Daftar item berubah urutan atau jumlahnya sesaat setelah halaman tampil.
  • Komponen tema gelap/terang berkedip karena state dari localStorage baru dibaca di client.
  • Elemen yang bergantung pada lebar layar atau objek window tampil berbeda setelah browser menghitung kondisi sebenarnya.

Penyebab mismatch yang paling sering

1. Data default server dan client berbeda

Ini kasus paling umum. Misalnya server merender panel dalam keadaan tertutup, tetapi x-data Alpine memulai dengan open: true. Hasilnya, panel tampak tertutup sebentar lalu terbuka.

Contoh masalah:

<!-- HTML dari server terlihat tertutup -->
<div x-data="{ open: true }">
  <button @click="open = !open">Toggle</button>
  <div x-show="open">Isi panel</div>
</div>

Jika markup awal dari server mengasumsikan panel tertutup, tetapi Alpine memulai open: true, akan ada perubahan visual saat inisialisasi.

2. State ditentukan dari localStorage

Server tidak bisa membaca localStorage browser. Jadi kalau state awal Alpine ditentukan dari preferensi lokal pengguna, HTML server hampir pasti berbeda dengan hasil akhir client.

Contoh umum:

  • tema gelap/terang,
  • sidebar terakhir dibuka/ditutup,
  • tab terakhir yang aktif.

Solusinya bukan memaksa server menebak, tetapi menyediakan fallback SSR yang stabil lalu mengizinkan client mengubahnya dengan cara yang tidak mengganggu.

3. Kondisi berbasis window atau document

Server tidak punya objek browser seperti window, document, ukuran viewport, media query runtime, atau posisi scroll. Jika state awal bergantung pada hal-hal ini, server dan client akan berbeda.

Contoh:

<div x-data="{ mobile: window.innerWidth < 768 }">...</div>

Selain berpotensi error jika dievaluasi terlalu dini, logika seperti ini memang tidak bisa disamakan dengan render server.

4. Waktu render dan data async

Server merender berdasarkan data yang tersedia saat request diproses. Setelah halaman tampil, Alpine bisa menjalankan fetch, membaca cache browser, atau memproses state tambahan. Jika state awal terlalu bergantung pada hasil setelah load, tampilan awal akan berubah lagi.

5. Markup kondisional tidak stabil

Markup yang bergantung pada kondisi tertentu harus konsisten antara SSR dan client. Bila server merender satu cabang, tetapi Alpine segera menggantinya dengan cabang lain, pengguna melihat elemen berpindah, tinggi layout berubah, atau fokus keyboard hilang.

Reproduksi minimal: kasus panel yang meloncat

Contoh berikut cukup untuk menunjukkan masalahnya.

View CodeIgniter 4 yang bermasalah

<div class="card" x-data="{ open: true }">
  <button type="button" @click="open = !open">Filter</button>

  <!-- Server mengharapkan panel tertutup, tapi Alpine memulai open:true -->
  <div x-show="open">
    <p>Opsi filter...</p>
  </div>
</div>

Gejala di browser:

  1. HTML awal bisa tampak tanpa state yang benar.
  2. Setelah Alpine aktif, panel langsung berubah.
  3. Pengguna merasa komponen “meloncat”.

Masalahnya bukan di Alpine semata, tetapi di kontrak state awal yang tidak sinkron antara server dan client.

Pola aman: kirim initial state dari CodeIgniter 4 ke Alpine.js

Perbaikan paling aman adalah membuat server dan client memakai sumber state awal yang sama. Artinya, CodeIgniter 4 menghitung state awal, mengirimkannya ke view sebagai JSON yang aman, lalu Alpine membaca nilai itu sebagai input awal.

Controller CodeIgniter 4

<?php

namespace App\Controllers;

class Dashboard extends BaseController
{
    public function index()
    {
        $initialState = [
            'filterOpen' => false,
            'userName'   => auth()->user()->username ?? null,
            'isLoggedIn' => auth()->loggedIn() ?? false,
        ];

        return view('dashboard', [
            'initialState' => $initialState,
        ]);
    }
}

Intinya, state yang memang diketahui server sebaiknya dihitung di server.

View CodeIgniter 4 dengan JSON aman

<?php
  $jsonFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
  $initialStateJson = json_encode($initialState, $jsonFlags);
?>

<div
  x-data="panelComponent()"
  class="card"
>
  <button type="button" @click="toggle" :aria-expanded="open.toString()">
    Filter
  </button>

  <div x-cloak x-show="open">
    <p>Opsi filter...</p>
  </div>
</div>

<script>
  function panelComponent(initialState) {
    return {
      open: Boolean(initialState.filterOpen),
      userName: initialState.userName,
      isLoggedIn: Boolean(initialState.isLoggedIn),
      toggle() {
        this.open = !this.open;
      }
    }
  }
</script>

Mengapa pendekatan ini bekerja?

  • Server dan client memakai state awal yang sama.
  • Nilai JSON di-encode dengan aman agar tidak merusak atribut HTML atau membuka celah injeksi.
  • Alpine tidak menebak state dari default lokal yang berbeda.

Gunakan data yang benar-benar diperlukan untuk initial render. Jangan kirim seluruh objek besar jika hanya butuh 2-3 properti untuk state awal komponen.

Kenapa perlu JSON aman?

Karena data dimasukkan ke HTML, Anda harus menghindari karakter yang bisa memutus atribut atau menghasilkan markup tak valid. Kombinasi json_encode(...) dengan flag heksadesimal dan esc(..., 'attr') membantu menjaga output tetap aman untuk konteks atribut.

Menggunakan x-cloak untuk mencegah flicker saat inisialisasi

x-cloak tidak menyelesaikan mismatch state, tetapi sangat berguna untuk menyembunyikan elemen yang belum siap ditampilkan sebelum Alpine selesai inisialisasi.

CSS global

[x-cloak] { display: none !important; }

Penggunaan

<div x-data="panelComponent()">
  <div x-cloak x-show="open">
    Konten panel
  </div>
</div>

Pola ini cocok jika elemen memang seharusnya tidak muncul sebelum state client siap. Namun ada trade-off:

  • Pro: menghilangkan flicker visual.
  • Kontra: elemen disembunyikan sampai Alpine aktif, jadi bila JavaScript gagal dimuat, elemen bisa tetap tidak terlihat.

Karena itu, jangan gunakan x-cloak secara membabi buta untuk semua konten penting. Untuk konten inti, lebih aman membuat fallback SSR yang memang sudah benar.

Guard untuk kode browser-only

Jika komponen perlu membaca localStorage, matchMedia, atau window, lakukan setelah inisialisasi client dan jangan jadikan hasilnya sebagai satu-satunya penentu markup awal.

Contoh pola aman untuk preferensi lokal

<div x-data="themeComponent()">
  <button @click="toggleTheme">Tema</button>
</div>

<script>
  function themeComponent(initialState) {
    return {
      theme: initialState.theme || 'light',
      init() {
        try {
          const saved = window.localStorage.getItem('theme');
          if (saved === 'light' || saved === 'dark') {
            this.theme = saved;
          }
        } catch (e) {
          // Abaikan jika storage tidak tersedia
        }
      },
      toggleTheme() {
        this.theme = this.theme === 'dark' ? 'light' : 'dark';
        try {
          window.localStorage.setItem('theme', this.theme);
        } catch (e) {}
      }
    }
  }
</script>

Prinsipnya:

  • Initial state tetap berasal dari server atau fallback yang netral.
  • Browser-only enhancement dijalankan setelah komponen hidup.
  • Jika nilai dari browser berbeda, perubahan harus minim dan terkontrol.

Kesalahan yang perlu dihindari

<div x-data="{ theme: localStorage.getItem('theme') || 'light' }">

Secara praktis ini membuat state awal bergantung penuh pada browser. Untuk preferensi visual kecil mungkin masih bisa diterima, tetapi untuk struktur layout utama biasanya memicu loncatan yang mengganggu.

Membangun fallback SSR yang stabil

Kunci integrasi frontend ringan di CodeIgniter 4 adalah memisahkan dua hal:

  • apa yang harus benar sejak HTML pertama,
  • apa yang boleh disempurnakan setelah JavaScript aktif.

Pilih state default yang aman secara visual

Jika server tidak bisa mengetahui kondisi sebenarnya, pilih fallback yang:

  • tidak merusak layout,
  • tidak membingungkan pengguna,
  • tidak membuat konten penting hilang,
  • mudah diubah setelah client siap.

Contoh keputusan yang sering masuk akal:

  • Sidebar default tertutup jika versi terbuka bisa menggeser layout besar.
  • Panel filter default tertutup jika isinya sekunder.
  • Info autentikasi dirender berdasarkan session server, bukan hasil pengecekan client.

Utamakan data server untuk hal yang memang server tahu

Jangan ambil keputusan initial render dari JavaScript jika server sebenarnya sudah punya datanya, misalnya:

  • status login,
  • nama pengguna,
  • fitur berdasarkan role/permission,
  • hasil query untuk halaman saat ini.

Semakin banyak informasi penting diambil dari sumber server yang sama, semakin kecil kemungkinan mismatch.

Hindari mengubah struktur markup secara agresif saat init

Perubahan seperti menambah/menghapus blok besar saat Alpine baru aktif lebih terlihat dibanding sekadar mengganti kelas CSS. Jika memungkinkan:

  • render struktur dasar yang sama di server,
  • ubah visibilitas atau kelas secara halus di client,
  • jaga ukuran container agar tidak menyebabkan layout shift besar.

Contoh praktis view CI4 yang lebih stabil

Contoh berikut menggabungkan beberapa pola sekaligus: initial state dari server, fallback SSR stabil, dan guard browser-only.

<?php
  $jsonFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
  $initialStateJson = json_encode([
      'sidebarOpen' => false,
      'isLoggedIn'  => !empty($isLoggedIn),
      'userName'    => $userName ?? null,
  ], $jsonFlags);
?>

<style>
  [x-cloak] { display: none !important; }
</style>

<aside
  class="sidebar"
  x-data="sidebarComponent()"
  :class="{ 'is-open': open }"
>
  <button type="button" @click="toggle" :aria-expanded="open.toString()">
    Menu
  </button>

  <nav>
    <ul>
      <li><a href="/dashboard">Dashboard</a></li>
      <li><a href="/reports">Laporan</a></li>
    </ul>
  </nav>

  <div class="user-box">
    <?php if (!empty($isLoggedIn) && !empty($userName)): ?>
      <p>Halo, <?= esc($userName) ?></p>
    <?php else: ?>
      <p>Halo, tamu</p>
    <?php endif; ?>
  </div>

  <div x-cloak x-show="ready && fromStorage">
    <small>Preferensi lokal diterapkan</small>
  </div>
</aside>

<script>
  function sidebarComponent(initialState) {
    return {
      open: Boolean(initialState.sidebarOpen),
      ready: false,
      fromStorage: false,
      init() {
        this.ready = true;

        try {
          const saved = window.localStorage.getItem('sidebarOpen');
          if (saved === 'true' || saved === 'false') {
            this.open = saved === 'true';
            this.fromStorage = true;
          }
        } catch (e) {
          // Tidak perlu menggagalkan UI jika storage tidak tersedia
        }
      },
      toggle() {
        this.open = !this.open;
        try {
          window.localStorage.setItem('sidebarOpen', String(this.open));
        } catch (e) {}
      }
    }
  }
</script>

Apa yang sudah benar dari contoh di atas?

  • HTML server tetap valid dan bisa dipakai tanpa JavaScript.
  • Informasi login dirender dari server, bukan ditebak client.
  • State sidebar punya default yang stabil.
  • Preferensi dari localStorage diterapkan sesudah init, dengan guard try/catch.
  • Elemen yang bergantung pada status client memakai x-cloak.

Kapan mismatch masih bisa diterima?

Tidak semua perubahan setelah init harus dianggap bug berat. Ada kasus di mana perbedaan kecil memang wajar, misalnya:

  • menandai bahwa preferensi lokal sudah diterapkan,
  • mengisi data sekunder yang memang baru diambil setelah load,
  • enhancement visual minor yang tidak mengubah struktur utama.

Yang perlu dihindari adalah mismatch pada konten inti, layout utama, atau aksi penting yang menyebabkan pengguna melihat UI berubah drastis.

Checklist debugging jika hydration terasa meloncat

Periksa sumber state awal

  • Apakah nilai default di x-data sama dengan yang diasumsikan server?
  • Apakah data penting sudah dikirim dari controller ke view?
  • Apakah ada properti yang nilainya diubah lagi di init()?

Audit dependensi browser-only

  • Apakah komponen membaca localStorage saat inisialisasi?
  • Apakah ada akses ke window, document, matchMedia, atau ukuran viewport?
  • Apakah logika itu benar-benar perlu memengaruhi tampilan awal?

Cek markup kondisional

  • Apakah server merender cabang A, tetapi Alpine segera mengganti ke cabang B?
  • Apakah x-show, x-if, atau class binding mengubah layout terlalu besar?
  • Apakah kontainer punya tinggi minimum agar perubahan tidak terlalu terasa?

Periksa cara menyisipkan JSON

  • Apakah JSON di-encode dengan aman?
  • Apakah output di-escape sesuai konteks atribut?
  • Apakah ada karakter kutip yang memutus nilai x-data?

Uji tanpa JavaScript dan dengan koneksi lambat

Ini cara sederhana tapi efektif:

  • matikan JavaScript sementara untuk melihat fallback SSR,
  • uji pada throttling jaringan di DevTools agar fase antara SSR dan inisialisasi client lebih mudah terlihat,
  • amati apakah layout awal tetap masuk akal sebelum Alpine aktif.

Kesalahan umum dalam integrasi CodeIgniter 4 + Alpine.js

  • Menaruh seluruh logika state di frontend padahal server sudah tahu jawabannya.
  • Mengandalkan localStorage untuk state pertama pada komponen yang memengaruhi layout utama.
  • Menggunakan x-cloak untuk menutupi mismatch tanpa memperbaiki akar masalahnya.
  • Mengirim JSON mentah tanpa encoding/escaping yang tepat.
  • Mengakses API browser langsung di ekspresi awal tanpa guard atau fallback.

Ringkasan langkah perbaikan

  1. Tentukan state apa saja yang harus benar sejak render server.
  2. Hitung state itu di controller atau view CodeIgniter 4.
  3. Kirim initial state ke Alpine sebagai JSON yang aman.
  4. Gunakan x-cloak hanya untuk bagian yang memang boleh menunggu inisialisasi client.
  5. Tempatkan kode browser-only di fase inisialisasi client dengan guard yang aman.
  6. Pilih fallback SSR yang stabil jika server tidak bisa mengetahui kondisi browser.
  7. Debug dengan membandingkan HTML awal dari server dan state setelah Alpine aktif.

Jika Anda konsisten memakai prinsip ini, integrasi CodeIgniter 4: SSR aman untuk Alpine.js akan terasa jauh lebih halus. Pengguna melihat HTML awal yang sudah benar, sementara Alpine menambahkan interaktivitas tanpa membuat tampilan awal berubah mendadak.