Akses langsung ke API peramban seperti localStorage atau window pada fase render komponen React di Next.js App Router merupakan penyebab utama hydration mismatch. Masalah ini memicu inkonsistensi antara Virtual DOM (VDOM) klien dan DOM statis hasil Server-Side Rendering (SSR), yang berujung pada error eksekusi runtime dan penurunan metrik Core Web Vitals.

Akar Masalah: Rekonsiliasi VDOM dan Error #418/#425

Saat mengeksekusi SSR, Next.js merender komponen menjadi representasi HTML murni di sisi server. Pada lingkungan Node.js ini, objek window maupun localStorage belum terdefinisi. Server menggunakan nilai default (misalnya tema light) untuk menyusun payload HTML.

Ketika peramban menerima payload tersebut, React memulai proses hydration: membaca kembali hierarki komponen untuk mengaitkan event listener dan menyusun fiber tree di sisi klien. Jika komponen membaca localStorage langsung di luar siklus effect (misalnya di root bodi fungsi komponen), klien akan langsung mengevaluasi nilai persisten pengguna (misalnya tema dark).

Kondisi ini menghasilkan disparitas struktural atau atribut:

  • SSR Node: <div class="theme-light">
  • Client Initial VDOM: <div class="theme-dark">

React mendeteksi divergensi ini pada algoritma rekonsiliasi dan memicu error produksi: React Error #418 (Hydration failed because the initial UI does not match what was rendered on the server) atau React Error #425 (Text content does not match server-rendered HTML). Akibatnya, React membuang cabang DOM yang dihasilkan server dan merender ulang dari awal secara sinkron di klien, memicu render drift dan penurunan performa.

Studi Kasus: Kerusakan Hidrasi pada Penentu Tema

Kode di bawah menunjukkan implementasi umum yang memicu hydration error:

// components/ThemeToggle.tsx
'use client';

import { useState } from 'react';

export default function ThemeToggle() {
  // BUG: Mengakses localStorage saat inisialisasi state
  const [theme, setTheme] = useState(() => {
    if (typeof window !== 'undefined') {
      return localStorage.getItem('theme') || 'light';
    }
    return 'light';
  });

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

Meskipun pengecekan typeof window !== 'undefined' mencegah crash saat eksekusi di server, nilai awal saat hidrasi pertama di peramban tetap menghasilkan nilai yang berbeda dari server jika localStorage berisi 'dark'. Rekonsiliasi gagal seketika.

3 Solusi Konkret Menangani Render Drift

1. Two-Pass Rendering dengan Hook useHydrated

Pendekatan ini memaksa klien merender nilai yang identik dengan server pada fase pertama hidrasi. Evaluasi localStorage ditunda hingga komponen terpasang (mounted) via useEffect.

// hooks/useHydrated.ts
'use client';

import { useState, useEffect } from 'react';

export function useHydrated(): boolean {
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    // ponytail: upgrade to useSyncExternalStore if concurrent tearing occurs
    setHydrated(true);
  }, []);

  return hydrated;
}

Implementasi pada komponen target:

// components/ThemeToggle.tsx
'use client';

import { useState, useEffect } from 'react';
import { useHydrated } from '@/hooks/useHydrated';

export default function ThemeToggle() {
  const isHydrated = useHydrated();
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme) {
      setTheme(savedTheme);
    }
  }, []);

  if (!isHydrated) {
    // Render placeholder struktural identik dengan server payload
    return <button disabled>Mode: ...</button>;
  }

  return (
    <button onClick={() => {
      const nextTheme = theme === 'light' ? 'dark' : 'light';
      setTheme(nextTheme);
      localStorage.setItem('theme', nextTheme);
    }}>
      Mode: {theme}
    </button>
  );
}

2. Dynamic Import dengan Pengecualian SSR

Jika komponen bergantung penuh pada state peramban dan tidak esensial untuk SEO, isolasi komponen keluar dari siklus SSR Next.js menggunakan next/dynamic.

// app/page.tsx
import dynamic from 'next/dynamic';

const UserSessionWidget = dynamic(
  () => import('@/components/UserSessionWidget'),
  { 
    ssr: false,
    loading: () => <div style={{ height: '40px', width: '120px' }} />
  }
);

export default function Page() {
  return (
    <main>
      <h1>Dashboard</h1>
      <UserSessionWidget />
    </main>
  );
}

Opsi ssr: false menginstruksikan server hanya merender elemen loading. Unduhan dan eksekusi bundel JavaScript komponen sepenuhnya didelegasikan ke klien setelah hidrasi halaman utama selesai, meniadakan risiko inkonsistensi DOM.

3. Inline Blocking Script untuk Menghindari FOUC

Penggunaan useEffect untuk penentuan tema visual memicu Flash of Unstyled Content (FOUC): layar berkedip putih sebelum beralih ke warna gelap. Solusi baku tanpa merusak hidrasi React adalah menyuntikkan skrip sinkronus pada elemen <head> sebelum parsing DOM body.

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  const themeScript = `
    (function() {
      try {
        var theme = localStorage.getItem('theme');
        var supportDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
        if (!theme && supportDarkMode) theme = 'dark';
        document.documentElement.setAttribute('data-theme', theme || 'light');
      } catch (e) {}
    })();
  `;

  return (
    <html lang="id" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />
      </head>
      <body>{children}</body>
    </html>
  );
}
Catatan: Tambahkan suppressHydrationWarning pada tag <html>. Atribut ini menginstruksikan React untuk mengabaikan perbedaan atribut data-theme atau class pada elemen akar antara SSR dan klien, sehingga rekonsiliasi tetap berhasil tanpa error.

Evaluasi Dampak terhadap Core Web Vitals (CLS & LCP)

Setiap solusi hidrasi membawa kompromi performa yang terukur pada metrik Core Web Vitals:

  • Cumulative Layout Shift (CLS): Pola two-pass rendering dan dynamic import rentan memicu pergeseran tata letak jika ukuran elemen pengganti (fallback/skeleton) berbeda dari elemen hasil hidrasi. Wajib mendefinisikan dimensi CSS tetap (lebar dan tinggi minimum) pada komponen cadangan untuk menjaga skor CLS di bawah ambang batas 0.1.
  • Largest Contentful Paint (LCP): Menunda render data penyimpanan menggunakan useEffect dapat menunda pelukisan elemen visual terbesar jika elemen tersebut berada di area above-the-fold. Sebaliknya, metode inline blocking script memblokir parser HTML selama beberapa milidetik, namun mencegah repainting total dan menjaga stabilitas LCP dibanding menunggu eksekusi React hydration tree secara utuh.