Chat UI untuk AI adalah daftar pesan yang disimpan di state: tiap kali pengguna mengirim, pesannya langsung muncul, lalu pesan asisten tumbuh potongan demi potongan dari stream. Bayangin WhatsApp. Bubble-mu muncul seketika dengan ikon jam (masih mengirim), lalu muncul "sedang mengetik...", lalu balasannya. Kamu bisa scroll ke atas membaca chat lama tanpa ditarik paksa ke bawah. Pekerjaan utamanya ada dua: mengelola state percakapan, dan menampilkan streaming response dengan rapi.
🧪 Tebakan cepat: di dalam callback stream kamu menulis
setMessages([...messages.slice(0, -1), { ...terakhir, text: terakhir.text + delta }]), memakaimessagesdari render saat tombol Kirim ditekan. Setelah stream selesai, layar menampilkan jawaban utuh, atau cuma potongan terakhir? Tebak dulu, jawabannya ada di bagian "State: daftar pesan".
State: daftar pesan
Asisten Toko Buku menyimpan percakapan sebagai array object:
const [messages, setMessages] = useState([]); // { id, role, text, status }
const [streaming, setStreaming] = useState(false);
// ubah satu pesan: selalu dari state terbaru (prev), selalu object baru
const ubah = (id, fn) => setMessages((prev) => prev.map((m) => (m.id === id ? fn(m) : m)));
idyang stabil dipakai sebagaikey, bukan index array.role"user"atau"assistant", sama dengan role di pelajaran Prompt Engineering, jadi riwayatnya bisa dikirim ulang ke server.status:"sending"dan"sent"untuk pesan pengguna;"streaming","done","stopped", atau"error"untuk pesan asisten. UI membaca status ini untuk menampilkan jam, kursor, atau tombol Coba lagi.setMessages((prev) => ...)membaca state terbaru. Callback stream jalan berkali-kali setelah render tempat dia dibuat, jadimessagesdi closure-nya sudah basi. Kalau pakaimessagesitu, tiap potongan mulai dari array lama yang sama dan menimpa potongan sebelumnya: yang tersisa cuma potongan terakhir. Itu jawaban tebakannya.
Kirim: pesan pengguna muncul duluan
Optimistic update artinya UI menampilkan hasil yang diharapkan sebelum server menjawab. Bubble pengguna dan bubble asisten yang masih kosong ditambahkan seketika, baru request-nya dikirim:
async function kirim(teks) {
const user = { id: crypto.randomUUID(), role: "user", text: teks, status: "sending" };
const asisten = { id: crypto.randomUUID(), role: "assistant", text: "", status: "streaming" };
setMessages((prev) => [...prev, user, asisten]); // muncul di frame berikutnya
await jalankanStream([...messages, user], asisten.id, user.id);
}
Jalannya di Lab untuk "Stok Laskar Pelangi masih ada?" (server stand-in, jeda awal 350 ms, satu potongan 24 byte tiap 60 ms):
| Waktu | Yang tampil |
|---|---|
| 0 ms | bubble pengguna dengan "mengirim...", bubble asisten "sedang menulis..." |
| sekitar 350 ms | potongan pertama datang: status pengguna jadi terkirim |
| sekitar 410 ms | delta pertama utuh, **Laskar, muncul |
| sekitar 890 ms | 9 potongan, 8 delta: "Laskar Pelangi masih ada, stoknya 12. Harganya Rp89.000." status done |
Streaming token demi token
Bagian membaca stream memakai fungsi bacaSSE dari pelajaran Streaming Responses (satu TextDecoder dengan { stream: true } dan buffer sampai baris kosong). Server Toko Buku mengirim data: {"teks":"..."} per potongan:
const kontrol = useRef(null);
async function jalankanStream(riwayat, asistenId, userId) {
kontrol.current = new AbortController();
setStreaming(true);
try {
const res = await fetch("/api/assistant/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: riwayat.map(({ role, text }) => ({ role, content: text })) }),
signal: kontrol.current.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
ubah(userId, (m) => ({ ...m, status: "sent" }));
await bacaSSE(res.body, (ev) => ubah(asistenId, (m) => ({ ...m, text: m.text + ev.teks })));
ubah(asistenId, (m) => ({ ...m, status: "done" }));
} catch (err) {
ubah(asistenId, (m) => ({ ...m, status: err.name === "AbortError" ? "stopped" : "error" }));
} finally {
setStreaming(false);
}
}
- Tiap delta membuat object pesan baru (
{ ...m, text: m.text + ev.teks }), bukan mengubah yang lama. - Gagal di tengah jalan gak menghapus teks yang sudah datang; statusnya saja yang jadi
"error".
Stop dan Coba lagi
Tombol Stop memanggil kontrol.current.abort(). reader.read() menolak dengan AbortError, statusnya jadi "stopped", dan teks yang sudah datang tetap ada. Seperti di pelajaran Streaming, server ikut membatalkan request ke OpenAI saat koneksinya tertutup.
Kalau koneksi putus, bubble asisten menampilkan tombol Coba lagi. Coba lagi mengirim ulang riwayat yang sama dan mengganti bubble asisten yang gagal, bukan menambah bubble pengguna kedua:
function cobaLagi(asistenId) {
const i = messages.findIndex((m) => m.id === asistenId);
const user = messages[i - 1];
ubah(asistenId, (m) => ({ ...m, text: "", status: "streaming" }));
jalankanStream(messages.slice(0, i), asistenId, user.id);
}
Auto-scroll yang gak melawan pengguna
Pesan baru sebaiknya terlihat, tapi pengguna yang sedang membaca chat lama gak boleh ditarik ke bawah tiap ada token. Jadi scroll otomatis cuma kalau posisinya memang sudah dekat dasar:
const daftar = useRef(null);
const nempel = useRef(true); // apakah pengguna ada di dekat dasar
function onScroll(e) {
const el = e.currentTarget;
nempel.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
useEffect(() => {
if (nempel.current) daftar.current.scrollTop = daftar.current.scrollHeight;
}, [messages]);
Kalau pengguna sedang di atas, tampilkan tombol kecil "Pesan baru" yang membawanya ke bawah saat dia mau.
Menampilkan teks model dengan aman
Jawaban model adalah teks dari luar. Lewat prompt injection, misalnya ulasan pembeli yang dikutip asisten, isinya bisa memuat <img src=x onerror=alert(1)>. Tiga cara menampilkan:
- Teks biasa:
{m.text}di JSX. React selalu meng-escape teks, jadi tag apa pun tampil sebagai huruf. Paling aman, tapi**tebal**tampil apa adanya. - Markdown yang aman:
react-markdown(versi 10.1.0 di npm, Oktober 2026). README-nya menyebut library ini aman secara default karena gak memakaidangerouslySetInnerHTMLdan meng-escape HTML. Kalau kamu memakaimarked(README-nya memperingatkan bahwa marked gak membersihkan HTML), bersihkan hasilnya denganDOMPurify.sanitize()dari paketdompurify(3.4.16) sebelum dipasang. Contoh dari README DOMPurify:DOMPurify.sanitize('<img src=x onerror=alert(1)//>')menjadi<img src="x">. - Jangan
dangerouslySetInnerHTML={{ __html: m.text }}dengan teks mentah. Browser membuat elemenimgbeneran, danonerror-nya jalan.
Satu hal soal streaming: di tengah stream, markdown sering belum lengkap, misalnya **Laskar yang tanda tutupnya belum datang. Renderer yang baik menampilkannya sebagai teks dulu sampai pasangannya lengkap.
Aksesibilitas: diumumkan sekali, bukan per token
Screen reader membacakan perubahan di live region, elemen dengan aria-live. Pasang aria-live="polite" di daftar pesan, supaya pesan baru dibacakan tanpa memotong yang sedang dibaca. Tapi kalau tiap token diumumkan, pengguna mendengar potongan kata yang terputus-putus. MDN menyarankan aria-busy="true" selama elemen masih berubah, lalu false saat selesai, supaya teknologi bantu menunggu sampai isinya lengkap:
<div ref={daftar} onScroll={onScroll} aria-live="polite" aria-busy={streaming}>
{messages.map((m) => <Bubble key={m.id} pesan={m} />)}
</div>
Tombol Stop dan Coba lagi harus <button> yang bisa dicapai keyboard, dan kolom ketik punya label.
⚠️ Jebakan yang sering ketemu
messagesdari closure. Di callback stream, pakaisetMessages((prev) => ...), kalau gak, cuma potongan terakhir yang tersisa.- Mengubah object pesan di dalam updater.
updated[updated.length - 1].content += tokenmengubah object lama. React StrictMode menjalankan updater dua kali di development, jadi token bisa muncul dobel. Selalu buat object baru. key={index}. Saat pesan diganti (Coba lagi) atau dihapus, React bisa memasangkan state ke bubble yang salah. Pakai id.- Scroll paksa ke bawah. Cek dulu apakah pengguna ada di dekat dasar.
- HTML mentah dari model. Teks biasa,
react-markdown, atauDOMPurify.sanitize(); jangan pernahdangerouslySetInnerHTMLdengan teks asli model.
TL;DR: Chat UI = array pesan { id, role, text, status } di state, diubah dengan setMessages((prev) => ...) dan object baru. Kirim secara optimistic: bubble pengguna dan bubble asisten kosong muncul dulu, lalu stream dibaca dengan bacaSSE dan tiap delta ditambahkan ke pesan asisten. Stop memakai AbortController (status stopped, teks tetap ada); koneksi putus jadi status error dengan Coba lagi yang mengganti bubble asisten. Auto-scroll cuma kalau pengguna di dekat dasar. Teks model ditampilkan sebagai teks biasa, react-markdown, atau HTML yang dibersihkan DOMPurify. Daftar pesan pakai aria-live="polite" dan aria-busy selama streaming, supaya diumumkan sekali, bukan per token.