Hydration error terjadi saat pohon DOM yang dihasilkan oleh server (SSR) tidak identik dengan representasi Virtual DOM pada render awal di client. Pada aplikasi video player minimalis—seperti antarmuka fokus bebas rekomendasi bergaya NoSuggest—masalah ini paling sering dipicu oleh pembacaan langsung API browser seperti localStorage atau window untuk mengambil preferensi volume, resume timestamp, atau status toggle panel.

Akar Masalah: Mengapa LocalStorage Merusak Hydration

Server node runtime tidak memiliki akses ke objek window atau localStorage. Jika komponen membaca storage langsung saat inisialisasi state, server akan menggunakan nilai fallback default (misalnya tombol toggle feed bernilai false atau panel tersembunyi).

Ketika file HTML tiba di browser, React menjalankan proses hydration untuk mengaitkan event listener ke elemen DOM yang ada. Jika client langsung membaca storage lokal yang bernilai true pada siklus render pertama, React mendeteksi perbedaan atribut atau struktur node antara HTML kiriman server dan output render client. Hasilnya adalah React hydration mismatch warning, potensi layout thrashing, atau UI glitch di mana elemen interaktif gagal merespons aksi klik.

Contoh Kode Masalah (Anti-Pattern)

Berikut adalah implementasi umum yang memicu hydration error secara konsisten:

// VideoPlayer.jsx - IMPLEMENTASI SALAH
import { useState } from 'react';

export function VideoPlayer({ src }) {
  // Membaca localStorage langsung saat inisialisasi
  const [autoplay, setAutoplay] = useState(() => {
    if (typeof window !== 'undefined') {
      return localStorage.getItem('player_autoplay') === 'true';
    }
    return false;
  });

  return (
    <div className="player-container">
      <video src={src} autoPlay={autoplay} controls />
      <button onClick={() => setAutoplay(!autoplay)}>
        {/* Server merender: Autoplay: OFF, Client merender: Autoplay: ON */}
        Autoplay: {autoplay ? 'ON' : 'OFF'}
      </button>
    </div>
  );
}

Solusi 1: Two-Pass Rendering dengan useEffect

Pendekatan standar adalah memastikan render pertama client persis sama dengan render server, lalu memperbarui state di client pass kedua via useEffect. Hook ini hanya dieksekusi setelah hydration selesai.

// VideoPlayerTwoPass.jsx
import { useState, useEffect } from 'react';

export function VideoPlayerTwoPass({ src }) {
  const [autoplay, setAutoplay] = useState(false);
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
    const saved = localStorage.getItem('player_autoplay');
    if (saved !== null) {
      setAutoplay(saved === 'true');
    }
  }, []);

  if (!isMounted) {
    // Render placeholder atau default state yang identik dengan server
    return (
      <div className="player-container">
        <video src={src} autoPlay={false} controls />
        <button disabled>Loading settings...</button>
      </div>
    );
  }

  return (
    <div className="player-container">
      <video src={src} autoPlay={autoplay} controls />
      <button onClick={() => {
        const nextState = !autoplay;
        setAutoplay(nextState);
        localStorage.setItem('player_autoplay', String(nextState));
      }}>
        Autoplay: {autoplay ? 'ON' : 'OFF'}
      </button>
    </div>
  );
}

Solusi 2: Hook useSyncExternalStore (Rekomendasi Modern)

React 18 memperkenalkan hook bawaan useSyncExternalStore. Solusi ini menghindari flashing re-render ganda dan menyediakan pemisahan eksplisit antara client snapshot dan server snapshot.

// useLocalStorage.js
import { useSyncExternalStore } from 'react';

function subscribe(callback) {
  window.addEventListener('storage', callback);
  return () => window.removeEventListener('storage', callback);
}

export function useLocalStorage(key, initialValue) {
  const getSnapshot = () => {
    const val = localStorage.getItem(key);
    return val !== null ? val : initialValue;
  };

  const getServerSnapshot = () => initialValue;

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

Implementasi pada komponen UI video player:

// MinimalVideoPlayer.jsx
import { useLocalStorage } from './useLocalStorage';

export function MinimalVideoPlayer({ src }) {
  const hideRecommendations = useLocalStorage('hide_recommendations', 'true') === 'true';

  return (
    <div className="focus-mode-wrapper">
      <video src={src} controls className="w-full" />
      {/* DOM server dan render hydration pertama dipastikan sinkron */}
      {hideRecommendations ? null : (
        <aside className="recommendations-drawer">
          <p>Daftar Video Terkait</p>
        </aside>
      )}
    </div>
  );
}

Checklist Audit SSR Guard

Gunakan checklist teknis berikut untuk mengaudit komponen antarmuka yang mengakses API client:

  • Tunda Evaluasi State Browser: Jangan panggil localStorage, sessionStorage, window.matchMedia, atau navigator pada blok badan komponen atau di dalam argumen default useState.
  • Validasi Fallback Snapshot: Pastikan fungsi getServerSnapshot pada useSyncExternalStore selalu mengembalikan tipe data primitif deterministik (bukan nilai acak atau turunan tanggal runtime).
  • Identifikasi Atribut Dinamis: Pastikan atribut HTML seperti style, className, atau disabled tidak bergantung pada nilai storage lokal sebelum event mount selesai.
  • Gunakan SupressHydrationWarning Secara Selektif: Gunakan atribut suppressHydrationWarning hanya pada elemen teks independen (seperti localized timestamp) jika perbedaan render dapat ditoleransi dan bukan pada node struktural.