Akar Masalah: Mengapa Streaming World State Memicu Hydration Mismatch

Rendering simulasi multi-turn AI agent (seperti Qwen-AgentWorld atau orchestrator berbasis environment step lainnya) pada arsitektur Server-Side Rendering (SSR) sering menghasilkan error klasik React: Hydration failed because the initial UI does not match the server-rendered HTML.

Kegagalan hidrasi ini umumnya disebabkan oleh dua faktor utama:

  1. Data Non-Deterministik pada Node State: Server menghasilkan properti dinamis saat render awal—seperti stepId menggunakan crypto.randomUUID() atau timestamp eksekusi Date.now(). Ketika browser merender komponen yang sama saat proses hidrasi, generator tersebut menghasilkan nilai berbeda.
  2. Race Condition Stream Ingestion: Transport layer (Server-Sent Events/SSE atau WebSocket) yang diinisialisasi di level modul atau top-level client component mulai mengonsumsi delta mutasi world state sebelum React menyelesaikan rekonsiliasi initial tree pada client. Akibatnya, client tree berubah secara konkuren sebelum hidrasi selesai.

Pola Anti-Pattern: Mengapa useState dan useEffect Gagal

Pendekatan naif mengandalkan kombinasi useState dan useEffect untuk menangkap event stream. Pola ini rapuh karena initial render di client dapat bentrok dengan snapshot server jika state lokal langsung termutasi saat inisialisasi koneksi.

// ANTI-PATTERN: Menyebabkan Hydration Mismatch
"use client";

import { useState, useEffect } from "react";

export function ProblematicAgentVisualizer() {
  const [worldState, setWorldState] = useState({
    stepId: `step-${Math.random()}`, // Non-deterministik antar server & client
    timestamp: Date.now(),          // Waktu SSR berbeda dengan waktu hidrasi browser
    agentPosition: { x: 0, y: 0 }
  });

  useEffect(() => {
    const eventSource = new EventSource("/api/agent/stream");
    eventSource.onmessage = (event) => {
      setWorldState(JSON.parse(event.data));
    };
    return () => eventSource.close();
  }, []);

  return (
    <div>
      <span>Step: {worldState.stepId}</span>
      <span>Tick: {worldState.timestamp}</span>
    </div>
  );
}

Solusi Teknis: Deterministic Snapshot & useSyncExternalStore

Solusi yang benar memisahkan fase SSR deterministik dari lifecycle streaming di client. Kita menggunakan useSyncExternalStore untuk mengelola subscription eksternal secara sinkron tanpa menyebabkan tearing atau hidrasi prematur.

1. Buat External Store Subscriber

// lib/agent-store.ts
export type WorldState = {
  stepId: string;
  tick: number;
  grid: number[][];
};

let currentSnapshot: WorldState | null = null;
const listeners = new Set<() => void>();

export const agentStore = {
  setInitialState(snapshot: WorldState) {
    if (!currentSnapshot) {
      currentSnapshot = snapshot;
    }
  },
  emit(nextState: WorldState) {
    currentSnapshot = nextState;
    listeners.forEach((listener) => listener());
  },
  subscribe(listener: () => void) {
    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  },
  getSnapshot() {
    return currentSnapshot;
  }
};

2. Implementasi Consumer Component

Server Component bertindak sebagai pemasok deterministic baseline state. Client Component mengonsumsi snapshot tersebut menggunakan getServerSnapshot parameter pada useSyncExternalStore.

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

import { useSyncExternalStore, useEffect } from "react";
import { agentStore, WorldState } from "@/lib/agent-store";

type Props = {
  initialState: WorldState;
};

export function AgentWorldVisualizer({ initialState }: Props) {
  agentStore.setInitialState(initialState);

  const state = useSyncExternalStore(
    agentStore.subscribe,
    agentStore.getSnapshot,
    () => initialState // Snapshot deterministik untuk hidrasi server & client
  );

  useEffect(() => {
    const source = new EventSource("/api/agent/stream");
    source.onmessage = (event) => {
      const next: WorldState = JSON.parse(event.data);
      agentStore.emit(next);
    };
    return () => source.close();
  }, []);

  const active = state ?? initialState;

  return (
    <div className="visualizer-container">
      <div data-testid="step-id">Step ID: {active.stepId}</div>
      <div data-testid="tick-counter">Tick: {active.tick}</div>
      <pre>{JSON.stringify(active.grid)}</pre>
    </div>
  );
}

3. Server Component Wrapper

// app/agent/page.tsx
import { AgentWorldVisualizer } from "@/components/AgentWorldVisualizer";
import { WorldState } from "@/lib/agent-store";

export default async function AgentPage() {
  // Snapshot awal harus di-resolve secara deterministik oleh server
  const initialWorldSnapshot: WorldState = {
    stepId: "step-0-init",
    tick: 0,
    grid: [[0, 0], [0, 0]]
  };

  return (
    <main>
      <h1>Simulasi Multi-Turn Agent</h1>
      <AgentWorldVisualizer initialState={initialWorldSnapshot} />
    </main>
  );
}

Verifikasi Bebas Hydration Mismatch

Pastikan perbaikan berfungsi tanpa warning pada runtime development:

  • Buka browser console. Pastikan tidak ada pesan Text content did not match atau Hydration failed saat initial page refresh (F5).
  • Verifikasi bahwa snapshot awal identik antara output curl http://localhost:3000/agent (SSR payload) dengan DOM tree pertama sebelum streaming socket menerima message pertama.
  • Pastikan transisi render: State awal tampil seketika, lalu tick counter bertambah secara reaktif tanpa merender ulang seluruh layout tree.

Evaluasi Trade-off Arsitektur Streaming

Penerapan deterministik SSR dengan useSyncExternalStore membawa trade-off arsitektural:

  • Initial Frame Lag: Client Component sengaja menolak update live stream sampai React hydration pass pertama selesai. Simulasi agent mungkin tertinggal 100-300ms dari waktu aktual server loop saat load awal. Solusi: Gunakan buffer ring pada client store untuk melompati (catch-up) frame yang tertinggal setelah hydration mounted.
  • Memory Footprint: Menyimpan snapshot terpisah di luar React render queue membutuhkan manajemen listener eksplisit agar tidak memicu memory leak pada single-page lifecycle.
  • Alternatif Termudah (Dynamic Boundary): Jika visualisasi world state tidak membutuhkan SEO dan tidak perlu di-render server sama sekali, alternatif paling efisien adalah menonaktifkan SSR pada komponen visualizer: const AgentVisualizer = dynamic(() => import('./Visualizer'), { ssr: false }). Gunakan pendekatan useSyncExternalStore jika snapshot awal wajib masuk dalam First Meaningful Paint.