Hydration drift pada UI chat/agent yang memakai SSR terjadi saat markup HTML hasil render server berbeda dengan render pertama di browser. Pada aplikasi chat modern yang menerima streaming update per token, mismatch ini mudah terjadi: data masuk sebelum proses hydration selesai, komponen membaca localStorage terlalu dini, atau ada nilai waktu dan angka acak yang berbeda antara server dan klien.
Kalau Anda membangun antarmuka agent ala tren modern seperti yang terlihat pada ekosistem Vercel/Eve, problem ini bukan soal estetika semata. Efeknya bisa berupa warning hydration, UI berkedip, pesan dobel, urutan token kacau, state input reset, sampai subtree React dibuang lalu dirender ulang penuh. Solusinya bukan mematikan SSR, melainkan menjaga shell SSR tetap deterministik dan menunda bagian yang benar-benar bergantung pada runtime klien atau event streaming.
Apa itu hydration drift pada UI streaming
Dalam SSR, server mengirim HTML awal agar halaman cepat tampil. Setelah JavaScript termuat, React melakukan hydrate: ia menghubungkan event handler dan state ke HTML yang sudah ada. Hydration berjalan baik bila render pertama di klien menghasilkan struktur dan konten yang sama dengan HTML dari server.
Pada UI chat/agent, drift muncul ketika sebelum atau saat hydration berlangsung, state klien sudah berubah. Contohnya:
- token pertama dari stream masuk dan langsung menambah isi balon chat, padahal server belum merender token itu,
- komponen membaca
localStorageuntuk memulihkan draft atau preferensi layout, - timestamp dirender dengan
Date.now()atau locale browser, - key list dibuat dari
Math.random(), - render bercabang berdasarkan
typeof windowatau kondisi environment lain.
Masalah utamanya bukan sekadar “data berubah”, tetapi data berubah terlalu cepat dan terlalu awal, sebelum React selesai menyamakan dunia server dan klien.
Gejala yang paling sering terlihat
1. Warning hydration di console
Gejala paling jelas adalah warning seperti teks tidak cocok, jumlah node berbeda, atau atribut tidak sama. Walau pesan pastinya bisa berbeda, inti error biasanya menunjuk ke mismatch antara HTML server dan render klien.
2. UI berkedip atau meloncat
Pesan placeholder dari server hilang lalu muncul ulang. Skeleton tiba-tiba diganti konten berbeda. Kadang input chat kehilangan fokus sesaat setelah halaman aktif.
3. Stream terasa dobel atau urutannya aneh
Jika token sudah ditampilkan oleh efek klien sementara hydration belum stabil, Anda bisa melihat sebagian isi pesan dirender dua kali atau potongan teks muncul dalam urutan yang tidak konsisten.
4. State lokal tidak sinkron
Draft yang dipulihkan dari localStorage bisa langsung menimpa isi SSR. Hasilnya, DOM awal berbeda dari yang diharapkan React saat hydrate.
Root cause: mengapa mismatch sering terjadi pada chat SSR
Streaming datang sebelum hydration selesai
Ini penyebab paling umum pada chat agent. Server merender daftar pesan A. Namun segera setelah bundle klien jalan, koneksi stream dibuka dan token baru mengubah pesan terakhir menjadi A+1 sebelum hydration selesai. React lalu membandingkan DOM server dengan VDOM klien yang sudah berbeda.
Membaca localStorage saat render awal
localStorage hanya tersedia di browser. Jika nilai awal state bergantung pada localStorage dan dipakai dalam render pertama, maka hasil render server dan klien hampir pasti berbeda.
Nilai waktu, locale, dan zona waktu
Timestamp relatif seperti “baru saja”, format jam lokal, atau tanggal yang dihitung saat render sering menghasilkan string berbeda antara server dan browser. Ini makin terasa jika server dan user berada di zona waktu berbeda.
Random value dan key yang tidak stabil
Menggunakan Math.random(), crypto.randomUUID(), atau generator ID lain langsung di render akan menghasilkan output yang tidak identik antara server dan klien. Jika ID dipakai sebagai key, React bisa salah memasangkan elemen list.
Conditional render berbasis environment
Pola seperti typeof window !== 'undefined' ? <A /> : <B /> sering tampak aman, tetapi justru menciptakan dua output berbeda. Jika cabang server dan klien menghasilkan struktur berbeda, hydration menjadi rapuh.
Prinsip utama: jaga shell SSR tetap deterministik
Untuk mencegah hydration drift, pisahkan UI menjadi dua lapis:
- Shell SSR deterministik: daftar pesan awal, struktur layout, placeholder, skeleton, dan elemen dasar yang hasilnya harus sama di server dan render pertama klien.
- Enhancement klien: pemulihan draft, auto-scroll, koneksi stream, status koneksi real-time, preferensi user dari storage, animasi, dan formatting yang bergantung browser.
Aturan praktisnya: kalau sebuah nilai tidak bisa dijamin sama antara server dan render pertama klien, jangan pakai nilai itu untuk menentukan output HTML awal.
Pola salah vs benar di Next.js/React
Pola salah: memulai stream dan membaca storage terlalu dini
"use client";
import { useEffect, useState } from "react";
export default function ChatPanel({ initialMessages }) {
const [messages, setMessages] = useState(() => {
const draft = localStorage.getItem("draft");
return draft
? [...initialMessages, { id: "draft", role: "user", content: draft }]
: initialMessages;
});
// Salah: stream langsung mengubah state segera setelah mount
useEffect(() => {
const es = new EventSource("/api/chat/stream");
es.onmessage = (event) => {
const token = event.data;
setMessages((prev) => {
const last = prev[prev.length - 1];
return [...prev.slice(0, -1), { ...last, content: last.content + token }];
});
};
return () => es.close();
}, []);
return (
<div>
{messages.map((m) => (
<p key={m.id}>{m.content}</p>
))}
</div>
);
}Ada dua masalah di sini. Pertama, render awal klien bergantung pada localStorage sehingga bisa berbeda dari SSR. Kedua, stream bisa mengubah isi pesan sebelum hydration stabil.
Pola benar: render awal identik, enhancement setelah mount
"use client";
import { useEffect, useRef, useState } from "react";
export default function ChatPanel({ initialMessages }) {
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
const [isHydrated, setIsHydrated] = useState(false);
const streamBufferRef = useRef([]);
useEffect(() => {
setIsHydrated(true);
}, []);
// Pulihkan state klien setelah mount, bukan saat render awal.
useEffect(() => {
if (!isHydrated) return;
const savedDraft = window.localStorage.getItem("draft") || "";
setDraft(savedDraft);
}, [isHydrated]);
// Tahan stream sampai hydration selesai.
useEffect(() => {
const es = new EventSource("/api/chat/stream");
es.onmessage = (event) => {
if (!isHydrated) {
streamBufferRef.current.push(event.data);
return;
}
applyToken(event.data);
};
return () => es.close();
}, [isHydrated]);
useEffect(() => {
if (!isHydrated || streamBufferRef.current.length === 0) return;
for (const token of streamBufferRef.current) {
applyToken(token);
}
streamBufferRef.current = [];
}, [isHydrated]);
function applyToken(token) {
setMessages((prev) => {
const last = prev[prev.length - 1];
if (!last || last.role !== "assistant") return prev;
return [...prev.slice(0, -1), { ...last, content: last.content + token }];
});
}
return (
<div>
{messages.map((m) => (
<p key={m.id}>{m.content}</p>
))}
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
</div>
);
}Pola ini bekerja karena render pertama klien memakai initialMessages yang sama dengan server. Semua sumber perbedaan baru diterapkan setelah mount.
Pola salah: nilai waktu dirender langsung
function MessageTime() {
return <span>{new Date().toLocaleTimeString()}</span>;
}Output server dan klien bisa berbeda beberapa detik, locale, atau zona waktu.
Pola benar: kirim nilai stabil dari server, format belakangan
function MessageTime({ isoTime }) {
return <time dateTime={isoTime}>{isoTime}</time>;
}Lalu, jika ingin format yang ramah user, lakukan peningkatan tampilan setelah mount atau format di server dan kirim string final yang tidak berubah.
Pola salah: key list acak
{messages.map((m) => (
<Message key={Math.random()} message={m} />
))}Ini membuat identitas elemen berubah terus. Pada chat streaming, efeknya bisa sangat buruk: posisi scroll, selection, dan state internal item menjadi tidak stabil.
Pola benar: gunakan ID yang berasal dari data
{messages.map((m) => (
<Message key={m.id} message={m} />
))}Strategi menunda render klien tanpa merusak UX
1. Gate dengan flag hydrated
Gunakan isHydrated untuk fitur yang memang hanya masuk akal di browser: stream live, draft dari storage, shortcut keyboard global, auto-focus, atau pengukuran ukuran elemen.
Jangan gunakan flag ini untuk menyembunyikan seluruh chat tanpa alasan. Target utamanya adalah bagian yang berpotensi menyebabkan mismatch, bukan mematikan manfaat SSR.
2. Buffer event streaming
Jika stream dapat mulai sangat cepat, tampung token sementara dalam buffer sampai hydration selesai. Setelah itu, flush buffer ke state secara berurutan. Pendekatan ini menjaga konsistensi tanpa kehilangan data.
3. Render placeholder yang stabil
Untuk pesan assistant yang sedang dibentuk, server bisa merender placeholder deterministik seperti balon kosong dengan indikator “menunggu respons”. Setelah klien siap, placeholder itu diisi token hasil stream. Yang penting, struktur awalnya sudah diketahui dan sama di dua sisi.
4. Pisahkan server data dan client state
Jangan campur initial server data dengan state browser dalam satu inisialisasi render. Simpan data server sebagai basis render awal, lalu terapkan patch klien di fase efek.
Checklist debugging hydration drift
Saat warning muncul, gunakan checklist berikut agar diagnosis lebih cepat:
- Bandingkan HTML awal server dengan render klien pertama. Fokus pada subtree chat, daftar pesan, timestamp, badge status, dan placeholder.
- Matikan stream sementara. Jika warning hilang, besar kemungkinan drift dipicu token yang masuk terlalu cepat.
- Cari pembacaan browser API saat render. Periksa
window,document,localStorage,matchMedia, ukuran viewport, dan sejenisnya. - Cari sumber nilai nondeterministik. Audit penggunaan
Date, random, UUID, dan formatter berbasis locale. - Periksa conditional render. Pastikan cabang server dan klien tidak menghasilkan struktur DOM berbeda pada render awal.
- Audit key pada list message. Key harus stabil dan berasal dari ID data, bukan index yang mudah bergeser atau nilai acak.
- Log urutan lifecycle penting. Catat kapan komponen mount, kapan stream tersambung, kapan token pertama masuk, dan kapan state berubah.
- Uji dengan jaringan cepat dan lambat. Race condition sering hanya terlihat pada kondisi tertentu: JS lambat tapi stream cepat, atau sebaliknya.
Teknik logging yang berguna
useEffect(() => {
console.log("[chat] mounted");
}, []);
useEffect(() => {
console.log("[chat] hydrated state changed:", isHydrated);
}, [isHydrated]);
function applyToken(token) {
console.log("[chat] token received:", token);
setMessages((prev) => {
console.log("[chat] before apply, last message:", prev[prev.length - 1]);
// update state...
return prev;
});
}Logging sederhana seperti ini membantu melihat apakah token pertama datang sebelum flag hydration aktif.
Menjaga shell SSR tetap deterministik pada chat agent modern
Pada arsitektur chat/agent modern, server sering merender riwayat percakapan dan klien melanjutkan interaksi secara real-time. Model ini baik, tetapi hanya jika kontrak SSR-nya jelas.
Berikut beberapa praktik yang layak dijadikan aturan tim:
- Server mengirim snapshot awal yang lengkap dan stabil. Misalnya daftar pesan dengan ID tetap, role, dan konten final yang sudah diketahui saat response SSR dibuat.
- Klien menganggap snapshot itu sebagai satu-satunya sumber kebenaran untuk render pertama.
- Semua patch setelah itu bersifat incremental. Streaming token, status tool call, indikator typing, dan metadata ephemeral diterapkan setelah hydration.
- Ephemeral UI tidak boleh mengubah struktur inti sebelum hydration selesai. Contohnya, jangan menambah node pesan baru dari stream tepat saat mount jika server belum merender placeholder-nya.
Prinsip yang paling aman: jika sebuah elemen penting akan segera diubah oleh stream, render dulu bentuk awalnya di server. Dengan begitu, klien hanya memperbarui isi, bukan mengubah struktur dasar DOM saat hydration berlangsung.
Kapan perlu menunda sebagian komponen sepenuhnya
Tidak semua bagian wajib ikut SSR. Untuk fitur yang sangat bergantung pada browser dan tidak penting bagi tampilan awal, Anda bisa menundanya sepenuhnya sebagai komponen klien setelah mount. Contohnya:
- panel debug token stream,
- grafik latensi lokal,
- preview attachment berbasis API browser,
- pengukur tinggi dinamis untuk auto-resize input.
Trade-off-nya adalah fitur tersebut tidak tersedia pada HTML awal. Ini biasanya dapat diterima jika bagian inti chat tetap SSR dan stabil.
Kesalahan umum yang sering luput
Menggunakan index sebagai key pada daftar pesan
Jika pesan bisa disisipkan, digabung, atau diperbarui oleh stream, index bukan identitas stabil. React dapat salah mempertahankan state komponen item.
Menggabungkan data stream ke object yang salah
Pada chat agent, token assistant sering dianggap selalu milik item terakhir. Ini gagal jika ada event lain masuk di tengah, misalnya status tool atau message placeholder baru. Pastikan token dipetakan ke message ID yang benar.
Format locale hanya di klien, tetapi teks sudah ada di server
Jika server merender “14:03” dan klien ingin menggantinya jadi format relatif “baru saja”, lakukan setelah hydration dan jangan ubah struktur inti yang sensitif.
Ringkasan solusi yang paling efektif
- Pastikan render pertama klien identik dengan HTML SSR.
- Jangan baca
localStorage, waktu lokal, random value, atau kondisi browser saat menentukan output render awal. - Tunda koneksi stream atau buffer token sampai hydration selesai.
- Gunakan placeholder atau shell SSR yang deterministik untuk pesan yang akan diisi secara streaming.
- Pakai key stabil berbasis ID data.
- Pisahkan server snapshot dari client enhancement.
Jika Anda sedang men-debug hydration drift pada UI streaming, fokuslah pada satu pertanyaan: apakah server dan render pertama klien benar-benar melihat dunia yang sama? Jika jawabannya tidak, perbaiki kontrak SSR lebih dulu. Setelah shell SSR stabil dan deterministik, fitur real-time seperti streaming token pada chat agent akan jauh lebih mudah dijaga tanpa warning, flicker, atau state yang kacau.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!