💪 Stream the comparison
The compare page works, but it's painful: the customer sends a question, the spinner sits there for 20–30 seconds, and then the whole table drops in at once. Nothing shows until the model has finished writing every last field.
Here's the thing — the server is already streaming the structured output down over SSE, token by token. The client just throws all of that away and waits for the validated
final object. There's no reason to wait: you can render the comparison as it arrives.useChat({ outputSchema }) hands you the in-flight state right next to final:final—Comparison | null. The validated object.nulluntil the model finishes.partial—DeepPartial<Comparison>. Updates on every JSON token; fields appear one at a time as they stream in.
🐨 What you will do
One change, in one file — that's the whole point of this exercise.
Open
app/components/comparison-chat.tsx and follow Kody 🐨: read partial alongside final, and render final ?? partial.Then open
app/components/comparison-table.tsx and just read it — you don't change a line. It already renders a PartialComparison, dropping in a <Skeleton /> for any field that hasn't arrived yet. That's why switching to streaming is a one-liner on your side: the table was built to cope with half-written data from the start.🦉 Why partial and final instead of just one
partial is parsed incrementally as JSON tokens arrive — it's the right thing to render while the model is writing. But it's a DeepPartial, so every field could be undefined and TypeScript can't guarantee anything.final only lands once the entire response has been validated against your Zod schema. It's a real Comparison with every required field present and constraints (.min(2), .int(), etc.) checked.Render from
final when available and from partial otherwise. final ?? partial does exactly that.📜 Docs
✅ How to know it is working
- Make sure at least two products are in your comparison list (the compare badge in the header should show ≥ 2).
- Open
/compareand send a question like "what's the best for everyday use?". - Within ~1–2 seconds, a table with skeleton placeholders appears — empty grey bars where the model hasn't filled fields in yet.
- Watch fields land one by one: the criteria line first, then each dimension, then each product's value, with rankings and the summary last.
- Once the stream completes, the skeletons are gone and the table is fully filled in —
finalhas taken over frompartial.
🚨 If you get stuck
- The table still doesn't appear until the stream finishes → check that
partialis in your destructure and thatliveisfinal ?? partial(not justfinal). - TypeScript complains about
undefinedfields somewhere → make sure you're passinglive(notfinal) to<ComparisonTable>. The table already toleratespartial'sundefineds; your job is just to hand it the in-flight value. - The page flashes an empty table on first paint, then jumps in → that's what the
Object.keys(live).length > 0guard (already wired) prevents: it keeps the spinner up until the first parseable token arrives.