State hydration drift di Next.js biasanya muncul ketika nilai awal komponen saat render di server berbeda dengan nilai awal saat komponen di-hydrate di browser. Kasus paling umum: komponen membaca localStorage, window, waktu saat ini, atau preferensi user sebelum React selesai menyamakan HTML server dengan state client.
Dampaknya nyata: muncul warning hydration, UI berkedip, konten berubah setelah mount, atau nilai seperti checkbox, theme, dan jumlah item cart terlihat tidak konsisten. Solusinya bukan sekadar menambahkan guard acak, tetapi memastikan render awal server dan client tetap stabil, lalu memindahkan pembacaan state browser-only ke fase yang aman.
Mengapa hydration drift terjadi
Pada render berbasis SSR, Next.js mengirim HTML dari server. Setelah halaman dimuat di browser, React melakukan hydration: ia menghubungkan event handler dan state ke HTML yang sudah ada. Proses ini mengasumsikan bahwa output render awal di client sama dengan HTML dari server.
Masalah muncul ketika komponen menghasilkan output berbeda di dua lingkungan:
- Server tidak punya akses ke
window,document, danlocalStorage. - Client punya akses ke data browser, preferensi user, waktu lokal, dan state yang berubah dari sesi sebelumnya.
Jika render awal client langsung membaca nilai yang tidak tersedia saat SSR, React melihat perbedaan markup. Inilah yang memicu warning seperti mismatch saat hydration, dan kadang diikuti perubahan UI setelah mount.
Gejala yang paling sering terlihat
- Warning hydration di console browser.
- Teks atau atribut berubah sesaat setelah halaman tampil.
- Checkbox awalnya tidak tercentang, lalu tiba-tiba menjadi tercentang.
- Theme default tampil terang, lalu berubah ke gelap setelah mount.
- Cart count awal 0 dari SSR, lalu berubah ke nilai dari
localStorage.
Contoh kode yang bermasalah
Berikut pola yang sering terlihat: membaca localStorage langsung di inisialisasi state.
import { useState } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState(
localStorage.getItem('theme') || 'light'
);
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Theme: {theme}
</button>
);
}Kode ini bermasalah karena:
- Di server,
localStoragetidak tersedia. - Walaupun diberi guard sederhana, render awal server mungkin menghasilkan
light, sedangkan client langsung membacadark. - Akibatnya, teks
Theme: lightdari SSR tidak cocok dengan render awal client.
Versi lain yang tampak aman tetapi tetap bisa menyebabkan drift:
import { useState } from 'react';
export default function CartBadge() {
const [count] = useState(() => {
if (typeof window === 'undefined') return 0;
return Number(localStorage.getItem('cart_count') || '0');
});
return <span>{count}</span>;
}Secara runtime ini tidak melempar error di server, tetapi output server dan client masih bisa berbeda. Server render 0, client render 3 pada hydration pertama. Guard semacam ini mencegah crash, bukan mencegah hydration drift.
Pola aman untuk mencegah state hydration drift di Next.js
1. Gunakan nilai awal yang stabil, lalu sinkronkan di useEffect
Pola paling aman adalah memastikan render awal sama di server dan client. Setelah komponen ter-mount di browser, baru baca localStorage atau API browser lain.
import { useEffect, useState } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('light');
const [ready, setReady] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') {
setTheme(saved);
}
setReady(true);
}, []);
useEffect(() => {
if (!ready) return;
window.localStorage.setItem('theme', theme);
}, [theme, ready]);
return (
<button
type="button"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
disabled={!ready}
>
Theme: {theme}
</button>
);
}Mengapa pola ini bekerja:
- SSR dan render awal client sama: keduanya mulai dari
light. - Pembacaan
localStoragehanya terjadi setelah mount, sehingga tidak mengganggu hydration. - Perubahan state setelah
useEffectadalah update normal React, bukan mismatch hydration.
Trade-off-nya: user bisa melihat nilai default sesaat sebelum nilai asli diterapkan. Ini aman secara teknis, tetapi bisa terasa seperti flicker jika state memengaruhi tampilan penting, misalnya theme.
2. Tampilkan placeholder yang stabil sampai state browser siap
Untuk menghindari UI yang tampak salah sesaat, tampilkan placeholder atau state netral sampai data client selesai dibaca.
import { useEffect, useState } from 'react';
export default function CartBadge() {
const [count, setCount] = useState(null);
useEffect(() => {
const value = Number(window.localStorage.getItem('cart_count') || '0');
setCount(value);
}, []);
return (
<span aria-live="polite">
{count === null ? '...' : count}
</span>
);
}Dengan pendekatan ini, server dan client sama-sama merender ... lebih dulu. Setelah mount, badge diperbarui ke nilai sebenarnya. Cocok jika menampilkan angka default seperti 0 justru menyesatkan user.
Untuk elemen yang memengaruhi layout, gunakan placeholder dengan ukuran tetap agar tidak terjadi layout shift.
3. Gunakan status mounted untuk komponen yang sangat bergantung pada browser state
Jika komponen sepenuhnya bergantung pada data browser dan tidak bermakna saat SSR, Anda bisa menunda render konten utamanya sampai komponen sudah ter-mount.
import { useEffect, useState } from 'react';
export default function RememberMeCheckbox() {
const [mounted, setMounted] = useState(false);
const [checked, setChecked] = useState(false);
useEffect(() => {
setMounted(true);
setChecked(window.localStorage.getItem('remember_me') === 'true');
}, []);
useEffect(() => {
if (!mounted) return;
window.localStorage.setItem('remember_me', String(checked));
}, [checked, mounted]);
if (!mounted) {
return <label><input type="checkbox" disabled /> Ingat saya</label>;
}
return (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
Ingat saya
</label>
);
}Pola ini membuat SSR tetap stabil, tetapi ada kompromi: interaktivitas tertunda sampai mount selesai.
4. Gunakan dynamic import tanpa SSR untuk widget yang memang client-only
Jika sebuah komponen tidak punya manfaat SEO dan hampir seluruh logikanya bergantung pada browser, Anda bisa memuatnya hanya di client.
import dynamic from 'next/dynamic';
const ClientOnlyCart = dynamic(() => import('./ClientOnlyCart'), {
ssr: false,
});
export default function Header() {
return (
<header>
<nav>...</nav>
<ClientOnlyCart />
</header>
);
}Pendekatan ini menghilangkan risiko mismatch karena komponen tidak dirender di server. Namun, ada trade-off penting:
- Tidak ada HTML SSR untuk komponen tersebut.
- Konten baru muncul setelah JavaScript client siap.
- Kurang ideal untuk elemen yang penting untuk SEO atau perceived performance.
Gunakan untuk widget seperti cart mini, preferensi panel, atau kontrol personalisasi yang memang tidak perlu dirender di server.
5. Bedakan antara guard API browser dan konsistensi render
Banyak developer berhenti pada pola ini:
if (typeof window !== 'undefined') {
// akses localStorage
}Guard ini perlu untuk menghindari error di server, tetapi tidak cukup untuk menjaga konsistensi hydration. Pertanyaan utamanya bukan hanya “apakah kode aman dijalankan?”, tetapi “apakah output render awal tetap sama antara server dan client?”.
Jika jawabannya tidak, Anda tetap berisiko mengalami drift.
Kasus khusus yang sering memicu drift
Theme berdasarkan preferensi user
Theme adalah contoh klasik. SSR mungkin mengirim mode terang, sementara browser menyimpan mode gelap. Jika tombol, class, atau label theme dirender dari state yang berbeda, user melihat flash perubahan tampilan.
Untuk kasus ini, ada dua pendekatan umum:
- Gunakan placeholder atau state netral lalu terapkan theme setelah mount.
- Simpan preferensi di cookie agar server juga mengetahui preferensi user dan bisa merender HTML yang konsisten sejak awal.
Jika Anda membutuhkan theme yang benar sejak first paint, menyimpan preferensi hanya di localStorage sering kurang cukup, karena server tidak bisa membacanya.
Cart count atau wishlist count
Jika jumlah item hanya ada di localStorage, SSR tidak akan tahu nilainya. Menampilkan 0 saat SSR lalu mengubahnya menjadi 4 setelah mount mungkin aman, tetapi bisa membingungkan user. Untuk data seperti ini, pertimbangkan:
- Placeholder netral.
- Client-only widget.
- Sinkronisasi cart ke backend atau cookie jika count penting ditampilkan akurat di SSR.
Nilai berbasis waktu atau locale
Hydration drift juga bisa terjadi jika Anda memanggil fungsi waktu saat render, misalnya new Date(), atau melakukan formatting yang berbeda antara server dan browser. Bahkan selisih detik atau timezone bisa menyebabkan perbedaan teks.
Jika nilai waktu harus tampil akurat per user, lebih aman:
- mengirim nilai waktu dari server sebagai props, atau
- menampilkannya setelah mount jika memang bergantung pada timezone browser.
Checklist debugging saat warning hydration muncul
Jika Anda melihat warning hydration atau UI berubah setelah mount, periksa daftar berikut:
- Apakah render awal membaca
window,document,localStorage,sessionStorage, ataumatchMedia? - Apakah ada state initializer yang menghasilkan nilai berbeda di server dan client?
- Apakah ada pemanggilan
new Date(),Math.random(), atau formatting locale langsung saat render? - Apakah atribut seperti
checked,selected,className, atau teks konten berubah segera setelah mount? - Apakah data user-specific hanya tersedia di browser, tetapi komponen tetap dipaksa SSR?
- Apakah komponen pihak ketiga mengakses DOM saat import atau render awal?
Teknik diagnosis yang praktis
- Tambahkan
console.logsementara untuk membandingkan nilai render awal di server dan client. - Uji halaman dalam mode production build, karena perilaku hydration sering lebih jelas daripada saat development.
- Isolasi komponen yang dicurigai, lalu ganti output-nya dengan placeholder statis. Jika warning hilang, sumber mismatch biasanya ada di komponen itu.
- Cari state yang bergantung pada browser tetapi diinisialisasi sebelum
useEffect.
Memilih antara SSR, CSR, atau hybrid
Kapan tetap menggunakan SSR
Pilih SSR jika konten penting untuk SEO, initial content, atau performa persepsi pengguna. Tetapi pastikan data yang dirender di server benar-benar tersedia di server juga. Jika preferensi user harus memengaruhi SSR, pertimbangkan penyimpanan yang bisa dibaca server, misalnya cookie atau data dari backend.
Kapan lebih baik client-side rendering
Pilih CSR untuk widget personal, state sesi lokal, atau komponen yang nilainya hanya relevan di browser. Ini mengurangi kompleksitas hydration, tetapi mengorbankan HTML awal dari server.
Kapan hybrid adalah pilihan terbaik
Sering kali solusi terbaik adalah hybrid:
- SSR untuk struktur halaman utama dan konten yang stabil.
- CSR atau client-only untuk bagian personal seperti theme switcher, cart badge, atau preferensi UI.
- Placeholder stabil agar layout tetap konsisten sebelum state client tersedia.
Pendekatan hybrid biasanya memberi kompromi paling masuk akal antara SEO, stabilitas hydration, dan UX.
Trade-off UX dan SEO yang perlu dipahami
- Defer ke
useEffect: aman untuk hydration, tetapi bisa menimbulkan flicker atau perubahan UI setelah mount. - Placeholder stabil: mencegah mismatch dan lebih jujur terhadap user, tetapi informasi aktual tertunda.
- No-SSR dynamic import: sederhana untuk komponen client-only, tetapi tidak memberi HTML awal dan bisa menunda interaktivitas.
- Menyimpan preferensi di cookie/backend: paling konsisten untuk SSR, tetapi menambah kompleksitas sinkronisasi dan arsitektur.
Tidak ada satu pola yang cocok untuk semua kasus. Kuncinya adalah menentukan apakah nilai tersebut perlu benar sejak server render, atau cukup akurat setelah client siap.
Kesalahan umum yang perlu dihindari
- Menganggap
typeof window !== 'undefined'otomatis menyelesaikan hydration mismatch. - Menginisialisasi state dari
localStoragediuseStatelalu heran karena UI berubah saat mount. - Menampilkan angka default seperti
0untuk data personal, padahal itu memberi kesan data benar padahal belum dimuat. - Menggunakan client-only rendering untuk komponen yang sebenarnya penting bagi SEO atau first paint.
- Mengabaikan warning hydration karena aplikasi “tetap jalan”, padahal mismatch bisa memicu perilaku UI yang sulit diprediksi.
Penutup
Untuk mencegah state hydration drift di Next.js dari localStorage, fokus utamanya adalah menjaga agar render awal server dan client menghasilkan markup yang sama. Jangan membaca sumber data browser-only dalam render awal jika nilainya bisa berbeda dari SSR.
Gunakan nilai awal yang stabil, sinkronkan state di useEffect, tampilkan placeholder bila perlu, dan pindahkan komponen yang benar-benar client-only ke dynamic import tanpa SSR. Jika state harus akurat sejak HTML pertama dikirim, pertimbangkan mekanisme yang juga bisa diakses server, bukan hanya localStorage.
Aturan praktis: jika suatu nilai hanya ada di browser, anggap nilai itu belum tersedia saat SSR. Dari situ, pilih apakah Anda akan menundanya sampai mount, menampilkan placeholder, atau memindahkan komponen menjadi client-only.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!