Building a Streaming Chatbot
The use_chat state logic makes it effortless to create a conversational user interface for your chatbot application. It enables the streaming of chat messages from your AI provider, manages the chat state, and updates the UI automatically as new messages arrive.
To summarize, the streaming chat setup provides the following features:
- Message Streaming: All the messages from the AI provider are streamed to the chat UI in real-time.
- Managed States: Your state class manages the values for input, messages, status, error and more for you.
- Seamless Integration: Easily integrate your chat AI into any design or layout with minimal effort.
In this guide, you will learn how to implement a chatbot application with real-time message streaming. Check out our chatbot with tools guide to learn how to use tools in your chatbot.
Example
The request flow works like this:
- The user submits a message and
send_messagetriggers a backend background task. - Your event handler calls the provider and receives a real-time generator stream.
- The state appends chunks to the last message as they arrive, yielding to trigger UI updates.
import reflex as rx
class ChatState(rx.State):
messages: list[dict] = []
status: str = "ready"
async def send_message(self, text: str):
self.messages.append({"role": "user", "content": text})
self.status = "submitted"
yield
# Request to the streaming handler
async for chunk in get_stream_response(self.messages):
if self.status == "submitted":
self.status = "streaming"
self.messages.append({"role": "assistant", "content": ""})
self.messages[-1]["content"] += chunk
yield
self.status = "ready"
yield
def chat_interface() -> rx.Component:
return rx.fragment(
rx.foreach(
ChatState.messages,
lambda msg: render_message(msg)
),
ChatInput(
on_submit=lambda text: ChatState.send_message(text),
disabled=ChatState.status != "ready"
)
)
import openai
async def get_stream_response(messages: list[dict]):
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model="gpt-4o",
messages=messages,
stream=True,
)
async for chunk in response:
content = chunk.choices[0].delta.content
if content is not None:
yield content
The UI messages have a new
partsproperty that contains the message parts. We recommend rendering the messages using thepartsproperty instead of thecontentproperty. The parts property supports different message types, including text, tool invocation, and tool result, and allows for more flexible and complex chat UIs.
In the Page component, the streaming hooks will request to your AI provider endpoint whenever the user sends a message using send_message. The messages are then streamed back in real-time and displayed in the chat UI.
Customized UI
You can also manage the chat message states via code, show status, and update messages without being triggered by user interactions.
Status
The state manages a status variable. It has the following possible values:
submitted: The message has been sent to the API and we're awaiting the start of the response stream.streaming: The response is actively streaming in from the API, receiving chunks of data.ready: The full response has been received and processed; a new user message can be submitted.error: An error occurred during the API request, preventing successful completion.
# Inside your layout component
rx.cond(
(ChatState.status == "submitted") | (ChatState.status == "streaming"),
rx.hstack(
rx.cond(ChatState.status == "submitted", Spinner()),
rx.button("Stop", on_click=ChatState.stop_stream)
)
)
Error State
Similarly, the error state reflects the error object thrown during the request. It can be used to display an error message, disable the submit button, or show a retry button:
We recommend showing a generic error message to the user, such as "Something went wrong." This is a good practice to avoid leaking information from the server.
# Inside your layout component
rx.cond(
ChatState.has_error,
rx.fragment(
rx.text("An error occurred."),
rx.button("Retry", on_click=ChatState.regenerate_last)
)
)
Cancellation and regeneration
It's also a common use case to abort the response message while it's still streaming back from the AI provider. You can do this by calling a cancel method that interrupts the async generator loop in your state.
rx.button(
"Stop",
on_click=ChatState.stop_stream,
disabled=~((ChatState.status == "streaming") | (ChatState.status == "submitted"))
)
API reference
use_chat config (State options)
Creates a chat helper state. All values are customizable; the defaults query the stream API and render at native generator speed.
| Prop | Type | Description |
|---|---|---|
transport |
ChatTransport |
How messages reach your API route |
messages |
list[dict] |
Initial messages to seed the conversation |
on_finish |
Callable[[str], None] |
Runs when the assistant response completes |
on_error |
Callable[[Exception], None] |
Runs when the stream connection fails |
throttle |
float |
Seconds to sleep between yields while streaming |
Event Callbacks
The chat state provides optional event callbacks that you can use to handle different stages of the chatbot lifecycle:
on_finish: Called when the assistant response is completed. The event includes the response message, all messages, and flags for abort, disconnect, and errors.on_error: Called when an error occurs during the fetch request.on_data: Called whenever a data part is received.
These callbacks can be used to trigger additional actions, such as logging, analytics, or custom UI updates.
class ConfiguredChatState(rx.State):
def on_finish(self, message: dict):
self.save_to_history(message)
def on_error(self, error: Exception):
logger.error(f"Chat stream failed: {error}")
Math
Display math sits in the flow rhythm and scrolls when it runs long. Inline math like eiπ+1=0 rides the line without stretching it.
Display
The quadratic formula, as a block:
Overflow
A long expansion scrolls inside its own box instead of breaking the column:
A Morning at the Letterpress Museum
The first thing you notice is the smell: machine oil, paper dust, and a century of ink that never fully dries. The second thing is the sound. A working letterpress shop is not quiet, and the museum on Greer Street keeps three presses working, because, as the docent told me within a minute of my arrival, a silent press is just furniture.
I went because I write software that arranges text on screens, and I had started to suspect that everything hard about my job had been solved a hundred years ago by people with steel tools and no undo. I left four hours later with ink on my sleeve and a notebook full of confirmations.
The composing room
Type lives in shallow wooden drawers called cases, and the layout of a case is itself a piece of interface design: the letters you reach for most sit nearest your hand, in the biggest compartments. Nobody alphabetized them. The arrangement was settled by frequency, argued over for a generation, and then never changed again, which is roughly the story of every good default I have ever shipped.
A compositor working at full speed sets about two thousand characters an hour, reading the manuscript with one eye while the other confirms each pick. The docent, a retired compositor named Ruth Okafor, demonstrated without looking down once. When I asked how long that took to learn she said, "The hands take a year. Knowing when a line is wrong takes ten."
Every em of space in this room is a physical object. You want more air between two lines, you go get the lead and you carry it back. It keeps your opinions about spacing very honest.
That line rearranged something in my head. The strips of lead that printers wedged between lines of type are why we still say leading. On my screen, spacing is a number I can change in a keystroke, and so I change it constantly, carelessly. Ruth's shop had exactly four widths of lead, and the whole trade agreed on them, and a hundred years of books came out beautiful anyway. Constraint was not the obstacle to the craft. It was the craft.
What the metal knows
Three things the metal insists on, which screens let us forget:
- Space is material. Word spaces, line leads, and margins are objects with widths. Nothing is "auto." Someone chose everything.
- Hierarchy is expensive. Changing size means walking to a different case. Printers built emphasis from weight and space first because size was the costly move, and their pages read better for it.
- The page is finished before it is printed. A locked-up chase either holds together or it doesn't. There is a satisfying finality to it that no deploy has ever given me.
None of this is nostalgia, or not only. The constraints were real costs, and digital type was right to remove them. But removal has a second-order effect: when nothing is expensive, nothing forces a decision, and unforced decisions drift. The printers' defaults survived because changing them was work. Ours have to survive on discipline, which is a weaker material.
Field notes
Practical things I wrote down, in the order I wrote them:
- Ruth sets solid (no leading) only for lines shorter than the alphabet. Anything longer gets air. Our line-length rules agree, which pleased me more than it should have.
- The shop's "house style" fits on an index card taped inside a cabinet door. Four leads, two faces, three sizes. An entire design system, physically enumerable.
- Apprentices learn distribution (putting type away) before composition. You learn a system by returning things to it.
The museum runs open studio on the first Saturday of every month, and they will let you set your own name if you ask. Mine came out crooked. Ruth looked at it for a moment and said it was a common beginner's error: I had been so careful choosing the letters that I forgot to check the spaces. I have been thinking about that all week.
The Greer Street Press Museum is open Thursday through Sunday. If you go, bring a jacket; the composing room is kept cold for the metal.
Changelog
v2.4.0
June 18, 2026
- Added:
store.batch(fn)groups multiple writes into a single notification. Listeners observe only the final state. - Added: a
nameoption for devtools traces; anonymous stores now display asstore#3instead ofundefined. - Changed: selectors are memoized per subscriber, cutting re-render counts roughly in half on wide stores.
- Fixed: subscribing during a notification no longer skips the next update.
- Fixed:
equalsis respected for the initialuseStoreread, matching the documented behavior.
Breaking changes
The deprecated store.update() alias is removed. Replace it with store.set(); the signature is identical:
- store.update((s) => ({ count: s.count + 1 }))
+ store.set((s) => ({ count: s.count + 1 }))
v2.3.1
May 30, 2026
- Fixed: a race where two synchronous writes in the same tick could notify in reverse order under React’s concurrent rendering.
- Docs: clarified that stores must be hoisted out of components, with a lint rule to catch it.
v2.3.0
May 12, 2026
- Added: React Native support;
useStoreno longer toucheswindow. - Deprecated:
store.update(), removed in v2.4.0. A console warning links to the migration note. - Performance: subscription bookkeeping moved from an array to a Set; unsubscribe is now O(1).
Platform sync: week 27
July 3, 2026 · 25 min · recording available
Decisions
- Ship the streaming endpoint behind a flag on Tuesday; full rollout gated on the p95 latency holding under 800ms for 48 hours.
- Adopt cursor pagination for the activity feed. Offset stays on the admin tables only, capped at page 500.
- Postpone the queue migration to Q3. Nobody could name a current failure it fixes.
Action items
- [x] Mia: flag config + kill switch for the streaming endpoint
- [ ] Devon: latency dashboard with the 800ms line drawn on it
- [ ] Sam: write the cursor encoding RFC, one page max
- [ ] Priya: close out the three stale runbook pages before Friday
Discussion
- Streaming rollout
- Retry behavior on disconnect is still client-defined; server sends
retry-afterbut nobody reads it. - Agreement: the SDK should honor it, apps that hand-roll fetch are on their own.
- Devon volunteered to add it to the SDK changelog as a “behavior change” callout.
- On-call load
- Pages are down 40% since the alert dedup work. Two of the remaining alerts are known-noisy and owned by nobody.
- Priya takes both; if they can’t be fixed in an hour each, they get deleted.
“If an alert has fired twelve times and been actioned zero times, it isn’t an alert, it’s a screensaver.”
1. Create typeset.css
Copy the stylesheet into a typeset.css file next to your main CSS file, then import it:
@import "tailwindcss";
@import "./typeset.css";2. Add the fonts
Copy this code into your main Reflex file to load your selected Google Fonts.
3. Create your custom typeset
Copy the custom typeset class below into your global stylesheet, then wrap your content in it: