Hydration mismatch terjadi ketika representasi virtual DOM yang dibuat saat Server-Side Rendering (SSR) tidak identik dengan pohon DOM yang direkonstruksi oleh React di sisi browser. Pada fitur komunitas seperti widget profil relasi, galat ini sering muncul saat merender status koneksi (misalnya status pertemanan atau tombol tindak lanjut) dan penanda waktu aktivitas pengguna.

Akar Masalah Hydration Mismatch Profil Relasi

Dua sumber utama ketidakcocokan data server dan client pada widget profil relasi adalah:

  • Kalkulasi Waktu Lokal (Relative Time): Server merender data menggunakan zona waktu UTC atau waktu server, sedangkan browser mengeksekusi Intl.DateTimeFormat atau manipulasi tanggal berdasarkan zona waktu lokal dan jam perangkat pengguna.
  • Status Sesi dan Token Autentikasi: Server sering kali merender komponen dalam status anonim atau menggunakan sesi cache, sedangkan client mengevaluasi status koneksi (misalnya "Terhubung" atau "Ikuti") menggunakan token dari localStorage atau cookie sesi yang terlambat dievaluasi.

Ketika perbedaan ini terjadi, React memicu galat runtime: Error: Text content does not match server-rendered HTML.

Contoh Kode Bermasalah

Komponen berikut memicu hydration error karena mengevaluasi waktu relatif dan status koneksi langsung saat proses render awal:

// components/RelationshipBadge.tsx
"use client";

interface Props {
  lastActive: string; // ISO String
  targetUserId: string;
}

export function RelationshipBadge({ lastActive, targetUserId }: Props) {
  // MASALAH 1: Kalkulasi Date.now() di server berbeda dengan di browser
  const diffMinutes = Math.floor((Date.now() - new Date(lastActive).getTime()) / 60000);
  const activeText = diffMinutes < 1 ? "Baru saja aktif" : `${diffMinutes} menit lalu`;

  // MASALAH 2: Akses token/state browser langsung pada render execution
  const isConnected = typeof window !== "undefined" 
    ? Boolean(localStorage.getItem(`conn_${targetUserId}`))
    : false;

  return (
    <div className="p-4 border rounded">
      <p>Status Aktivitas: {activeText}</p>
      <span>{isConnected ? "Terhubung" : "Belum Terhubung"}</span>
    </div>
  );
}

Solusi: Pendekatan Lazy Mount Tanpa Layout Shift

Alternatif paling ringkas dan aman untuk data non-kritis SEO adalah menunda rendering data dinamis client-only hingga komponen selesai di-mount, dengan tetap menjaga dimensi elemen (skeleton/placeholder) untuk mencegah Cumulative Layout Shift (CLS).

1. Hook Sederhana untuk Deteksi Mount

// hooks/useIsMounted.ts
import { useState, useEffect } from "react";

export function useIsMounted() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  return mounted;
}

2. Implementasi Komponen Terkoreksi

// components/SafeRelationshipBadge.tsx
"use client";

import { useIsMounted } from "../hooks/useIsMounted";

interface Props {
  lastActive: string;
  targetUserId: string;
}

export function SafeRelationshipBadge({ lastActive, targetUserId }: Props) {
  const isMounted = useIsMounted();

  // Render placeholder netral pada SSR untuk mempertahankan struktur layout
  if (!isMounted) {
    return (
      <div className="p-4 border rounded h-16 animate-pulse bg-gray-100" aria-hidden="true">
        <span className="sr-only">Memuat status relasi...</span>
      </div>
    );
  }

  // Eksekusi kalkulasi client-side aman setelah mount
  const diffMinutes = Math.floor((Date.now() - new Date(lastActive).getTime()) / 60000);
  const activeText = diffMinutes < 1 ? "Baru saja aktif" : `${diffMinutes} menit lalu`;
  const isConnected = Boolean(localStorage.getItem(`conn_${targetUserId}`));

  return (
    <div className="p-4 border rounded h-16">
      <p>Status Aktivitas: {activeText}</p>
      <span>{isConnected ? "Terhubung" : "Belum Terhubung"}</span>
    </div>
  );
}

Alternatif: Untuk komponen berat, gunakan next/dynamic dengan opsi { ssr: false } untuk membagi bundle JavaScript.

Verifikasi Otomatis dengan Playwright

Hydration mismatch sering kali tidak menggagalkan proses build produksi, hanya menghasilkan warning atau error di console browser. Tangkap anomali ini pada pipeline pengujian menggunakan Playwright.

// tests/hydration.spec.ts
import { test, expect } from "@playwright/test";

test("profil relasi tidak menghasilkan hydration mismatch di console", async ({ page }) => {
  const consoleErrors: string[] = [];

  page.on("console", (msg) => {
    if (msg.type() === "error" || msg.type() === "warning") {
      const text = msg.text();
      if (text.includes("did not match") || text.includes("Hydration")) {
        consoleErrors.push(text);
      }
    }
  });

  await page.goto("/profile/user-123");
  await page.waitForLoadState("networkidle");

  expect(consoleErrors).toEqual([]);
});

Aturan Penggunaan

  • Gunakan SSR penuh untuk data publik relasi yang relevan untuk mesin pencari, tetapi format tanggal di server menggunakan string deterministik tanpa kalkulasi milidetik berjalan.
  • Gunakan lazy client mount untuk elemen berbasis preferensi lokal, token autentikasi client, atau kalkulasi real-time per detik/menit.
  • Hindari penggunaan opsi suppressHydrationWarning secara global; batasi penggunaannya hanya pada atribut teks non-kritis tingkat rendah.