Pada UI rak film dinamis, hydration mismatch terjadi ketika HTML yang dihasilkan server tidak sama dengan output render awal di client. Gejalanya sering terlihat pada grid kartu film: urutan berubah setelah halaman aktif, tombol filter tiba-tiba berpindah status, jumlah kolom berbeda, atau React menampilkan peringatan seperti Text content does not match server-rendered HTML.
Jika Anda membangun katalog film bergaya rak atau kartu seperti situs koleksi, masalah ini hampir selalu berasal dari satu hal: render awal tidak deterministik. Penyebab umumnya adalah urutan data yang tidak stabil, state awal dari localStorage, logika berbasis viewport, penggunaan Math.random() atau Date, serta filter/sort yang dibaca berbeda antara server dan client. Solusinya bukan mematikan SSR, melainkan memastikan bahwa render pertama di server dan browser menghasilkan markup yang sama.
Mengapa hydration mismatch sering muncul pada rak film dinamis
Rak film atau grid kartu adalah kandidat klasik untuk mismatch karena komponennya biasanya menggabungkan banyak sumber state sekaligus:
- data katalog dari server,
- filter dan sort dari URL,
- preferensi pengguna seperti mode tampilan dari
localStorage, - jumlah kolom berdasarkan ukuran layar,
- indikator dinamis seperti label baru ditambahkan atau urutan acak.
Saat SSR aktif, server merender HTML lebih dulu. Browser lalu menjalankan JavaScript dan React mencoba mengaitkan event serta state ke markup yang sudah ada. Jika tree yang dirender di client berbeda pada render pertama, React mendeteksi mismatch. Pada kasus ringan, React bisa memperbaikinya dengan re-render. Pada kasus lain, Anda mendapat UI berkedip, elemen berpindah, atau state yang tidak konsisten.
Gejala yang perlu dicurigai
- Peringatan di console tentang hydration mismatch atau text content mismatch.
- Urutan kartu film berubah tepat setelah halaman selesai dimuat.
- Tampilan grid 4 kolom di server berubah menjadi 2 atau 5 kolom di client.
- Filter aktif di URL, tetapi tombol filter awal tidak sinkron.
- Ikon, label, atau penghitung koleksi berubah sesaat setelah hydration.
- Komponen tertentu hanya error di produksi, bukan saat development.
Catatan: pada development, beberapa framework dan mode strict dapat membuat gejala tampak lebih sering. Itu bukan berarti bug boleh diabaikan. Jika SSR dan client tidak konsisten, masalah tetap nyata di produksi.
Penyebab utama hydration mismatch pada UI rak film
1. Urutan data tidak stabil
Ini penyebab paling umum. Misalnya server mengembalikan daftar film tanpa ORDER BY yang tegas, atau client melakukan sort ulang berdasarkan properti yang tidak selalu tersedia. Hasilnya, urutan kartu pada SSR dan client berbeda.
Masalah juga muncul jika Anda melakukan transformasi seperti ini pada render:
const movies = items.sort((a, b) => a.title.localeCompare(b.title));Array.prototype.sort() memodifikasi array asli. Jika array yang sama dipakai di tempat lain atau urutan awalnya tidak identik, hasil render bisa tidak konsisten. Selain itu, jika nilai sort bergantung pada locale, field kosong, atau data yang datang belakangan, hasilnya makin tidak stabil.
2. State awal dari localStorage
Pola umum: pengguna memilih mode compact grid, ukuran poster, atau daftar favorit, lalu aplikasi membaca localStorage langsung saat inisialisasi state. Di server, localStorage tidak ada. Akibatnya, server merender satu nilai default, sementara client merender nilai lain pada pass pertama.
3. Ukuran viewport dipakai untuk menentukan markup awal
Contoh: jumlah kolom grid, jumlah item per baris, atau apakah sidebar filter muncul sebagai drawer ditentukan dari window.innerWidth. Server tidak tahu ukuran viewport browser. Jika jumlah elemen atau struktur DOM bergantung pada viewport saat render awal, mismatch hampir pasti terjadi.
4. Penggunaan Math.random(), Date, atau nilai yang berubah tiap render
Jika Anda memakai Math.random() untuk memilih film sorotan, mengacak urutan, atau membuat key, output server dan client akan berbeda. Hal yang sama berlaku untuk new Date() jika hasilnya memengaruhi label atau isi teks saat render.
5. Perbedaan filter/sort URL antara server dan client
Pada aplikasi katalog, filter dan sort biasanya disimpan di query string seperti ?sort=year_desc&format=blu-ray. Jika server merender berdasarkan satu sumber kebenaran tetapi client menginisialisasi state dari sumber lain, tombol aktif, urutan grid, atau hasil filter bisa berbeda.
Contohnya:
- server membaca
searchParams, - client mengabaikannya lalu memulai dari default,
- setelah hydration, client baru membaca URL dan mengubah state.
Perubahan ini cukup untuk menimbulkan mismatch.
Checklist diagnosis sebelum memperbaiki
- Baca pesan error di console. Perhatikan komponen yang disebut dalam stack trace.
- Bandingkan HTML awal server dengan render client pertama. Lihat isi teks, urutan list, class aktif, dan jumlah elemen.
- Cari sumber non-deterministik. Audit penggunaan
window,document,localStorage,Date,Math.random(), dan fungsi sort/filter yang bergantung pada kondisi client. - Verifikasi key list. Pastikan key stabil dan berasal dari ID data, bukan index atau nilai acak.
- Pastikan data SSR dan data hydration identik. Jangan lakukan fetch tambahan di client yang mengubah isi grid sebelum hydration selesai kecuali dibungkus dengan pola aman.
- Periksa sinkronisasi URL. Query string yang dipakai server harus menjadi dasar state awal client.
- Uji dengan viewport berbeda. Jika mismatch hanya muncul pada mobile atau desktop, curigai logika layout berbasis
window.innerWidth.
Strategi inti: buat render pertama deterministik
Prinsip dasarnya sederhana: render pertama di server dan client harus memakai input yang sama. Hal-hal yang hanya diketahui browser boleh diterapkan setelah hydration, bukan saat render awal.
Gunakan data yang sudah distabilkan di server
Jika urutan rak film penting, stabilkan di lapisan server atau sebelum komponen dikirim ke client. Jangan serahkan urutan ke logika acak atau sort implisit.
type Movie = {
id: string;
title: string;
year: number | null;
addedAt: string;
};
function sortMoviesDeterministically(items: Movie[]) {
return [...items].sort((a, b) => {
const byTitle = a.title.localeCompare(b.title);
if (byTitle !== 0) return byTitle;
const byYear = (a.year ?? 0) - (b.year ?? 0);
if (byYear !== 0) return byYear;
return a.id.localeCompare(b.id);
});
}Pola ini bekerja karena:
- tidak memodifikasi array asli,
- selalu punya tie-breaker terakhir dengan
id, - tidak bergantung pada state browser.
Jika data datang dari database, lebih baik lagi gunakan pengurutan yang eksplisit pada query agar server selalu mengembalikan urutan yang sama.
Gunakan key yang stabil
Pada grid kartu, gunakan movie.id sebagai key. Hindari index karena urutan dapat berubah saat filter atau sort diterapkan.
{movies.map((movie) => (
<MovieCard key={movie.id} movie={movie} />
))}Contoh React/Next.js: render deterministik untuk grid rak film
Contoh berikut menunjukkan pendekatan aman: filter dan sort awal berasal dari URL yang sudah diketahui saat SSR, lalu komponen client menerima nilai awal yang sama.
type Sort = 'title_asc' | 'year_desc';
type Movie = {
id: string;
title: string;
year: number | null;
format: 'blu-ray' | 'dvd' | '4k';
posterUrl: string;
};
function applyFilters(items: Movie[], format: string | null, sort: Sort) {
let result = items;
if (format) {
result = result.filter((m) => m.format === format);
}
const sorted = [...result].sort((a, b) => {
if (sort === 'title_asc') {
const byTitle = a.title.localeCompare(b.title);
return byTitle !== 0 ? byTitle : a.id.localeCompare(b.id);
}
const byYear = (b.year ?? 0) - (a.year ?? 0);
return byYear !== 0 ? byYear : a.id.localeCompare(b.id);
});
return sorted;
}
export default function MovieShelfPage({
movies,
searchParams,
}: {
movies: Movie[];
searchParams: { format?: string; sort?: string };
}) {
const initialFormat = searchParams.format ?? null;
const initialSort: Sort = searchParams.sort === 'year_desc' ? 'year_desc' : 'title_asc';
const initialMovies = applyFilters(movies, initialFormat, initialSort);
return (
<MovieShelfClient
movies={movies}
initialFormat={initialFormat}
initialSort={initialSort}
initialMovies={initialMovies}
/>
);
}Komponen client lalu memulai dari nilai yang sama, bukan dari default terpisah:
'use client';
import { useMemo, useState } from 'react';
type Sort = 'title_asc' | 'year_desc';
export function MovieShelfClient({
movies,
initialFormat,
initialSort,
initialMovies,
}: {
movies: Movie[];
initialFormat: string | null;
initialSort: Sort;
initialMovies: Movie[];
}) {
const [format, setFormat] = useState<string | null>(initialFormat);
const [sort, setSort] = useState<Sort>(initialSort);
const visibleMovies = useMemo(() => {
return applyFilters(movies, format, sort);
}, [movies, format, sort]);
return (
<>
<FilterBar
format={format}
sort={sort}
onFormatChange={setFormat}
onSortChange={setSort}
/>
<MovieGrid movies={visibleMovies} />
</>
);
}Karena initialFormat dan initialSort berasal dari sumber yang sama dengan SSR, render pertama tetap sinkron.
Menangani state client-only dengan aman
Kasus: preferensi tampilan dari localStorage
Preferensi seperti ukuran poster atau mode compact boleh tetap disimpan di localStorage, tetapi jangan dipakai untuk mengubah markup pada render pertama. Gunakan default yang sama antara server dan client, lalu baca penyimpanan setelah komponen terpasang.
'use client';
import { useEffect, useState } from 'react';
type ViewMode = 'comfortable' | 'compact';
function ViewModeToggle() {
const [viewMode, setViewMode] = useState<ViewMode>('comfortable');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const saved = window.localStorage.getItem('view-mode');
if (saved === 'comfortable' || saved === 'compact') {
setViewMode(saved);
}
}, []);
useEffect(() => {
if (mounted) {
window.localStorage.setItem('view-mode', viewMode);
}
}, [mounted, viewMode]);
return (
<button onClick={() => setViewMode(viewMode === 'comfortable' ? 'compact' : 'comfortable')}>
Mode: {viewMode}
</button>
);
}Yang penting di sini bukan tombolnya, melainkan prinsipnya: nilai awal aman dan deterministik, nilai browser diterapkan setelah mount.
Jika state client-only mengubah struktur layout
Jika preferensi pengguna memengaruhi struktur DOM secara besar, pertimbangkan placeholder netral sampai komponen terpasang. Misalnya, tampilkan grid standar selama SSR, lalu aktifkan mode compact setelah hydration. Trade-off-nya adalah ada perubahan UI setelah mount, tetapi itu lebih aman daripada mismatch.
Placeholder aman untuk data atau UI yang belum boleh diputuskan saat SSR
Tidak semua kondisi harus diselesaikan di server. Untuk hal yang memang hanya diketahui browser, tampilkan placeholder yang tidak menimbulkan perbedaan struktur kritis.
'use client';
import { useEffect, useState } from 'react';
function ClientOnlyShelfHint() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <p aria-hidden="true">Menyiapkan preferensi tampilan...</p>;
}
return <p>Preferensi tampilan siap digunakan.</p>;
}Placeholder aman berarti:
- markup awal tidak tergantung pada browser-only API,
- jumlah elemen penting tetap stabil bila memungkinkan,
- perubahan setelah mount tidak mengacaukan key dan urutan list.
Jangan tentukan jumlah item atau struktur grid dari viewport saat SSR
Salah satu kesalahan umum adalah merender jumlah kartu berbeda untuk mobile dan desktop berdasarkan window.innerWidth. Server tidak memiliki informasi ini. Sebagai gantinya:
- gunakan CSS responsive untuk jumlah kolom,
- jangan ubah jumlah node berdasarkan viewport pada render awal,
- jika perlu fitur client-only seperti virtualized layout atau masonry khusus, aktifkan setelah mount.
Contoh yang aman: selalu render daftar film yang sama, lalu biarkan CSS mengatur tampilan kolom.
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}Dengan pendekatan ini, HTML tetap sama. Hanya presentasi visual yang berubah, dan itu aman untuk hydration.
Sinkronisasi URL sebagai sumber kebenaran filter dan sort
Untuk katalog film, pola paling aman adalah menjadikan URL sebagai sumber kebenaran bagi filter dan sort yang harus SSR-friendly. Server merender berdasarkan query string, lalu client menginisialisasi state dari nilai yang sama dan memperbarui URL ketika pengguna berinteraksi.
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
function useSyncShelfUrl(format: string | null, sort: 'title_asc' | 'year_desc') {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
if (format) params.set('format', format);
else params.delete('format');
params.set('sort', sort);
const nextUrl = `${pathname}?${params.toString()}`;
router.replace(nextUrl);
}, [format, sort, pathname, router, searchParams]);
}Ada beberapa catatan praktis:
- Inisialisasi state awal tetap harus berasal dari server atau props SSR, bukan dari hook URL yang menghasilkan nilai berbeda pada render pertama.
- Hindari loop update: hanya panggil sinkronisasi saat nilai benar-benar berubah.
- Jika URL dipakai untuk fetch data baru, pastikan transisi tidak menyebabkan list sementara berubah ke default yang berbeda dari SSR.
Jika aplikasi Anda memakai pendekatan server component dan client component, pertahankan batas tanggung jawab yang jelas: server membaca URL dan menyediakan nilai awal; client mengelola interaksi lanjutan.
Hindari random dan Date pada render
Jika Anda perlu label seperti ditambahkan hari ini atau blok pilihan acak, jangan hitung langsung dengan new Date() atau Math.random() di body komponen yang ikut SSR.
Pilihan yang lebih aman:
- hitung di server dan kirim sebagai field eksplisit,
- gunakan timestamp yang sama untuk SSR dan hydration melalui props,
- jalankan logika acak hanya setelah mount jika memang murni kosmetik,
- jangan gunakan nilai acak untuk
key.
// Buruk
const featured = movies[Math.floor(Math.random() * movies.length)];
// Lebih aman: server menentukan featuredId lebih dulu
const featured = movies.find((m) => m.id === featuredId);Pola perbaikan yang biasanya efektif
1. Stabilkan data sebelum render
- Gunakan sort dengan tie-breaker.
- Pastikan list selalu punya key stabil.
- Jangan mutasi array props secara langsung.
2. Pisahkan state SSR-friendly dan client-only
- SSR-friendly: filter URL, hasil pencarian, sort utama, pagination.
- Client-only: preferensi visual, panel yang dibuka, mode grid personal, bookmark lokal.
3. Gunakan CSS untuk responsivitas, bukan branching render awal
- Markup tetap sama.
- Kolom dan ukuran kartu diatur oleh CSS.
4. Tunda efek browser sampai mount
- Baca
localStoragediuseEffect. - Akses
windowhanya setelah komponen terpasang.
5. Jadikan URL sumber kebenaran untuk filter/sort yang perlu SSR
- Server membaca query.
- Client mulai dari nilai yang sama.
- Interaksi berikutnya menyinkronkan URL secara eksplisit.
Contoh struktur komponen yang lebih aman
Untuk UI rak film yang kompleks, pembagian berikut biasanya lebih mudah dipelihara:
- Server layer: baca query URL, fetch data, stabilkan urutan, hitung state awal.
- Client shell: pegang state interaktif yang harus sinkron dengan URL.
- Presentational grid: komponen murni yang hanya menerima array film final dan merender kartu berdasarkan
id. - Client-only enhancements: preferensi tampilan, animasi, shortcut keyboard, atau panel yang tidak memengaruhi render SSR awal.
Dengan struktur ini, Anda mengurangi risiko bahwa komponen grid menerima input berbeda antara server dan client.
Common mistakes yang sering terlewat
- Menggunakan index sebagai key pada kartu film.
- Melakukan sort langsung pada array props.
- Menginisialisasi state dari
window.locationataulocalStoragedi luaruseEffect. - Merender jumlah item berbeda berdasarkan viewport.
- Menampilkan tanggal relatif seperti 3 menit lalu saat SSR tanpa timestamp bersama.
- Mengubah filter default di client yang tidak sama dengan nilai URL atau SSR.
- Menggabungkan fetch SSR dengan fetch client yang menimpa list sebelum hydration stabil.
Debugging yang praktis di proyek nyata
Log input render, bukan hanya output
Saat mismatch terjadi, jangan hanya melihat markup. Log juga input yang dipakai komponen:
- panjang array film,
- nilai sort dan filter awal,
- id 5 item pertama setelah sort/filter,
- status mounted atau tidak,
- apakah data berasal dari SSR atau hasil efek client.
Sering kali akar masalah langsung terlihat ketika urutan 5 item pertama di server berbeda dari client.
Bekukan sumber perubahan satu per satu
Nonaktifkan sementara fitur-fitur berikut untuk mengisolasi penyebab:
- sinkronisasi
localStorage, - logika viewport,
- sort/filter interaktif,
- label berbasis waktu,
- random featured item.
Jika mismatch hilang setelah satu fitur dimatikan, Anda sudah menemukan kelas masalahnya.
Periksa HTML yang benar-benar dikirim server
Jangan hanya percaya apa yang terlihat setelah browser selesai menjalankan JavaScript. Lihat source respons server atau gunakan alat inspeksi jaringan untuk memeriksa HTML mentah. Ini penting untuk memastikan apakah masalah berasal dari SSR, atau dari perubahan client tepat setelah hydration.
Kapan boleh memakai client-only rendering?
Ada kasus di mana sebagian kecil UI memang lebih masuk akal dirender hanya di client, misalnya panel preferensi yang seluruhnya bergantung pada storage lokal dan tidak penting untuk SEO. Ini sah, tetapi jangan jadikan solusi default untuk seluruh rak film. Jika grid utama ikut dipindah ke client-only, Anda kehilangan manfaat SSR untuk waktu tampil awal, crawlability, dan konsistensi data awal.
Pilih client-only untuk komponen yang:
- tidak menentukan isi utama halaman,
- bergantung penuh pada API browser,
- masuk akal jika muncul setelah mount.
Penutup
SSR aman untuk rak film dinamis berarti menjaga agar server dan client merender struktur yang sama pada pass pertama. Kuncinya adalah render deterministik: urutan data stabil, state awal tidak bergantung pada browser, viewport ditangani oleh CSS, random dan waktu tidak dipakai langsung saat render, serta filter/sort URL menjadi sumber kebenaran yang konsisten.
Jika Anda menangani katalog film, grid kartu, atau rak koleksi yang kaya interaksi, fokuslah pada pemisahan antara state yang harus diketahui server dan state yang hanya relevan setelah komponen hidup di browser. Dengan pola itu, Anda bisa mempertahankan manfaat SSR tanpa terus-menerus berhadapan dengan hydration mismatch yang sulit dilacak.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!