Pada aplikasi server-side rendering (SSR), flicker biasanya terjadi ketika HTML awal yang dikirim server tidak sama dengan state yang dihitung ulang oleh JavaScript di browser. Di CodeIgniter 4, masalah ini sering muncul saat view sudah dirender di server, tetapi skrip frontend langsung mengganti isi DOM karena nilai default, waktu, cookie, localStorage, atau data user di klien berbeda dari yang dipakai server.

Solusi utamanya bukan sekadar “menunggu JavaScript selesai”, melainkan menyamakan state awal antara server dan klien. Untuk itu, gunakan pola yang aman: serialisasi state awal dari server, beri guard untuk kode browser-only, gunakan placeholder yang stabil untuk data yang belum siap, dan tunda update yang bergantung pada browser sampai komponen atau skrip benar-benar mounted.

Mengapa UI Bisa Flicker pada SSR di CodeIgniter 4

Pada pola SSR, alurnya biasanya seperti ini:

  1. Controller CodeIgniter 4 mengambil data dan merender view HTML.
  2. Browser menerima HTML dan langsung menampilkannya.
  3. JavaScript frontend berjalan, membaca state dari browser, lalu memperbarui UI.

Flicker muncul jika langkah pertama dan ketiga menghasilkan nilai UI yang berbeda. Pengguna melihat HTML awal, lalu sepersekian detik kemudian tampilannya berubah. Pada beberapa kasus, ini bukan hanya masalah estetika, tetapi juga bisa memicu:

  • pergeseran layout yang mengganggu,
  • tombol atau label berubah setelah terlihat,
  • nilai form berganti setelah sempat tampil,
  • konten personalisasi terlambat muncul,
  • indikasi state login/tema yang tidak konsisten.

Penyebab Umum State Hydration Tidak Sinkron

1. Nilai default server dan browser berbeda

Ini kasus paling umum. Misalnya server merender tab aktif sebagai overview, tetapi saat JavaScript berjalan, klien menetapkan default ke settings. Akibatnya UI berubah setelah halaman terlihat.

Masalah inti: state awal tidak memiliki satu sumber kebenaran yang sama.

2. Membaca localStorage atau cookie terlalu dini

Server tidak bisa membaca localStorage, jadi kalau state awal bergantung pada sana, render server hanya menebak. Ketika browser membaca nilai aktual dari localStorage, DOM langsung berubah.

Cookie sedikit berbeda karena sebagian cookie bisa dibaca server lewat request. Namun mismatch tetap bisa terjadi bila:

  • server tidak menggunakan cookie itu saat merender,
  • cookie diubah oleh skrip sebelum UI stabil,
  • ada logika klien yang berbeda dari logika server.

3. Elemen yang bergantung pada waktu

Contohnya: jam saat ini, teks “5 menit yang lalu”, countdown, atau perhitungan berdasarkan timezone browser. Server dan browser hampir pasti tidak menghitung waktu pada momen yang identik.

Jika nilai waktu dirender langsung di HTML awal dan dihitung ulang segera di browser, perubahan visual akan terlihat sebagai flicker.

4. Random value

Penggunaan Math.random() di browser atau nilai acak saat render server dapat menyebabkan class, urutan elemen, ID, atau ilustrasi berbeda antara hasil server dan hasil klien. Untuk SSR, nilai acak pada render awal hampir selalu sumber mismatch jika tidak dikontrol.

5. Data user datang terlambat

Contohnya server hanya tahu pengguna belum memiliki profil lengkap, tetapi detail personalisasi baru diambil lewat API setelah halaman dimuat. Jika HTML awal menampilkan placeholder yang terlalu berbeda dengan hasil akhir, pengguna akan melihat loncatan UI.

Masalah makin jelas pada komponen seperti:

  • menu akun,
  • notifikasi,
  • harga atau lokasi berbasis user,
  • fitur berbasis role/permission,
  • preferensi tema atau bahasa.

Pola Perbaikan yang Aman

1. Serialisasi state awal dari server

Pola paling aman adalah membuat server menjadi sumber state awal, lalu mengirim state itu ke browser dalam bentuk JSON. Dengan begitu JavaScript tidak menebak lagi state awal, tetapi memakai data yang sama dengan HTML yang sudah dirender.

Di CodeIgniter 4, controller dapat menyiapkan data untuk view sekaligus menyiapkan objek state yang akan diserialisasi.

<?php

namespace App\Controllers;

class Dashboard extends BaseController
{
    public function index()
    {
        $user = [
            'name' => 'Ayu',
            'isLoggedIn' => true,
        ];

        $initialState = [
            'theme' => 'light',
            'activeTab' => 'overview',
            'user' => $user,
            'serverTime' => date('c'),
            'hasUnreadNotifications' => false,
        ];

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

Lalu di view CI4:

<div id="app"
     data-theme=""
     data-active-tab="">

    <nav class="tabs">
        <button class="tab 
    </div>
</div>

<script>
    window.__INITIAL_STATE__ = ;
</script>
<script src="/assets/js/dashboard.js" defer></script>

Mengapa pola ini bekerja? Karena HTML awal dan state JavaScript berasal dari objek yang sama. Saat skrip frontend aktif, ia tidak menghitung ulang default dari nol, melainkan melanjutkan dari state yang sudah dipakai server.

Catatan keamanan: jangan serialisasikan data sensitif ke window.__INITIAL_STATE__. Hanya kirim data yang memang aman untuk terlihat di browser.

2. Guard untuk kode browser-only

Kode yang bergantung pada API browser sebaiknya tidak menentukan hasil render awal. Contoh paling umum adalah localStorage, window, document, viewport, timezone browser, atau preferensi perangkat.

Gunakan state dari server untuk render awal, lalu baca data browser setelah halaman siap.

(function () {
  const state = window.__INITIAL_STATE__ || {};
  const app = document.getElementById('app');

  if (!app) return;

  render(state);

  // Browser-only enhancement, dijalankan setelah render awal stabil
  queueMicrotask(() => {
    try {
      const savedTheme = window.localStorage.getItem('theme');
      if (savedTheme && savedTheme !== state.theme) {
        state.theme = savedTheme;
        applyTheme(state.theme);
      }
    } catch (e) {
      console.warn('Gagal membaca localStorage:', e);
    }
  });

  function render(currentState) {
    applyTheme(currentState.theme || 'light');
  }

  function applyTheme(theme) {
    app.setAttribute('data-theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }
})();

Intinya, jangan jadikan pembacaan localStorage sebagai penentu HTML awal jika server tidak bisa menghasilkan nilai yang sama.

3. Gunakan placeholder yang stabil

Jika data memang belum tersedia saat server merender, lebih aman menampilkan placeholder yang stabil daripada menampilkan nilai sementara yang kemungkinan besar salah. Placeholder yang stabil mengurangi kesan “berkedip” karena struktur UI tidak berubah drastis.

Contoh buruk:

  • server menulis “0 notifikasi”, lalu klien mengubah jadi “12 notifikasi” beberapa milidetik kemudian.

Contoh lebih aman:

  • server menampilkan ikon notifikasi dengan indikator placeholder atau teks “Memuat…”.
<div id="notification-box" data-ready="false">
    <span class="label">Notifikasi</span>
    <span class="count placeholder">...</span>
</div>

Kemudian setelah data benar-benar tersedia, skrip mengganti isi placeholder itu. Karena bentuk dasarnya sudah stabil, perubahan terasa lebih halus dan mengurangi layout shift.

4. Defer update yang memang harus datang dari klien

Beberapa state memang hanya bisa diketahui browser, misalnya preferensi tema yang disimpan di localStorage atau timezone lokal. Untuk kasus seperti ini, jangan memaksa server menebak terlalu agresif. Lebih aman melakukan update terkontrol setelah mount.

Contoh: teks waktu relatif.

<time class="js-relative-time" datetime="">
    
</time>
document.querySelectorAll('.js-relative-time').forEach((el) => {
  const iso = el.getAttribute('datetime');
  if (!iso) return;

  const date = new Date(iso);
  if (Number.isNaN(date.getTime())) return;

  // Update setelah halaman tampil, bukan sebagai sumber render awal
  requestAnimationFrame(() => {
    el.textContent = formatRelativeTime(date);
  });
});

function formatRelativeTime(date) {
  const diffMs = Date.now() - date.getTime();
  const diffMin = Math.floor(diffMs / 60000);

  if (diffMin < 1) return 'Baru saja';
  if (diffMin === 1) return '1 menit lalu';
  return diffMin + ' menit lalu';
}

Pendekatan ini menjaga HTML awal tetap valid dan informatif untuk SEO maupun fallback non-JS, lalu memperbaiki presentasi setelah browser siap.

Contoh Alur Praktis: View CodeIgniter 4 + Script Frontend

Berikut alur sederhana yang relatif aman untuk mencegah render awal berbeda:

Controller

<?php

namespace App\Controllers;

class Profile extends BaseController
{
    public function show()
    {
        $themeFromCookie = $this->request->getCookie('theme') ?: 'light';

        $user = [
            'name' => 'Budi',
            'avatarUrl' => null,
        ];

        $initialState = [
            'theme' => $themeFromCookie,
            'user' => $user,
            'profileLoaded' => false,
        ];

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

View

<div id="profile-app" data-theme="">
    <section class="profile-header">
        <div class="avatar">
            
                <img src="" alt="Avatar">
            
                <div class="avatar-placeholder" aria-hidden="true"></div>
            
        </div>

        <div class="user-name">
            
        </div>
    </section>

    <section id="profile-extra" data-ready="false">
        <p class="placeholder">Memuat detail profil...</p>
    </section>
</div>

<script>
window.__INITIAL_STATE__ = ;
</script>
<script src="/assets/js/profile.js" defer></script>

Script frontend

(async function () {
  const state = window.__INITIAL_STATE__ || {};
  const app = document.getElementById('profile-app');
  const extra = document.getElementById('profile-extra');

  if (!app || !extra) return;

  applyTheme(state.theme || 'light');

  // Sinkronisasi browser-only setelah render awal
  try {
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme && savedTheme !== state.theme) {
      applyTheme(savedTheme);
    }
  } catch (e) {
    console.warn('localStorage tidak tersedia:', e);
  }

  // Data user tambahan datang belakangan: gunakan placeholder stabil
  try {
    const response = await fetch('/api/profile-extra', {
      headers: { 'Accept': 'application/json' }
    });

    if (!response.ok) throw new Error('Gagal mengambil profile extra');

    const data = await response.json();

    extra.innerHTML = '';

    const bio = document.createElement('p');
    bio.textContent = data.bio || 'Bio belum tersedia';

    extra.appendChild(bio);
    extra.setAttribute('data-ready', 'true');
  } catch (e) {
    extra.innerHTML = '<p>Detail profil gagal dimuat.</p>';
    console.error(e);
  }

  function applyTheme(theme) {
    app.setAttribute('data-theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }
})();

Poin penting dari alur ini:

  • Theme awal diambil server dari cookie bila memungkinkan, sehingga HTML awal lebih dekat dengan kondisi nyata.
  • localStorage hanya dipakai sebagai koreksi setelah halaman siap, bukan sebagai dasar render SSR.
  • Data tambahan user tidak dipalsukan saat SSR; sebagai gantinya dipakai placeholder stabil.

Panduan Per Kasus

Jika state berasal dari cookie

Selama cookie dikirim bersama request dan aman dipakai di server, lebih baik render SSR berdasarkan cookie tersebut. Ini cocok untuk:

  • tema terang/gelap,
  • bahasa yang dipilih user,
  • preferensi layout sederhana.

Keuntungannya: state awal server dan klien lebih mudah sinkron. Kekurangannya: perubahan cookie di klien setelah request tidak akan memengaruhi HTML yang sudah terlanjur dirender.

Jika state berasal dari localStorage

Server tidak bisa mengaksesnya. Pilih salah satu:

  • migrasikan preferensi penting ke cookie agar bisa dibaca server, atau
  • terima bahwa state final hanya diketahui klien, lalu gunakan placeholder atau update tertunda.

Untuk hal yang sangat memengaruhi visual seperti tema, cookie biasanya lebih cocok daripada hanya localStorage.

Jika elemen berbasis waktu

Simpan nilai waktu absolut pada HTML, misalnya ISO timestamp, lalu format relatif di klien setelah mount. Jangan gunakan string relatif seperti “baru saja” sebagai sumber kebenaran SSR kecuali Anda siap menerima perubahan cepat.

Jika ada random value

Hindari membuat nilai acak saat render awal untuk atribut yang memengaruhi DOM atau class. Jika butuh ID unik, usahakan menggunakan nilai deterministik dari data yang sudah ada, bukan angka acak saat render.

Jika data user datang terlambat

Jangan tampilkan data tebakan. Gunakan skeleton, placeholder, atau blok yang ukurannya stabil. Tujuannya agar transisi dari “belum ada data” ke “data tersedia” tidak terasa seperti UI salah render.

Checklist Debugging Saat UI Masih Flicker

  1. Bandingkan HTML awal dengan DOM setelah JavaScript berjalan.
    Lihat apakah class, teks, atribut, atau urutan elemen berubah segera setelah load.
  2. Cari semua nilai default di dua sisi.
    Periksa apakah PHP di view/controller memakai default berbeda dari JavaScript frontend.
  3. Audit pembacaan API browser.
    Cek penggunaan localStorage, sessionStorage, window.matchMedia, ukuran layar, timezone, atau navigator.
  4. Cari nilai non-deterministik.
    Telusuri penggunaan waktu saat ini, angka acak, atau format tanggal yang bergantung locale browser.
  5. Periksa data async yang telat.
    Identifikasi bagian UI yang langsung diganti setelah respons API masuk.
  6. Log state awal secara eksplisit.
    Tambahkan log untuk state dari server dan state pertama di browser, lalu cocokkan isinya.
  7. Uji koneksi lambat.
    Dengan jaringan lambat, flicker dan placeholder yang buruk biasanya lebih mudah terlihat.
  8. Pastikan serialisasi JSON aman dan valid.
    Bug encoding atau karakter khusus dapat membuat state awal rusak di browser.

Kesalahan yang Sering Terjadi

  • Menggunakan default berbeda antara PHP dan JavaScript tanpa sadar.
  • Merender teks waktu relatif di server lalu menghitung ulang di klien beberapa saat kemudian.
  • Menganggap localStorage setara dengan cookie dalam konteks SSR.
  • Memasukkan terlalu banyak data sensitif ke state awal yang diserialisasi.
  • Mengganti placeholder dengan elemen yang ukurannya jauh berbeda, sehingga layout bergeser.
  • Menjalankan update klien terlalu dini sebelum UI awal stabil.

Trade-off UX dan SEO

Keuntungan sinkronisasi state SSR

  • halaman terasa lebih stabil,
  • lebih sedikit perubahan visual setelah load,
  • pengalaman pengguna lebih konsisten,
  • markup awal lebih representatif terhadap konten sebenarnya.

Trade-off yang perlu dipahami

UX: Menunda update klien sampai mount dapat mengurangi flicker, tetapi berarti sebagian personalisasi muncul sedikit lebih lambat. Ini sering lebih baik daripada menampilkan state yang salah lalu menggantinya mendadak.

SEO: SSR membantu mesin pencari melihat konten awal. Namun untuk data yang memang hanya tersedia di browser atau datang dari API privat, Anda mungkin harus memilih placeholder yang netral. Ini baik untuk stabilitas UI, tetapi berarti tidak semua personalisasi muncul dalam HTML awal.

Kompleksitas: Menjaga satu sumber state awal menambah disiplin arsitektur. Anda perlu memastikan controller, view, dan skrip frontend memakai kontrak data yang sama.

Prinsip praktis: jika sebuah nilai penting untuk render pertama, usahakan server dapat mengetahuinya. Jika server tidak bisa mengetahuinya secara andal, jangan pura-pura tahu; tampilkan placeholder stabil lalu perbarui di klien.

Penutup

Untuk mencegah flicker dan state hydration yang tidak sinkron di CodeIgniter 4, fokus utama Anda adalah memastikan HTML hasil SSR dan state awal JavaScript berasal dari sumber yang sama. Penyebab umum hampir selalu berputar pada default yang berbeda, pembacaan data browser terlalu dini, nilai berbasis waktu atau random, serta data user yang datang terlambat.

Pola yang paling aman adalah:

  • serialisasikan state awal dari server,
  • beri guard pada kode browser-only,
  • pakai placeholder yang stabil,
  • defer update klien yang tidak bisa diketahui server.

Dengan pendekatan ini, aplikasi SSR berbasis CodeIgniter 4 akan terasa lebih stabil, lebih mudah di-debug, dan lebih ramah bagi pengguna maupun mesin pencari.