Flicker UI saat hydration di Next.js App Router biasanya terlihat sebagai konten yang berubah sesaat setelah halaman dimuat: tema terang tiba-tiba menjadi gelap, teks login berubah setelah state client terbaca, atau layout meloncat ketika ukuran viewport diketahui browser. Akar masalahnya hampir selalu sama: HTML yang dikirim server tidak sama dengan output render awal di client.
Di App Router, ini sering lebih membingungkan karena komponen server dan client hidup berdampingan. Masalahnya bukan selalu error hydration yang fatal. Banyak kasus justru lolos tanpa crash, tetapi UI berkedip, berpindah, atau mengganti isi setelah JavaScript aktif. Artikel ini fokus pada cara mendiagnosis gejala tersebut dan memperbaikinya secara bertahap dengan pola yang aman.
Memahami akar masalah: SSR vs hydration
Pada render awal, server menghasilkan HTML berdasarkan data yang tersedia di server. Setelah itu, React di browser melakukan hydration: ia memasang event handler dan mencocokkan tree React dengan HTML yang sudah ada. Jika render awal di client menghasilkan output berbeda, React harus menyesuaikan DOM. Penyesuaian inilah yang sering terlihat sebagai flicker.
Di Next.js App Router, pembagian tanggung jawab penting:
- Server Components dirender di server dan ideal untuk data yang stabil saat request berlangsung.
- Client Components dapat memakai state, effect, event handler, dan API browser seperti
windowataulocalStorage.
Masalah muncul ketika nilai yang hanya tersedia di browser memengaruhi markup awal, misalnya:
- tema dari
localStorage - status autentikasi yang baru diketahui di client
- ukuran viewport dari
window.innerWidth - waktu lokal pengguna atau hasil
Date.now() - informasi browser seperti timezone, media query, atau preferensi warna
Jika server menebak satu nilai, lalu client menghitung nilai lain saat hydration, UI akan berubah sesaat setelah load.
Gejala umum flicker UI saat hydration
1. Konten berubah sesaat setelah halaman tampil
Contoh paling umum:
- placeholder “Login” berubah menjadi nama pengguna
- tema terang muncul dulu, lalu berganti gelap
- harga, tanggal, atau jam berubah format setelah hydration
2. Layout shift kecil tanpa error fatal
Terkadang tidak ada pesan error besar di console, tetapi elemen pindah posisi karena class, ukuran, atau konten berubah setelah client mount. Ini sering dianggap masalah CSS, padahal sumbernya adalah mismatch render.
3. Warning hydration yang muncul sesekali
Warning seperti text content did not match atau peringatan bahwa atribut berbeda antara server dan client bisa muncul, tetapi tidak selalu. Mismatch ringan bisa menyebabkan UI berkedip walau aplikasi tetap berjalan.
Anti-pattern yang paling sering memicu mismatch halus
Mengakses API browser saat render
Ini sumber masalah klasik. Komponen client memang berjalan di browser, tetapi ia juga ikut menghasilkan markup awal yang perlu konsisten dengan server.
'use client'
export default function ThemeLabel() {
const theme = localStorage.getItem('theme') || 'light'
return <span>Tema: {theme}</span>
}Kode di atas bermasalah karena localStorage tidak tersedia di server, dan bahkan jika dibungkus kondisi tertentu, output awal dapat berbeda antara server dan client.
Menggunakan nilai waktu atau angka acak langsung di render
export default function Greeting() {
const currentHour = new Date().getHours()
const text = currentHour < 12 ? 'Pagi' : 'Sore'
return <p>Selamat {text}</p>
}Jam di server bisa berbeda dari client, apalagi jika timezone berbeda. Hasilnya: teks awal berubah saat hydration.
export default function IdBadge() {
const id = Math.random().toString(36).slice(2, 7)
return <span>ID: {id}</span>
}Nilai acak saat render hampir pasti tidak cocok antara server dan client.
Menggunakan viewport untuk menentukan markup awal
'use client'
export default function ResponsiveNav() {
const isMobile = window.innerWidth < 768
return isMobile ? <MobileNav /> : <DesktopNav />
}Server tidak tahu ukuran viewport sebenarnya. Jika server merender satu versi dan client memilih versi lain, akan ada pergantian UI setelah load.
Menginisialisasi state dari browser-only value di render pertama
'use client'
import { useState } from 'react'
export default function Sidebar() {
const [collapsed] = useState(
localStorage.getItem('sidebar') === 'collapsed'
)
return <aside className={collapsed ? 'collapsed' : ''}>...</aside>
}Walaupun terlihat rapi, initial state ini masih bergantung pada browser-only data. Jika server merender kondisi berbeda, kelas CSS berubah saat hydration dan memicu flicker.
Pola perbaikan yang aman dan bertahap
1. Pastikan render awal deterministik
Prinsip utamanya: render pertama harus menghasilkan markup yang sama di server dan client. Jika data hanya tersedia di browser, jangan gunakan untuk menentukan HTML awal. Render fallback yang stabil dulu, lalu perbarui setelah mount.
Contoh perbaikan untuk localStorage:
'use client'
import { useEffect, useState } from 'react'
export default function ThemeLabel() {
const [theme, setTheme] = useState('light')
useEffect(() => {
const saved = localStorage.getItem('theme')
if (saved) setTheme(saved)
}, [])
return <span>Tema: {theme}</span>
}Mengapa ini lebih aman? Karena server dan client sama-sama memulai dari nilai light. Setelah komponen mount, barulah data browser dibaca dan UI diperbarui secara sadar.
Trade-off-nya jelas: jika nilai sebenarnya bukan light, pengguna masih bisa melihat perubahan sesaat. Jadi ini aman dari mismatch, tetapi belum tentu bebas flicker visual. Untuk kasus tema, biasanya perlu strategi yang lebih baik.
2. Untuk tema, set nilai sedini mungkin sebelum paint
Flicker tema biasanya terjadi karena class atau atribut tema baru diterapkan setelah React mount. Solusi yang lebih baik adalah menetapkan tema dari cookie atau script awal sehingga HTML/CSS pertama sudah sesuai dengan preferensi pengguna.
Pendekatannya bisa berupa:
- simpan tema di cookie agar server dapat membaca preferensi saat render
- atau jalankan script kecil sangat awal untuk menetapkan class tema sebelum UI terlihat penuh
Jika tema hanya dibaca dari localStorage di useEffect, mismatch memang berkurang, tetapi flash tema lama tetap bisa terjadi.
Untuk state yang memengaruhi tampilan global seperti tema, lebih baik sinkronkan sumber kebenaran agar server dan client sepakat sejak render pertama.
3. Pisahkan bagian client-only dengan guard mount
Untuk data yang memang tidak masuk akal dirender di server, tampilkan fallback stabil sampai komponen benar-benar berada di browser.
'use client'
import { useEffect, useState } from 'react'
export default function ViewportInfo() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return <span>Memuat info layar...</span>
}
const isMobile = window.innerWidth < 768
return <span>{isMobile ? 'Mobile' : 'Desktop'}</span>
}Ini cocok jika bagian tersebut bukan konten kritikal untuk SEO atau tidak harus tampil identik di server. Anda menukar sedikit delay dengan output yang konsisten.
4. Gunakan dynamic import untuk komponen yang sepenuhnya client-only
Jika sebuah komponen bergantung penuh pada API browser, editor WYSIWYG, chart interaktif, atau library yang tidak relevan untuk SSR, pertimbangkan memuatnya hanya di client.
import dynamic from 'next/dynamic'
const ClientOnlyChart = dynamic(() => import('./ClientOnlyChart'), {
ssr: false,
loading: () => <p>Memuat chart...</p>
})
export default function Dashboard() {
return <ClientOnlyChart />
}Kapan memilih ini?
- komponen tidak perlu HTML dari server
- library bergantung pada
windowatau DOM - SSR justru memicu mismatch atau biaya tambahan yang tidak perlu
Trade-off:
- konten tidak tersedia pada HTML awal
- bisa menunda interaktivitas
- kurang ideal untuk konten penting di atas fold jika SEO atau perceived performance menjadi prioritas
5. Gunakan Suspense untuk loading boundary, bukan untuk menyembunyikan mismatch
Suspense berguna untuk memisahkan area loading dan membuat transisi data lebih terkendali. Namun, ia bukan solusi otomatis untuk hydration mismatch. Pakai Suspense saat memang ada boundary async atau komponen yang boleh menampilkan fallback sementara.
Contoh pemakaian yang masuk akal adalah saat sebagian UI menunggu data atau modul client tertentu, bukan untuk menutupi render yang nondeterministik.
6. Pindahkan sumber data ke server bila memungkinkan
Jika state awal bisa diketahui di server, gunakan itu. Misalnya:
- preferensi tema dari cookie
- locale dari request
- status user dari session server
- fitur tertentu dari flag yang dibaca server
Semakin banyak keputusan UI awal dibuat dari data yang tersedia saat SSR, semakin kecil peluang flicker saat hydration.
Contoh salah dan versi perbaikannya
Kasus 1: tema dari localStorage
Versi bermasalah:
'use client'
export default function ThemePanel() {
const theme = localStorage.getItem('theme') || 'light'
return <div data-theme={theme}>Panel</div>
}Masalah: render awal bergantung pada data browser-only.
Versi lebih aman:
'use client'
import { useEffect, useState } from 'react'
export default function ThemePanel() {
const [theme, setTheme] = useState('light')
useEffect(() => {
const saved = localStorage.getItem('theme')
if (saved) setTheme(saved)
}, [])
return <div data-theme={theme}>Panel</div>
}Versi terbaik untuk mengurangi flash: kirim tema dari server melalui cookie atau terapkan tema sebelum paint dengan mekanisme awal yang konsisten.
Kasus 2: waktu lokal di render awal
Versi bermasalah:
export default function LastUpdated() {
return <p>{new Date().toLocaleTimeString()}</p>
}Masalah: format waktu dan timezone bisa berbeda antara server dan client.
Versi perbaikan:
'use client'
import { useEffect, useState } from 'react'
export default function LastUpdated() {
const [time, setTime] = useState<string | null>(null)
useEffect(() => {
setTime(new Date().toLocaleTimeString())
}, [])
return <p>{time ?? '...'}</p>
}Jika waktu itu penting untuk SEO atau konsistensi awal, formatlah di server dari sumber waktu yang stabil dan tampilkan versi server sebagai nilai awal.
Kasus 3: markup berbeda berdasarkan viewport
Versi bermasalah:
'use client'
export default function Hero() {
const isMobile = window.innerWidth < 768
return isMobile ? <SmallHero /> : <LargeHero />
}Perbaikan yang disarankan: gunakan CSS responsif untuk perbedaan tampilan bila memungkinkan, bukan percabangan render berdasarkan window pada render awal.
export default function Hero() {
return (
<section className="hero">
<div className="hero-mobile">...</div>
<div className="hero-desktop">...</div>
</section>
)
}Lalu kontrol tampilannya dengan CSS media query. Pendekatan ini biasanya lebih stabil daripada mengganti subtree React saat hydration.
Langkah diagnosis yang praktis
1. Cari bagian UI yang berubah setelah mount
Jangan mulai dari seluruh halaman. Amati elemen spesifik yang berkedip:
- teks
- className
- atribut data-theme
- konten hasil format tanggal
- komponen yang hanya muncul di browser
Pertanyaan paling penting: nilai apa yang berbeda antara HTML server dan render awal client?
2. Tambahkan logging yang membedakan server dan client
Untuk komponen yang dicurigai, log nilai saat render:
console.log('render theme', {
isServer: typeof window === 'undefined',
value: theme
})Jika log menunjukkan nilai berbeda di server dan client sebelum effect berjalan, Anda sudah menemukan kandidat mismatch.
Hindari logging berlebihan di seluruh tree. Fokus pada state atau prop yang memengaruhi markup.
3. Gunakan React DevTools untuk memeriksa prop dan state setelah mount
React DevTools membantu melihat apakah komponen tertentu langsung berubah state segera setelah mount. Pola yang patut dicurigai:
- state awal
false, lalu langsung menjaditruetanpa interaksi pengguna - prop dari parent berubah segera setelah hydration
- className berubah akibat effect pertama
Jika perubahan itu mengendalikan struktur atau teks utama, kemungkinan besar flicker berasal dari sana.
4. Bandingkan View Source dengan DOM setelah hydration
View Source menunjukkan HTML dari server, sedangkan tab Elements di DevTools menunjukkan DOM setelah browser dan React bekerja. Jika keduanya berbeda pada bagian yang terlihat berkedip, masalahnya hampir pasti hydration mismatch atau update awal yang terlalu cepat.
5. Perhatikan warning hydration di console, tetapi jangan bergantung penuh padanya
Tidak semua mismatch menimbulkan error yang jelas. Warning tetap penting, namun ketiadaan warning bukan bukti bahwa hydration aman.
Kapan memakai useEffect, client-only guard, dynamic import, atau Suspense?
Gunakan useEffect jika:
- nilai hanya tersedia di browser
- perubahan setelah mount dapat diterima
- Anda bisa menampilkan fallback stabil lebih dulu
Contoh: membaca localStorage, timezone lokal, atau preferensi UI non-kritis.
Gunakan guard client-only jika:
- konten memang tidak relevan untuk SSR
- lebih aman menunda render daripada memaksa markup awal yang salah
Contoh: informasi viewport, integrasi browser API, widget yang hanya berguna setelah interaksi.
Gunakan dynamic import dengan SSR dimatikan jika:
- komponen sangat bergantung pada DOM/browser
- library pihak ketiga tidak ramah SSR
- Anda ingin mengisolasi area client-only dengan fallback yang jelas
Gunakan Suspense jika:
- Anda punya boundary loading yang memang perlu fallback
- komponen async atau lazy-loaded perlu transisi yang lebih rapi
Jangan memilih Suspense hanya karena ada flicker. Selesaikan dulu sumber nondeterministic render-nya.
Kesalahan umum saat memperbaiki flicker
- Menambahkan cek
typeof windowlangsung di render. Ini sering hanya memindahkan masalah, bukan menyelesaikannya. - Menggunakan
suppressHydrationWarningterlalu cepat. Ini bisa berguna untuk kasus sangat spesifik, tetapi tidak memperbaiki penyebab mismatch. - Memaksa seluruh halaman menjadi client component. Ini bisa mengurangi manfaat SSR dan belum tentu menghilangkan flicker.
- Menggunakan state awal yang berbeda dengan data server. Jika initial state tidak sinkron, hydration tetap berisiko.
- Mencampur logika responsif ke JavaScript padahal CSS cukup. Banyak kasus viewport lebih aman diselesaikan oleh CSS.
Checklist pencegahan sebelum komponen dirilis
- Apakah render awal bergantung pada
window,document,localStorage, atau media query browser? - Apakah ada penggunaan
Date, timezone lokal, atauMath.random()di render? - Apakah state awal di client bisa berbeda dari data yang dipakai server?
- Apakah keputusan UI awal sebenarnya bisa dipindahkan ke server melalui cookie, header, atau session?
- Apakah perbedaan mobile/desktop bisa ditangani dengan CSS, bukan percabangan React?
- Jika komponen memang client-only, apakah lebih tepat diisolasi dengan
dynamic importatau guard mount? - Apakah fallback awal cukup stabil sehingga tidak menyebabkan layout shift besar?
Ringkasan pendek yang bisa langsung diterapkan
Jika Anda melihat flicker UI saat hydration di Next.js App Router, asumsikan dulu ada perbedaan antara output server dan render awal client. Cari nilai yang hanya diketahui browser, lalu hentikan nilai itu memengaruhi markup awal. Render fallback yang deterministik, pindahkan state awal ke server bila memungkinkan, gunakan useEffect untuk data browser-only, dan pilih dynamic import hanya untuk komponen yang memang tidak perlu SSR.
Prinsip yang paling penting sederhana: server dan client harus sepakat pada render pertama. Setelah itu, update di client akan terasa normal, bukan sebagai flicker yang membingungkan pengguna.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!