Skip to content
Consultation

Search Dialog to AI Thread: One Array, Seven useState Gone

Adityo Guni Waluyo

Follow-up questions wiped the previous answer. One ThreadEntry array with functional updates turned the single-slot dialog into a real thread.

I asked the Ask AI panel a question. The streaming answer came in clean, partial tokens rendering, source pills appearing, the whole thing felt alive. Then I typed a follow-up. Hit Enter. And the first answer vanished. Completely. Replaced by a fresh streaming response for question two, as if question one never happened.

That's when I started digging into how the search dialog managed state. Seven separate useState calls: one for the current question, one for the answer text, one for the streaming boolean, one for sources, one for follow-up suggestions, one for errors, and one for which view to show. Every new ask wiped and rebuilt most of these. There was no thread, just a single question-and-answer slot that got overwritten each time.

What I thought the fix needed

My first instinct was history. Obviously the dialog needed to remember previous exchanges somehow. That meant either a separate history array bolted alongside the existing state, or reaching for a proper state management library. I spent a decent chunk of an afternoon mentally comparing Zustand and Jotai, wondering which one would handle a growing list of thread entries without re-rendering the entire panel on every SSE delta.

Then I stopped and looked at what the thread actually needed to be. A list of turns. User asks, assistant answers, user asks again. Ordered. Append-only during a conversation, with in-place updates while streaming. That's not a job for a state library. That's an array.

One array, seven useState gone

The whole thing collapsed into a single useState holding an array of ThreadEntry objects. Each entry is either a user pill (the question text) or an AssistantEntry (streaming text, sources, follow-ups, error state, streaming flag). When a new question comes in, I append the user pill, then append a blank assistant entry and start streaming into it.

The key part: SSE deltas patch entries in place by id. React's updater function makes this safe even when patches arrive fast. patchEntry takes a functional update, so each call works off the latest state instead of stale closures. In code:

setEntries(prev => prev.map(e =>
  e.id === id ? { ...e, answer: e.answer + delta } : e
))

No race conditions, no lost tokens. React queues those updater functions and re-renders with each one sequentially [2], which matters when you're getting a token every few milliseconds over a stream.

When the user asks a second question before the first finishes, the code aborts the in-flight stream [1]. But the old assistant entry keeps its partial text. The streaming flag flips to false, and the response freezes mid-sentence where it was when the abort hit. It doesn't disappear. It doesn't get overwritten. It just stops.

Cleanup order matters more than the data structure

The sequence before appending anything new turned out to be the tricky part. Abort the current stream, mark the old assistant entry as done, then append the new user-assistant pair. Do it in the wrong order and you either overwrite partial text or fire a new stream before the old one actually cancelled.

Errors live inline per entry too. A 501 drops both the user question and its assistant response, and disables the ask input for that row. A 429 or stream failure renders as an error message inside the specific assistant entry that failed, not as a global banner. The rest of the thread stays untouched.

Typing into the question box never aborts anything. Search results stay visible, the thread stays alive, you can scroll up and read previous answers while composing the next one. It sounds obvious, but the old single-slot state made everything mutually exclusive.

Auto-scroll happens by setting scrollTop to scrollHeight whenever entries change. Simple, no library. The thread panel stays pinned to the bottom as new content streams in. Render order is just array order, and the browser handles the rest.

What surprised me is how little infrastructure a thread actually needs. One array, one functional update pattern, one abort-and-preserve sequence. The complexity was never in storing the history. It was in the cleanup choreography between stopping the old stream and starting the new one without losing anything in between. If you want the foundation first, I wrote about streaming AI answers through plain fetch; the dialog itself lives in the semantic related, TL;DR, and live search work.

Sources

Related articles