LocalStorage dan SSR sering menimbulkan masalah yang terlihat sepele tetapi membingungkan: UI sempat tampil dengan satu nilai, lalu berubah sesaat setelah halaman aktif di browser. Gejala ini biasanya muncul sebagai flicker, state yang berubah tiba-tiba, atau peringatan hydration mismatch karena HTML dari server berbeda dengan hasil render awal di client.

Penyebab utamanya sederhana: server tidak bisa membaca localStorage, tetapi browser bisa. Jika state awal komponen bergantung pada localStorage dan dibaca terlalu dini, maka render di server dan render awal di client akan menghasilkan output berbeda. Solusinya bukan sekadar menambah pengecekan window, tetapi memastikan render awal tetap stabil dan pembacaan storage dilakukan pada waktu yang tepat.

Kenapa UI bisa berkedip saat hydration?

Pada aplikasi SSR, alurnya umumnya seperti ini:

  1. Server merender HTML awal berdasarkan data yang tersedia di server.
  2. Browser menerima HTML tersebut dan langsung menampilkannya.
  3. JavaScript client berjalan, lalu framework melakukan hydration untuk menghubungkan HTML yang sudah ada dengan state dan event handler di browser.
  4. Jika state awal di client berbeda dari HTML server, UI bisa berubah atau hydration memunculkan warning.

Masalah muncul ketika nilai state awal ditentukan dari localStorage. Di server, localStorage tidak ada, sehingga biasanya komponen memakai nilai default. Di browser, localStorage ada, sehingga nilai state bisa berbeda. Hasilnya: server menghasilkan HTML A, client ingin menampilkan HTML B.

Contoh kasus umum:

  • Toggle tema: server merender mode light, client menemukan theme=dark di localStorage lalu mengganti tampilan.
  • Status sidebar: server menganggap sidebar terbuka, client membaca preferensi sidebar tertutup.
  • Tab aktif, filter, atau layout: HTML awal menampilkan default, lalu berubah setelah hydration.

Intinya, hydration mengharapkan output awal di client konsisten dengan HTML dari server. Jika localStorage mengubah output terlalu cepat, mismatch mudah terjadi.

Mengapa membaca localStorage terlalu dini memicu mismatch?

Kesalahan yang sering terjadi adalah mengakses localStorage saat inisialisasi state yang ikut menentukan markup awal.

// Contoh framework-agnostic secara konsep, pola ini bermasalah
const initialTheme = localStorage.getItem('theme') || 'light';
renderApp({ theme: initialTheme });

Kode di atas mungkin tampak wajar di aplikasi client-only, tetapi bermasalah pada SSR karena:

  • Di server, localStorage tidak tersedia.
  • Jika diberi fallback, server tetap merender dengan nilai default.
  • Di client, sebelum atau saat hydration, nilai dari storage bisa berbeda.
  • Perbedaan itu mengubah class, teks, atribut, atau struktur elemen.

Bahkan jika Anda menulis guard seperti ini:

const initialTheme = typeof window !== 'undefined'
  ? localStorage.getItem('theme') || 'light'
  : 'light';

Masalah konsistensi render belum tentu selesai. Guard tersebut memang mencegah error di server, tetapi tetap memungkinkan server merender light sementara client langsung ingin dark. Secara fungsional aman, tetapi secara hydration masih berpotensi memicu flicker atau mismatch.

Pola aman: state default stabil, baca storage setelah mounted

Pola yang paling aman untuk banyak kasus adalah:

  1. Tentukan default state yang stabil untuk render server.
  2. Gunakan nilai default itu juga pada render awal di client.
  3. Setelah komponen benar-benar berjalan di browser, baca localStorage di mounted, useEffect, atau onMount.
  4. Perbarui state setelah hydration selesai.

Secara konsep:

// Framework-agnostic pseudo-code
state = {
  theme: 'light',
  hydrated: false
}

onClientMounted(() => {
  const saved = localStorage.getItem('theme');
  if (saved === 'light' || saved === 'dark') {
    state.theme = saved;
  }
  state.hydrated = true;
});

Mengapa pola ini bekerja?

  • Server dan client sama-sama memulai dari nilai yang identik.
  • Hydration berlangsung terhadap markup yang konsisten.
  • Perubahan state terjadi setelah komponen aktif di browser, bukan saat framework masih mencoba mencocokkan HTML awal.

Kelemahannya: pengguna mungkin melihat perubahan sesaat setelah mount. Jadi pola ini mengorbankan sedikit kenyamanan visual demi konsistensi render.

Tambahkan placeholder atau skeleton bila perlu

Jika state dari localStorage sangat memengaruhi tampilan, lebih baik menunda render bagian tertentu sampai hydration selesai.

// Pseudo-code
if (!state.hydrated) {
  renderPlaceholder();
} else {
  renderRealUI(state.theme);
}

Pendekatan ini berguna jika perubahan setelah mount akan terlalu mencolok, misalnya:

  • layout sidebar yang berubah lebar secara drastis,
  • komponen dashboard yang bergantung pada preferensi tampilan,
  • konten yang berubah teks atau urutannya.

Trade-off-nya jelas: Anda mengurangi flicker mismatch, tetapi menambah fase placeholder singkat.

Gunakan guard browser-only dengan benar

Browser-only guard tetap penting, tetapi fungsinya adalah mencegah akses API browser di server, bukan menjamin konsistensi HTML.

function readStoredTheme() {
  if (typeof window === 'undefined') return null;
  try {
    return window.localStorage.getItem('theme');
  } catch {
    return null;
  }
}

Gunakan guard seperti ini di dalam lifecycle client-side, bukan sebagai satu-satunya solusi untuk inisialisasi state SSR.

Contoh implementasi praktis yang aman

Pola dasar framework-agnostic

// Tujuan: render awal konsisten, lalu sinkronkan dari localStorage di client
const DEFAULT_THEME = 'light';

let state = {
  theme: DEFAULT_THEME,
  hydrated: false
};

function mount() {
  state.hydrated = true;

  try {
    const saved = window.localStorage.getItem('theme');
    if (saved === 'light' || saved === 'dark') {
      state.theme = saved;
      applyTheme(saved);
    } else {
      applyTheme(DEFAULT_THEME);
    }
  } catch {
    applyTheme(DEFAULT_THEME);
  }
}

function onThemeChange(nextTheme) {
  state.theme = nextTheme;
  applyTheme(nextTheme);

  try {
    window.localStorage.setItem('theme', nextTheme);
  } catch {
    // abaikan jika storage tidak tersedia
  }
}

Hal penting dari pola di atas:

  • DEFAULT_THEME dipakai sebagai sumber kebenaran render awal.
  • Pembacaan storage dilakukan setelah komponen aktif di browser.
  • Penulisan ke storage dilakukan hanya saat ada interaksi atau sinkronisasi state.
  • Akses storage dibungkus try/catch karena mode privasi atau kebijakan browser bisa melempar error.

Kapan placeholder lebih baik daripada langsung render?

Gunakan placeholder jika perubahan state memodifikasi struktur besar atau memengaruhi persepsi stabilitas halaman. Misalnya, jika menu navigasi, grid produk, atau preferensi density tabel berubah total setelah mount, menampilkan skeleton singkat sering terasa lebih rapi daripada menampilkan versi default lalu mengubahnya.

Variasi ringkas di framework populer

Next.js / React

Di React, pembacaan localStorage yang aman untuk SSR umumnya dilakukan di useEffect karena hook ini hanya berjalan di client setelah render awal.

import { useEffect, useState } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState('light');
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    setHydrated(true);
    try {
      const saved = window.localStorage.getItem('theme');
      if (saved === 'light' || saved === 'dark') {
        setTheme(saved);
      }
    } catch {}
  }, []);

  useEffect(() => {
    if (!hydrated) return;
    document.documentElement.dataset.theme = theme;
    try {
      window.localStorage.setItem('theme', theme);
    } catch {}
  }, [theme, hydrated]);

  if (!hydrated) {
    return <button disabled>Memuat preferensi...</button>;
  }

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Tema: {theme}
    </button>
  );
}

Catatan praktis:

  • Jangan membaca localStorage langsung di body komponen untuk menentukan markup SSR.
  • Jika bagian tertentu benar-benar client-only, pertimbangkan pemisahan komponen khusus client. Namun, ini tetap trade-off karena Anda menunda render bagian tersebut.
  • Untuk tema global, pendekatan berbasis cookie atau inline script yang sangat awal kadang lebih konsisten daripada hanya mengandalkan localStorage.

Nuxt / Vue

Di ekosistem Vue/Nuxt, pola yang setara adalah memakai lifecycle yang hanya berjalan di client, seperti onMounted.

<script setup>
import { ref, onMounted, watch } from 'vue';

const theme = ref('light');
const hydrated = ref(false);

onMounted(() => {
  hydrated.value = true;
  try {
    const saved = localStorage.getItem('theme');
    if (saved === 'light' || saved === 'dark') {
      theme.value = saved;
    }
  } catch {}
});

watch(theme, (value) => {
  if (!hydrated.value) return;
  document.documentElement.dataset.theme = value;
  try {
    localStorage.setItem('theme', value);
  } catch {}
});
</script>

<template>
  <button v-if="hydrated" @click="theme = theme === 'light' ? 'dark' : 'light'">
    Tema: {{ theme }}
  </button>
  <button v-else disabled>Memuat preferensi...</button>
</template>

Jika Anda perlu membatasi render hanya di browser, pola client-only bisa dipakai untuk komponen tertentu. Namun gunakan seperlunya, karena terlalu banyak komponen client-only bisa mengurangi manfaat SSR.

SvelteKit

Di SvelteKit, gunakan onMount untuk akses localStorage dan, bila perlu, cek environment browser.

<script>
  import { onMount } from 'svelte';

  let theme = 'light';
  let hydrated = false;

  onMount(() => {
    hydrated = true;
    try {
      const saved = localStorage.getItem('theme');
      if (saved === 'light' || saved === 'dark') {
        theme = saved;
      }
      document.documentElement.dataset.theme = theme;
    } catch {}
  });

  function toggleTheme() {
    theme = theme === 'light' ? 'dark' : 'light';
    document.documentElement.dataset.theme = theme;
    try {
      localStorage.setItem('theme', theme);
    } catch {}
  }
</script>

{#if hydrated}
  <button on:click={toggleTheme}>Tema: {theme}</button>
{:else}
  <button disabled>Memuat preferensi...</button>
{/if}

Sama seperti framework lain, fokus utamanya adalah menjaga output SSR tetap stabil dan memindahkan akses storage ke tahap client.

Opsi persist state yang lebih konsisten daripada localStorage murni

LocalStorage nyaman untuk preferensi sederhana, tetapi tidak selalu ideal untuk SSR karena server tidak bisa membacanya. Jika preferensi tersebut memengaruhi HTML awal secara signifikan, pertimbangkan opsi berikut.

1. Cookie untuk state yang perlu diketahui server

Jika server perlu merender tema, locale, layout, atau preferensi penting secara benar sejak awal, cookie sering lebih cocok karena dapat dibaca di request server. Dengan begitu, server bisa merender HTML yang sudah sesuai preferensi pengguna sebelum hydration terjadi.

Kapan memilih cookie:

  • tema harus benar sejak first paint,
  • layout awal perlu konsisten di semua halaman,
  • preferensi harus tersedia saat SSR, middleware, atau edge rendering.

Kekurangannya:

  • ukuran data sebaiknya kecil,
  • harus mempertimbangkan scope, expiry, dan keamanan,
  • sinkronisasi antara cookie dan state client perlu disiplin.

2. State dari server atau profil pengguna

Untuk aplikasi yang punya autentikasi, preferensi UI sering lebih baik disimpan di backend atau profil pengguna. Server lalu menyisipkan preferensi itu ke SSR payload atau data awal halaman. Pendekatan ini lebih konsisten lintas perangkat dan tidak bergantung pada storage per-browser.

Kapan memilih ini:

  • preferensi perlu sinkron lintas device,
  • state penting untuk pengalaman aplikasi,
  • Anda ingin satu sumber kebenaran yang lebih stabil.

3. Inline script awal untuk kasus tema visual

Untuk kasus tema gelap/terang, sebagian tim memakai script kecil di dokumen awal agar class tema diterapkan sebelum aplikasi ter-hydrate. Ini bisa mengurangi flicker visual. Namun, pendekatan ini harus diterapkan hati-hati agar tidak bertentangan dengan output SSR dan tetap mudah dipelihara.

Pendekatan ini cocok jika:

  • masalah utama adalah kilatan warna atau CSS tema,
  • perubahan tidak banyak mengubah struktur HTML,
  • Anda butuh hasil visual cepat sebelum framework aktif.

Tetap ingat: ini lebih merupakan optimasi presentasi, bukan pengganti desain state SSR yang konsisten.

Kesalahan umum yang sering menyebabkan perilaku membingungkan

  • Membaca localStorage saat inisialisasi state SSR. Ini sumber mismatch yang paling sering.
  • Menganggap guard typeof window sudah cukup. Guard mencegah error, tetapi tidak otomatis mencegah HTML berbeda.
  • Mengubah struktur DOM setelah mount tanpa placeholder. Misalnya daftar item, layout grid, atau navigasi besar langsung berubah total.
  • Tidak memvalidasi nilai dari storage. Data lama, format salah, atau value tak terduga bisa membuat UI masuk state yang tidak valid.
  • Tidak menangani error storage. Di beberapa kondisi, akses storage bisa gagal.
  • Menggabungkan beberapa sumber state tanpa prioritas jelas. Misalnya cookie mengatakan dark, localStorage mengatakan light, default aplikasi light, lalu hasil akhir tidak konsisten.

Checklist debugging saat UI berkedip atau hydration mismatch

  1. Bandingkan HTML server dan render awal client. Tanyakan: apakah teks, class, atribut, atau urutan elemen berubah karena localStorage?
  2. Cari akses storage di tempat yang terlalu awal. Periksa inisialisasi state, computed awal, body komponen, atau module scope.
  3. Pastikan nilai default benar-benar sama di server dan client. Jangan gunakan default berbeda berdasarkan environment.
  4. Tambahkan flag hydration. Gunakan hydrated atau status serupa untuk mengontrol kapan UI sensitif boleh dirender.
  5. Validasi isi localStorage. Pastikan hanya nilai yang diharapkan yang dipakai.
  6. Periksa warning hydration di console. Biasanya warning menunjukkan elemen atau atribut mana yang tidak cocok.
  7. Uji dengan storage kosong dan storage berisi nilai lama. Bug sering hanya muncul pada pengguna yang sudah pernah menyimpan preferensi.
  8. Uji hard refresh dan navigasi antar halaman. Perilaku initial load dan client-side navigation bisa berbeda.
  9. Pertimbangkan memindahkan state ke cookie atau server jika pengaruhnya terhadap SSR terlalu besar.

Trade-off UX vs konsistensi render

Tidak ada satu solusi yang selalu paling baik. Anda perlu memilih berdasarkan prioritas aplikasi.

Pilih default stabil + baca saat mounted jika:

  • preferensi tidak kritis untuk first paint,
  • Anda ingin solusi paling aman terhadap mismatch SSR,
  • perubahan setelah mount masih dapat diterima.

Pilih placeholder/skeleton jika:

  • perubahan dari state persisted sangat mencolok,
  • Anda ingin menghindari UI tampil salah lalu berubah,
  • pengguna lebih nyaman melihat loading singkat daripada flicker.

Pilih cookie atau state server jika:

  • server harus tahu state sebelum merender HTML,
  • tema atau layout harus benar sejak awal,
  • konsistensi lebih penting daripada implementasi paling sederhana.

Secara praktis, localStorage cocok untuk persistensi client-side ringan, tetapi bukan sumber state ideal untuk menentukan output SSR pertama. Jika state tersebut memengaruhi HTML awal, pikirkan desain data sejak awal: apakah state ini cukup dibaca setelah mount, atau seharusnya tersedia juga untuk server?

Penutup

Masalah LocalStorage dan SSR bukan sekadar soal API browser yang tidak tersedia di server. Akar masalahnya adalah ketidaksamaan render awal antara server dan client. Saat state awal bergantung pada localStorage dan dibaca terlalu dini, hydration dapat memunculkan UI berkedip, nilai state berubah tiba-tiba, atau warning mismatch.

Pola paling aman adalah menjaga default state tetap stabil saat SSR, lalu membaca localStorage setelah komponen ter-mount di browser. Jika perubahan visualnya besar, gunakan placeholder atau skeleton. Jika state harus diketahui sejak awal oleh server, pertimbangkan cookie atau persistensi di backend. Dengan pendekatan ini, Anda bisa menyeimbangkan UX, konsistensi render, dan kompleksitas implementasi dengan lebih sadar.