Pada aplikasi Rust Leptos SSR, bug hydration mismatch sering muncul ketika state awal komponen langsung membaca localStorage. Gejalanya biasanya jelas: muncul warning hydration di console, UI sempat berkedip, dan nilai yang dirender server berbeda dari nilai awal di browser.

Solusi yang paling aman adalah membuat render pertama deterministik. Artinya, HTML yang dihasilkan server harus menggunakan fallback state yang stabil dan tidak bergantung pada API browser. Setelah hydration selesai di client, barulah aplikasi membaca localStorage dan menyinkronkan state.

Masalah Utama: SSR dan Browser Tidak Punya Sumber State yang Sama

Dalam mode SSR, komponen Leptos dirender dua kali dalam konteks yang berbeda:

  • Di server, untuk menghasilkan HTML awal.
  • Di browser, saat hydration menghubungkan HTML server dengan reactive state dan event handler.

Masalah muncul karena localStorage hanya tersedia di browser. Jika nilai awal signal ditentukan dari localStorage, maka:

  • Server tidak bisa membaca nilainya, sehingga memakai default tertentu.
  • Browser bisa membaca nilainya, sehingga menghasilkan initial state yang berbeda.

Perbedaan ini membuat markup hasil server tidak identik dengan ekspektasi client saat hydration. Akibatnya, framework harus menyesuaikan DOM, memunculkan warning, atau memicu flicker.

Gejala yang Umum Terjadi

  • Warning hydration mismatch di console browser.
  • Teks, tema, atau preferensi UI berubah sesaat setelah halaman tampil.
  • Nilai form, toggle, atau status panel berbeda antara render awal dan hasil akhir setelah hydration.
  • Komponen tertentu terlihat dirender ulang tanpa alasan yang jelas.

Contoh Anti-Pattern: Membaca localStorage Saat Inisialisasi State

Berikut pola yang sering terlihat, tetapi bermasalah pada SSR:

use leptos::*;
use web_sys::window;

#[component]
pub fn ThemeToggle() -> impl IntoView {
    let initial_theme = window()
        .and_then(|w| w.local_storage().ok().flatten())
        .and_then(|storage| storage.get_item("theme").ok().flatten())
        .unwrap_or_else(|| "light".to_string());

    let (theme, set_theme) = create_signal(initial_theme);

    view! {
        <button on:click=move |_| {
            let next = if theme.get() == "light" { "dark" } else { "light" };
            set_theme.set(next.to_string());
        }>
            {move || format!("Theme: {}", theme.get())}
        </button>
    }
}

Sekilas terlihat aman karena ada fallback ke "light". Namun secara arsitektur ini tetap keliru untuk SSR:

  • Inisialisasi state mencoba mengambil sumber data browser terlalu awal.
  • Di server, nilai hampir pasti jatuh ke fallback.
  • Di browser, nilai bisa langsung menjadi "dark" sebelum hydration selesai.
  • Akibatnya, DOM awal server dan state client tidak sinkron.

Catatan: Masalah utamanya bukan hanya apakah kode “berjalan” atau tidak, tetapi apakah initial render di server dan client menghasilkan output yang identik.

Pola Perbaikan: Fallback Stabil di SSR, Baca localStorage Setelah Hydration

Pendekatan yang lebih benar adalah:

  1. Tetapkan default state yang stabil dan bisa dipakai baik di server maupun client.
  2. Render HTML awal berdasarkan default tersebut.
  3. Setelah komponen aktif di browser, baca localStorage.
  4. Jika ada nilai tersimpan, sinkronkan signal.
  5. Saat state berubah di client, simpan kembali ke localStorage.

Dengan pola ini, render pertama tetap konsisten, lalu perubahan terjadi sebagai update reaktif biasa setelah hydration.

Contoh Perbaikan

use leptos::*;
use web_sys::window;

#[component]
pub fn ThemeToggle() -> impl IntoView {
    // Fallback stabil untuk SSR dan client initial render
    let (theme, set_theme) = create_signal("light".to_string());

    // Baca localStorage hanya di client setelah mount/hydration
    on_mount({
        let set_theme = set_theme.clone();
        move || {
            if let Some(storage) = window()
                .and_then(|w| w.local_storage().ok().flatten())
            {
                if let Ok(Some(saved)) = storage.get_item("theme") {
                    if saved == "light" || saved == "dark" {
                        set_theme.set(saved);
                    }
                }
            }
        }
    });

    // Sinkronkan perubahan state ke localStorage hanya di client
    create_effect(move |_| {
        let current = theme.get();
        if let Some(storage) = window()
            .and_then(|w| w.local_storage().ok().flatten())
        {
            let _ = storage.set_item("theme", &current);
        }
    });

    view! {
        <button on:click=move |_| {
            let next = if theme.get() == "light" { "dark" } else { "light" };
            set_theme.set(next.to_string());
        }>
            {move || format!("Theme: {}", theme.get())}
        </button>
    }
}

Mengapa pola ini bekerja?

  • Server dan client sama-sama mulai dari nilai "light", sehingga markup awal konsisten.
  • on_mount hanya berjalan di browser, jadi akses ke localStorage tidak memengaruhi SSR.
  • Jika ada nilai tersimpan, perubahan terjadi setelah hydration sebagai update biasa, bukan sebagai konflik antara dua initial render.

Kapan UI Masih Bisa Berkedip?

Meskipun hydration mismatch hilang, UI masih bisa berubah sesaat setelah mount jika nilai di localStorage berbeda dari fallback. Ini bukan mismatch, tetapi state reconciliation setelah hydration.

Contohnya, server merender tema light, lalu browser membaca dark dari localStorage dan memperbarui UI. Secara teknis ini aman, tetapi pengguna bisa melihat perubahan singkat.

Cara Mengurangi Flicker

  • Pilih fallback yang paling masuk akal dan paling sering dipakai.
  • Untuk kasus seperti tema, pertimbangkan menyelaraskan sumber state awal dari sisi server, misalnya lewat cookie yang ikut terbaca saat SSR.
  • Jangan menunda seluruh render hanya demi membaca localStorage; biasanya lebih baik menerima update kecil setelah mount daripada merusak determinisme SSR.

Jika kebutuhan Anda adalah tanpa flicker sama sekali, maka localStorage saja sering tidak cukup sebagai sumber kebenaran awal pada SSR. Anda memerlukan state yang juga tersedia di server, misalnya cookie atau data request.

Contoh Kasus yang Sering Bermasalah

1. Tema gelap/terang

Kasus paling umum. Jika kelas tema langsung ditentukan dari localStorage saat inisialisasi komponen, hasil SSR dan hydration mudah berbeda.

2. Status panel terbuka/tertutup

Misalnya sidebar collapsed yang disimpan di browser. Jika server merender sidebar terbuka tetapi client langsung menganggap tertutup, struktur atau class DOM bisa berubah saat hydration.

3. Nilai filter atau tab aktif

Jika tab aktif diambil dari localStorage terlalu awal, konten yang dirender server bisa berbeda dari tab yang diharapkan browser.

Anti-Pattern yang Perlu Dihindari

Mengakses API browser di luar lifecycle client

Hindari membaca window, document, atau localStorage dalam logika inisialisasi yang ikut dipakai SSR. Walaupun diberi guard atau fallback, hasil akhirnya tetap bisa tidak deterministik.

Membuat fallback yang berbeda antara server dan client

Jangan gunakan logika default yang diam-diam berbeda. Misalnya server default ke light, tetapi client default ke preferensi tersimpan jika tersedia. Itu tetap menghasilkan initial state berbeda.

Menyisipkan logika penyimpanan yang memicu update terlalu dini

Jika effect penulisan ke localStorage berjalan sebelum nilai hasil pembacaan selesai diterapkan, Anda bisa menimpa nilai lama dengan fallback baru. Pastikan urutan sinkronisasi masuk akal.

Mengandalkan localStorage sebagai sumber state SSR

localStorage tidak tersedia di server. Jika state benar-benar memengaruhi tampilan awal yang sensitif terhadap flicker, pindahkan sumber state awal ke media yang bisa diakses saat request, seperti cookie.

Pola yang Lebih Aman untuk Sinkronisasi

Untuk komponen yang sedikit lebih kompleks, Anda bisa memisahkan tiga tanggung jawab:

  1. Initial deterministic state untuk SSR.
  2. Client-side hydration sync untuk membaca data browser.
  3. Persistence effect untuk menyimpan perubahan berikutnya.

Secara mental model, anggap localStorage sebagai persistence layer di client, bukan sebagai input utama untuk render server.

Contoh dengan validasi nilai

use leptos::*;
use web_sys::window;

fn read_saved_theme() -> Option<String> {
    let storage = window()?.local_storage().ok().flatten()?;
    let value = storage.get_item("theme").ok().flatten()?;

    match value.as_str() {
        "light" | "dark" => Some(value),
        _ => None,
    }
}

#[component]
pub fn ThemeToggle() -> impl IntoView {
    let (theme, set_theme) = create_signal("light".to_string());

    on_mount({
        let set_theme = set_theme.clone();
        move || {
            if let Some(saved) = read_saved_theme() {
                set_theme.set(saved);
            }
        }
    });

    create_effect(move |_| {
        let current = theme.get();
        if let Some(storage) = window().and_then(|w| w.local_storage().ok().flatten()) {
            let _ = storage.set_item("theme", &current);
        }
    });

    view! {
        <button on:click=move |_| {
            let next = if theme.get() == "light" { "dark" } else { "light" };
            set_theme.set(next.to_string());
        }>
            {move || format!("Theme: {}", theme.get())}
        </button>
    }
}

Keuntungan dari pola ini:

  • Logika baca terisolasi dan mudah diuji.
  • Nilai dari localStorage divalidasi sebelum masuk ke state.
  • Komponen lebih mudah dipelihara saat kebutuhan bertambah.

Debugging Hydration Mismatch di Leptos SSR

1. Bandingkan output server dan state awal client

Lihat HTML awal yang dikirim server, lalu periksa nilai signal saat browser mulai hydration. Jika keduanya berbeda sebelum interaksi pengguna, ada kemungkinan sumber state awal tidak deterministik.

2. Cari akses browser API di jalur render awal

Audit komponen untuk akses seperti:

  • window()
  • document()
  • local_storage()
  • sessionStorage
  • ukuran viewport atau media query yang dievaluasi terlalu awal

Jika dipakai untuk membentuk nilai awal view, itu kandidat kuat penyebab mismatch.

3. Log urutan eksekusi

Tambahkan logging sederhana untuk membedakan:

  • nilai fallback saat inisialisasi
  • nilai setelah on_mount
  • nilai saat effect persist berjalan

Ini membantu mendeteksi apakah nilai lama tertimpa oleh fallback, atau apakah sinkronisasi terjadi dalam urutan yang salah.

4. Uji dengan localStorage kosong dan terisi

Bug sering hanya muncul saat browser memiliki data lama. Uji minimal dua skenario:

  • localStorage kosong
  • localStorage berisi nilai berbeda dari fallback SSR

5. Perhatikan perubahan struktur DOM, bukan hanya teks

Mismatch lebih berisiko jika state mengubah struktur elemen, atribut penting, atau urutan node. Perubahan teks biasanya lebih mudah direkonsiliasi dibanding perubahan struktur besar.

Kapan Perlu Cookie, Bukan localStorage?

Jika state memengaruhi tampilan awal secara signifikan dan Anda ingin menghindari flicker, pertimbangkan memindahkan state awal ke cookie. Alasannya sederhana:

  • Cookie tersedia saat request server.
  • SSR bisa merender HTML awal dengan nilai yang sama seperti yang akan dipakai client.
  • Hydration menjadi lebih konsisten karena kedua sisi berbagi sumber data yang sama.

Pilih localStorage jika state hanya perlu dipulihkan setelah halaman hidup di browser dan sedikit perubahan visual setelah mount masih bisa diterima.

Pilih cookie jika state harus memengaruhi SSR pertama secara akurat, misalnya tema global atau layout awal yang tidak boleh berubah setelah halaman tampil.

Checklist Agar Render Pertama Deterministik

  • Gunakan default state yang sama di server dan client.
  • Jangan baca localStorage saat inisialisasi state untuk komponen SSR.
  • Baca data browser hanya di lifecycle client seperti on_mount.
  • Sinkronkan state setelah hydration, bukan sebelum.
  • Validasi nilai dari localStorage sebelum masuk ke signal.
  • Pastikan effect penyimpanan tidak menimpa nilai lama secara prematur.
  • Jika state harus akurat sejak SSR, gunakan sumber data yang tersedia di server, seperti cookie.

Penutup

Penyebab hydration mismatch pada Rust Leptos SSR dengan localStorage hampir selalu sama: server dan browser memulai render dari state yang berbeda. Perbaikannya bukan dengan menambah kondisi acak di inisialisasi, melainkan dengan memastikan render pertama tetap stabil dan deterministik.

Pola yang paling praktis adalah fallback state stabil saat SSR, baca localStorage hanya di client, lalu sinkronkan state setelah hydration. Dengan pendekatan ini, warning hydration berkurang, UI lebih konsisten, dan perilaku komponen lebih mudah dipahami saat di-debug.