Hydration mismatch pada React/Next.js terjadi saat DOM tree hasil render server tidak identik dengan initial render tree di client browser. Pada implementasi form persetujuan (terms agreement, persetujuan kontrak, atau privasi data), eror ini kerap muncul karena pembacaan data non-deterministik seperti localStorage, cookie browser yang tidak tersinkronisasi dengan server, atau perbedaan format localized timestamp pada initial render.

Ketika mismatch terjadi, React terpaksa membuang markup server dan melakukan render ulang pada client (client-bailout), merusak tree attachment, membatalkan optimasi performa SSR, dan memicu Cumulative Layout Shift (CLS).

Akar Masalah: State Non-Deterministik pada Initial Pass

Server dan client harus menghasilkan representasi UI yang 100% identik pada first pass render client. Form persetujuan rentan melanggar aturan ini karena dua faktor utama:

  1. Pembacaan Client Storage Saat Eksekusi Komponen: Mengevaluasi localStorage.getItem('agreement_status') langsung di dalam body fungsi komponen atau sebagai initial state useState. Di server, objek window bernilai undefined (fallback ke false), sementara di browser bernilai true.
  2. Perbedaan Format Tanggal (Localized Timestamp): Menampilkan label seperti "Terakhir diperbarui: 15/05/2024" menggunakan toLocaleDateString() tanpa menentukan locale eksplisit dan zona waktu. Server (biasanya berjalan pada UTC) merender format berbeda dibandingkan peramban lokal user.

Tolak Solusi Instan yang Berbahaya

Terdapat dua anti-pattern yang sering disalahgunakan untuk meredam peringatan hidrasi:

  • suppressHydrationWarning: Atribut ini hanya membungkam log peringatan di konsol, bukan menyelesaikan akar masalah. DOM tree tetap tidak konsisten, memicu potensi silent bug di mana status checkbox di UI berbeda dengan state internal form handler.
  • dynamic(() => import(...), { ssr: false }): Mematikan SSR pada form persetujuan merusak first contentful paint (FCP), menghasilkan layout shift masif ketika form tiba-tiba muncul, dan menghilangkan validasi markup dari server.

Solusi Arsitektur: Pola State Deterministik

Pendekatan deterministik menjamin output server dan initial render client selalu sama persis. Terdapat dua strategi utama:

1. Pola Two-Pass Rendering dengan Skeleton Placeholder

Render state default netral (atau skeleton) secara deterministik pada server dan first pass client, lalu mutasikan state berdasarkan client environment hanya setelah mounting selesai (pass kedua).

2. Server-Side Cookie Synchronization

Untuk menghindari render pass kedua sama sekali, simpan status persetujuan ke dalam HTTP cookie. Server membaca cookie pada request header dan menyuntikkannya sebagai initialState langsung ke komponen.

Implementasi: Komponen Form Persetujuan Hydration-Safe

Contoh implementasi form persetujuan menggunakan TypeScript dan React dengan pola two-pass rendering deterministik serta mitigasi CLS melalui dimensional placeholder.

// hooks/useHydrated.ts
import { useSyncExternalStore } from 'react';

const emptySubscribe = () => () => {};

export function useHydrated(): boolean {
  return useSyncExternalStore(
    emptySubscribe,
    () => true,
    () => false
  );
}
// components/AgreementForm.tsx
'use client';

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

interface AgreementFormProps {
  contractVersion: string;
  lastUpdatedIso: string;
  onSubmit: (agreed: boolean) => void;
}

export function AgreementForm({
  contractVersion,
  lastUpdatedIso,
  onSubmit,
}: AgreementFormProps) {
  const isHydrated = useHydrated();
  const [agreed, setAgreed] = useState(false);

  // ponytail: format tanggal statis via ISO string deterministik
  const formattedDate = lastUpdatedIso.slice(0, 10);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSubmit(agreed);
      }}
      className="agreement-card min-h-[140px] p-4 border rounded"
    >
      <h3 className="text-lg font-bold">Syarat dan Ketentuan (v{contractVersion})</h3>
      <p className="text-sm text-gray-500">Pembaruan: {formattedDate}</p>

      <div className="mt-4 flex items-center gap-2 min-h-[24px]">
        {!isHydrated ? (
          // Skeleton dengan dimensi identik untuk mencegah Cumulative Layout Shift (CLS)
          <div className="h-5 w-48 bg-gray-200 animate-pulse rounded" aria-hidden="true" />
        ) : (
          <label className="flex items-center gap-2 cursor-pointer">
            <input
              type="checkbox"
              id="agreement-checkbox"
              checked={agreed}
              onChange={(e) => setAgreed(e.target.checked)}
              className="w-4 h-4"
            />
            <span className="text-sm">Saya menyetujui seluruh klausul di atas</span>
          </label>
        )}
      </div>

      <button
        type="submit"
        disabled={!isHydrated || !agreed}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        Lanjutkan
      </button>
    </form>
  );
}

Mitigasi Cumulative Layout Shift (CLS)

Saat menerapkan conditional render pasca-hidrasi, form berisiko melompat jika fallback placeholder tidak memiliki bounding-box yang identik dengan elemen interaktif aslinya. Perhatikan penggunaan utility class berikut:

  • Beri batas dimensi minimum pada container induk (contoh: min-h-[140px]).
  • Beri placeholder skeleton dimensi vertikal dan horizontal yang mendekati checkbox dan teks label (contoh: min-h-[24px] dan h-5 w-48).
  • Hindari menyembunyikan kontainer secara kondisional (if (!isHydrated) return null;), karena akan memicu pergeseran layout drastis saat hidrasi selesai.

Verifikasi Tree Consistency Melalui Unit Test

Gunakan tes otomatis untuk membandingkan output SSR (renderToString) dengan markup hasil first pass di DOM test runner (Node/JSDOM).

// AgreementForm.test.tsx
import React from 'react';
import { describe, it, expect } from 'vitest';
import { renderToString } from 'react-dom/server';
import { render } from '@testing-library/react';
import { AgreementForm } from './AgreementForm';

describe('AgreementForm SSR Consistency', () => {
  it('menghasilkan markup identik antara server renderToString dan first client mount', () => {
    const props = {
      contractVersion: '1.2.0',
      lastUpdatedIso: '2024-05-15T00:00:00.000Z',
      onSubmit: () => {},
    };

    // 1. Dapatkan markup HTML dari server engine
    const serverHtml = renderToString(<AgreementForm {...props} />);

    // 2. Dapatkan markup client pada pass pertama
    const { container } = render(<AgreementForm {...props} />);
    const clientHtml = container.innerHTML;

    // Verifikasi tree deterministik (tidak boleh mismatch sebelum hidrasi interaktif)
    expect(clientHtml).toBe(serverHtml);
  });
});

Kesimpulan

Hydration mismatch pada form persetujuan diselesaikan bukan dengan menonaktifkan SSR atau mengabaikan warning konsol, melainkan melalui penataan alur render deterministik. Pisahkan dependensi client-only dari initial render, pastikan penanganan tanggal menggunakan format statis independen dari locale mesin, dan gunakan placeholder berdimensi tetap untuk menjamin skor CLS tetap nol.