~20 min · case-study, cwkpippa, streaming, sse, real-world
Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
This is the lesson where everything in the track lands at once. The async-suspense trio plus a tiny custom hook handles real production data — Dad chatting with Pippa over a streaming HTTP connection, messages arriving token-by-token, edits and regenerations changing the conversation tree under your feet.
The product surface
cwkPippa is a chat app. You type, you hit send, the assistant streams a response back. The response can be cancelled. Past turns can be edited (creating a branch). Any turn can be regenerated. The sidebar lists conversations; the main panel shows the active one. All four brains (Claude, Codex, Gemini, Ollama) share this surface — only the streaming backend differs.
The real-world async problem
A 'message' isn't a single fetch. It's a server-sent stream where:
The user message is local-only until accepted by the server (optimistic append).
The assistant message arrives in chunks (text deltas, tool_use blocks, thinking blocks).
Each chunk has to be written to JSONL before the UI sees it (the durability invariant — see docs/PIPPA-ARCHITECTURE.md).
The user can hit stop mid-stream — the in-flight request aborts but partial content stays.
On reconnect, the conversation is re-fetched from SQLite (the derived mirror) and any incomplete turn is healed from JSONL.
Layering the async-suspense primitives onto this real surface is what this case study shows.
The shape of useChat
One custom hook owns the conversation contract: messages, send, regenerate, edit, stop, isStreaming, error. Components don't know about fetch URLs, parent IDs, JSONL durability, or SSE parsing. They call useChat(conversationId) and render off the returned object. The hook is the boundary between the rest of the app and the streaming reality below.
Where each piece lives
Initial load — fetch(/api/conversations/:id), parses to typed Message[], populates state.
Send — optimistic local append of the user message, POST to the streaming endpoint, parse the SSE stream, append assistant tokens to a draft message in state, finalize on done event.
Stop — AbortController on the in-flight stream. Partial message stays as-is.
Error — surfaces via the hook's error field, also via the error boundary at the page root (network errors).
Healing — handled by the backend on the next GET (rebuilds incomplete turns from JSONL deltas); frontend just re-reads.
What Suspense looks like in this case
The initial conversation load uses Suspense — the page shell renders immediately, the message list region shows a skeleton until the fetch resolves. After that, streaming is an in-place state update — no further suspension. The chat doesn't flicker to a fallback on every send; that would be miserable UX.
Suspense is for boundaries, not for every state change. Use it at navigation/initial-load boundaries. Use plain state updates (or useTransition — Track 7) for ongoing in-place changes. The 'every change suspends' anti-pattern produces apps that flicker.
What this case study is teaching
Not 'how to stream tokens' — that's the implementation detail. The lesson is:
The custom hook is the right encapsulation for stateful streaming logic.
Suspense handles the bootstrap; in-place state updates handle the stream.
AbortController is mandatory the moment a user-initiated 'stop' exists.
The server is the source of truth; the client renders a derived view. Healing happens server-side.
Code
useChat — the shape (simplified from cwkPippa's actual hook)·tsx
import { use, useState, useRef, useCallback } from "react";
import { fetchConversation } from "@/lib/api";
import type { Message } from "@/types";
// Module-level cache so the same conversation id returns the same promise
// across renders (the use() cardinal rule from lesson 4).
const conversationCache = new Map<string, Promise<Message[]>>();
function getConversationPromise(id: string) {
if (!conversationCache.has(id)) {
conversationCache.set(id, fetchConversation(id));
}
return conversationCache.get(id)!;
}
export function useChat(conversationId: string) {
// Initial load via use() — suspends the first time, cached on repeat.
const initial = use(getConversationPromise(conversationId));
const [messages, setMessages] = useState<Message[]>(initial);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<Error | null>(null);
const ctrlRef = useRef<AbortController | null>(null);
const send = useCallback(async (text: string) => {
// Abort any in-flight stream first.
ctrlRef.current?.abort();
const ctrl = new AbortController();
ctrlRef.current = ctrl;
setError(null);
setIsStreaming(true);
// Optimistic append — user message appears instantly.
const userMsg: Message = { id: `local-${Date.now()}`, role: "user", content: text };
const draftAssistant: Message = { id: `draft-${Date.now()}`, role: "assistant", content: "" };
setMessages((m) => [...m, userMsg, draftAssistant]);
try {
const response = await fetch(`/api/chat`, {
method: "POST",
body: JSON.stringify({ conversationId, text }),
signal: ctrl.signal,
});
if (!response.body) throw new Error("no stream body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Parse SSE chunks, accumulate into the draft assistant message.
for (const event of parseSSE(chunk)) {
if (event.type === "delta") {
setMessages((m) =>
m.map((msg) =>
msg.id === draftAssistant.id
? { ...msg, content: msg.content + event.text }
: msg
)
);
} else if (event.type === "done") {
// Replace draft id with server-issued one.
setMessages((m) =>
m.map((msg) => (msg.id === draftAssistant.id ? { ...msg, id: event.messageId } : msg))
);
// Invalidate cache so the next mount re-fetches authoritative state.
conversationCache.delete(conversationId);
}
}
}
} catch (e) {
if ((e as Error).name !== "AbortError") setError(e as Error);
// Partial draft stays — server JSONL captured the same partial.
} finally {
setIsStreaming(false);
}
}, [conversationId]);
const stop = useCallback(() => ctrlRef.current?.abort(), []);
return { messages, send, stop, isStreaming, error };
}
function parseSSE(_chunk: string): Array<{ type: "delta"; text: string } | { type: "done"; messageId: string }> {
// Real parser handles partial lines, multiple events per chunk, JSON payloads.
// Simplified here for the case-study shape.
return [];
}
Build a useStream hook that streams from any URL returning a text stream (use any echo-stream service or build a fake one with setTimeout in your dev server). Pattern: use() for initial state, useState for the accumulating stream, AbortController for stop, optimistic append for user messages. Then wire it to a simple chat-shaped UI. The point isn't to match cwkPippa's exact API — it's to feel the seven concerns interlocking (suspense, use, state, ref, abort, optimistic, error).
Hint
If you skip AbortController, switching between two streams will leave the first one writing into your state while the second runs. The race produces interleaved output. AbortController fixes it cleanly.
Progress
Progress is local-only — sign in to sync across devices.