From d502a7eefdc7927f91a8789d058bcde289b7d089 Mon Sep 17 00:00:00 2001 From: Aditya Sundaram Date: Thu, 16 Jul 2026 17:02:12 -0700 Subject: [PATCH 1/6] docs: chat app recipe with streaming patterns --- docs/recipes/others/chat.md | 169 ++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/recipes/others/chat.md diff --git a/docs/recipes/others/chat.md b/docs/recipes/others/chat.md new file mode 100644 index 00000000000..4d36680bc2e --- /dev/null +++ b/docs/recipes/others/chat.md @@ -0,0 +1,169 @@ +```python exec +import reflex as rx +``` + +# Chat App + +A chat interface renders a running list of messages and lets the user send new ones. Each +message is a `{"role": ..., "content": ...}` dictionary held in state, and the component +loops over that list to draw bubbles. This recipe covers a basic chat UI and the +streaming patterns that keep the loading experience clean. + +## Basic Chat UI + +Store messages in state, append the user's message on submit, and produce a reply. The +example below echoes the input so it runs on its own; in a real app you would call your +model provider instead. + +```python demo exec +class ChatUIState(rx.State): + messages: list[dict[str, str]] = [] + + @rx.event + def send_message(self, form_data: dict): + text = form_data.get("message", "").strip() + if not text: + return + self.messages.append({"role": "user", "content": text}) + self.messages.append({"role": "assistant", "content": f"You said: {text}"}) + + +def message_bubble(message: dict[str, str]) -> rx.Component: + is_user = message["role"] == "user" + return rx.el.div( + rx.el.div( + message["content"], + class_name=rx.cond( + is_user, + "bg-blue-500 text-white rounded-lg px-4 py-2 max-w-md", + "bg-gray-100 text-gray-900 rounded-lg px-4 py-2 max-w-md", + ), + ), + class_name=rx.cond(is_user, "flex justify-end", "flex justify-start"), + ) + + +def chat_ui() -> rx.Component: + return rx.el.div( + rx.el.div( + rx.foreach(ChatUIState.messages, message_bubble), + class_name="flex flex-col gap-3 p-4 h-96 overflow-y-auto", + ), + rx.el.form( + rx.el.input( + name="message", + placeholder="Type a message...", + class_name="flex-1 border rounded-lg px-3 py-2", + ), + rx.el.button( + "Send", + type="submit", + class_name="px-4 py-2 bg-blue-500 text-white rounded-lg", + ), + on_submit=ChatUIState.send_message, + reset_on_submit=True, + class_name="flex gap-2 p-4 border-t", + ), + class_name="max-w-xl mx-auto border rounded-xl", + ) +``` + +## Streaming Responses + +When a model streams its reply token by token, show a loading indicator immediately and +append the assistant message only once the stream actually starts producing content. + +The key rule: **do not pre-append an empty assistant message** before the response +begins. Doing so renders a blank bubble alongside the loading dots, producing a broken +double-indicator experience. + +### Incorrect + +An empty assistant bubble appears next to the loading dots because it is appended before +any content arrives: + +```python +class ChatState(rx.State): + messages: list[dict[str, str]] = [] + is_streaming: bool = False + + @rx.event(background=True) + async def send_message(self, form_data: dict): + user_msg = form_data.get("message", "").strip() + if not user_msg: + return + + async with self: + self.messages.append({"role": "user", "content": user_msg}) + # BAD: appending an empty assistant message creates a blank bubble + self.messages.append({"role": "assistant", "content": ""}) + self.is_streaming = True + + client = openai.AsyncOpenAI() + stream = await client.chat.completions.create( + model="gpt-4o", + messages=[{"role": m["role"], "content": m["content"]} for m in self.messages[:-1]], + stream=True, + ) + async for chunk in stream: + delta = chunk.choices[0].delta.content or "" + async with self: + self.messages[-1]["content"] += delta + + async with self: + self.is_streaming = False +``` + +### Correct + +The loading dots show first, and the assistant bubble appears only on the first streamed +token: + +```python +class ChatState(rx.State): + messages: list[dict[str, str]] = [] + is_streaming: bool = False + + @rx.event(background=True) + async def send_message(self, form_data: dict): + user_msg = form_data.get("message", "").strip() + if not user_msg: + return + + async with self: + self.messages.append({"role": "user", "content": user_msg}) + self.is_streaming = True + yield # show the loading indicator immediately + + client = openai.AsyncOpenAI() + stream = await client.chat.completions.create( + model="gpt-4o", + messages=[{"role": m["role"], "content": m["content"]} for m in self.messages], + stream=True, + ) + + # Append the assistant message only after the stream starts producing content + first_token = True + async for chunk in stream: + delta = chunk.choices[0].delta.content or "" + if not delta: + continue + async with self: + if first_token: + self.messages.append({"role": "assistant", "content": delta}) + self.is_streaming = False # hide loading dots on the first token + first_token = False + else: + self.messages[-1]["content"] += delta + + async with self: + self.is_streaming = False +``` + +The differences that matter: + +1. Set `is_streaming = True` and `yield` to show the loading indicator right away. +2. Build the API request from `self.messages` directly — there is no trailing empty + message to slice off with `[:-1]`. +3. Append the assistant message only on the first token from the stream. +4. Hide the loading indicator on the first token, not at the end of the stream. From d1dfd4bcc8eb75558b08d24e4601ed64e048bc7f Mon Sep 17 00:00:00 2001 From: Aditya Sundaram Date: Thu, 16 Jul 2026 17:08:27 -0700 Subject: [PATCH 2/6] docs: ruff-format the chat recipe code blocks --- docs/recipes/others/chat.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/recipes/others/chat.md b/docs/recipes/others/chat.md index 4d36680bc2e..6d49eb9f809 100644 --- a/docs/recipes/others/chat.md +++ b/docs/recipes/others/chat.md @@ -102,7 +102,9 @@ class ChatState(rx.State): client = openai.AsyncOpenAI() stream = await client.chat.completions.create( model="gpt-4o", - messages=[{"role": m["role"], "content": m["content"]} for m in self.messages[:-1]], + messages=[ + {"role": m["role"], "content": m["content"]} for m in self.messages[:-1] + ], stream=True, ) async for chunk in stream: @@ -138,7 +140,9 @@ class ChatState(rx.State): client = openai.AsyncOpenAI() stream = await client.chat.completions.create( model="gpt-4o", - messages=[{"role": m["role"], "content": m["content"]} for m in self.messages], + messages=[ + {"role": m["role"], "content": m["content"]} for m in self.messages + ], stream=True, ) From 32622aaa5a352825ebba137c57d9d30d56b31e83 Mon Sep 17 00:00:00 2001 From: Aditya Sundaram Date: Thu, 16 Jul 2026 17:23:00 -0700 Subject: [PATCH 3/6] docs: fix chat recipe streaming (yield outside state lock, import openai, try/finally is_streaming) --- docs/recipes/others/chat.md | 84 +++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/docs/recipes/others/chat.md b/docs/recipes/others/chat.md index 6d49eb9f809..de69919d320 100644 --- a/docs/recipes/others/chat.md +++ b/docs/recipes/others/chat.md @@ -77,12 +77,22 @@ The key rule: **do not pre-append an empty assistant message** before the respon begins. Doing so renders a blank bubble alongside the loading dots, producing a broken double-indicator experience. +Both snippets below use the [`openai`](https://pypi.org/project/openai/) package and read +the API key from the `OPENAI_API_KEY` environment variable. They also run as +[background events](/docs/events/background-events/), so all state mutations happen inside +`async with self:` and every `yield` is placed **after** the block exits — holding the +state lock across a `yield` would block every other event handler until the stream +finishes. + ### Incorrect An empty assistant bubble appears next to the loading dots because it is appended before any content arrives: ```python +import openai + + class ChatState(rx.State): messages: list[dict[str, str]] = [] is_streaming: bool = False @@ -98,13 +108,15 @@ class ChatState(rx.State): # BAD: appending an empty assistant message creates a blank bubble self.messages.append({"role": "assistant", "content": ""}) self.is_streaming = True + request_messages = [ + {"role": m["role"], "content": m["content"]} + for m in self.messages[:-1] + ] client = openai.AsyncOpenAI() stream = await client.chat.completions.create( model="gpt-4o", - messages=[ - {"role": m["role"], "content": m["content"]} for m in self.messages[:-1] - ], + messages=request_messages, stream=True, ) async for chunk in stream: @@ -119,9 +131,13 @@ class ChatState(rx.State): ### Correct The loading dots show first, and the assistant bubble appears only on the first streamed -token: +token. The stream is wrapped in a `try/finally` so `is_streaming` always resets, even if +the API call raises: ```python +import openai + + class ChatState(rx.State): messages: list[dict[str, str]] = [] is_streaming: bool = False @@ -135,39 +151,45 @@ class ChatState(rx.State): async with self: self.messages.append({"role": "user", "content": user_msg}) self.is_streaming = True - yield # show the loading indicator immediately - - client = openai.AsyncOpenAI() - stream = await client.chat.completions.create( - model="gpt-4o", - messages=[ - {"role": m["role"], "content": m["content"]} for m in self.messages - ], - stream=True, - ) - - # Append the assistant message only after the stream starts producing content - first_token = True - async for chunk in stream: - delta = chunk.choices[0].delta.content or "" - if not delta: - continue + request_messages = [ + {"role": m["role"], "content": m["content"]} + for m in self.messages + ] + yield # flush outside the lock so the loading indicator shows now + + try: + client = openai.AsyncOpenAI() + stream = await client.chat.completions.create( + model="gpt-4o", + messages=request_messages, + stream=True, + ) + # Append the assistant message only once content starts arriving + first_token = True + async for chunk in stream: + delta = chunk.choices[0].delta.content or "" + if not delta: + continue + async with self: + if first_token: + self.messages.append( + {"role": "assistant", "content": delta} + ) + self.is_streaming = False # hide dots on first token + first_token = False + else: + self.messages[-1]["content"] += delta + finally: async with self: - if first_token: - self.messages.append({"role": "assistant", "content": delta}) - self.is_streaming = False # hide loading dots on the first token - first_token = False - else: - self.messages[-1]["content"] += delta - - async with self: - self.is_streaming = False + self.is_streaming = False ``` The differences that matter: -1. Set `is_streaming = True` and `yield` to show the loading indicator right away. +1. Set `is_streaming = True`, then `yield` **after** leaving `async with self:` to flush + the loading indicator without holding the state lock. 2. Build the API request from `self.messages` directly — there is no trailing empty message to slice off with `[:-1]`. 3. Append the assistant message only on the first token from the stream. 4. Hide the loading indicator on the first token, not at the end of the stream. +5. Wrap the stream in `try/finally` so `is_streaming` resets even when the API call fails. From 98976d6e373364bc92c8a2b8fb4d5d4e18373182 Mon Sep 17 00:00:00 2001 From: Aditya Sundaram Date: Thu, 16 Jul 2026 17:23:13 -0700 Subject: [PATCH 4/6] docs: register chat recipe in the Other recipes sidebar section --- .../templates/docpage/sidebar/sidebar_items/recipes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/recipes.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/recipes.py index dfec6460cfb..cf27fb217d2 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/recipes.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/recipes.py @@ -38,6 +38,7 @@ def get_sidebar_items_recipes(): recipes.others.chips, recipes.others.speed_dial, recipes.others.dark_mode_toggle, + recipes.others.chat, ], ), ] From 722007582a06a6ce14c2834cc2a9932fe45e4b26 Mon Sep 17 00:00:00 2001 From: Aditya Sundaram Date: Thu, 16 Jul 2026 17:27:49 -0700 Subject: [PATCH 5/6] docs: apply ruff-format to chat recipe streaming snippets --- docs/recipes/others/chat.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/recipes/others/chat.md b/docs/recipes/others/chat.md index de69919d320..5c276c42c8a 100644 --- a/docs/recipes/others/chat.md +++ b/docs/recipes/others/chat.md @@ -109,8 +109,7 @@ class ChatState(rx.State): self.messages.append({"role": "assistant", "content": ""}) self.is_streaming = True request_messages = [ - {"role": m["role"], "content": m["content"]} - for m in self.messages[:-1] + {"role": m["role"], "content": m["content"]} for m in self.messages[:-1] ] client = openai.AsyncOpenAI() @@ -152,8 +151,7 @@ class ChatState(rx.State): self.messages.append({"role": "user", "content": user_msg}) self.is_streaming = True request_messages = [ - {"role": m["role"], "content": m["content"]} - for m in self.messages + {"role": m["role"], "content": m["content"]} for m in self.messages ] yield # flush outside the lock so the loading indicator shows now @@ -172,9 +170,7 @@ class ChatState(rx.State): continue async with self: if first_token: - self.messages.append( - {"role": "assistant", "content": delta} - ) + self.messages.append({"role": "assistant", "content": delta}) self.is_streaming = False # hide dots on first token first_token = False else: From a131a483018f548934417f22cbb6822a0c18f435 Mon Sep 17 00:00:00 2001 From: Aditya Sundaram Date: Thu, 16 Jul 2026 18:51:32 -0700 Subject: [PATCH 6/6] Keep streamed tokens tied to their own assistant message --- docs/recipes/others/chat.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/recipes/others/chat.md b/docs/recipes/others/chat.md index 5c276c42c8a..c60037224e7 100644 --- a/docs/recipes/others/chat.md +++ b/docs/recipes/others/chat.md @@ -130,8 +130,12 @@ class ChatState(rx.State): ### Correct The loading dots show first, and the assistant bubble appears only on the first streamed -token. The stream is wrapped in a `try/finally` so `is_streaming` always resets, even if -the API call raises: +token. Two more details make this safe: the stream is wrapped in a `try/finally` so +`is_streaming` always resets even if the API call raises, and — because background events +run concurrently, so a second submit can start before the first reply finishes — an +`is_generating` guard serializes requests while the assistant message is updated by a +captured index rather than `self.messages[-1]`, which would otherwise point at whichever +message is currently last and let overlapping streams write to the wrong bubble: ```python import openai @@ -140,6 +144,7 @@ import openai class ChatState(rx.State): messages: list[dict[str, str]] = [] is_streaming: bool = False + is_generating: bool = False @rx.event(background=True) async def send_message(self, form_data: dict): @@ -148,6 +153,9 @@ class ChatState(rx.State): return async with self: + if self.is_generating: + return # a reply is still streaming; ignore overlapping submits + self.is_generating = True self.messages.append({"role": "user", "content": user_msg}) self.is_streaming = True request_messages = [ @@ -155,6 +163,7 @@ class ChatState(rx.State): ] yield # flush outside the lock so the loading indicator shows now + assistant_index = None try: client = openai.AsyncOpenAI() stream = await client.chat.completions.create( @@ -162,22 +171,22 @@ class ChatState(rx.State): messages=request_messages, stream=True, ) - # Append the assistant message only once content starts arriving - first_token = True async for chunk in stream: delta = chunk.choices[0].delta.content or "" if not delta: continue async with self: - if first_token: + if assistant_index is None: + # First token: create the bubble and capture its index self.messages.append({"role": "assistant", "content": delta}) + assistant_index = len(self.messages) - 1 self.is_streaming = False # hide dots on first token - first_token = False else: - self.messages[-1]["content"] += delta + self.messages[assistant_index]["content"] += delta finally: async with self: self.is_streaming = False + self.is_generating = False ``` The differences that matter: @@ -189,3 +198,6 @@ The differences that matter: 3. Append the assistant message only on the first token from the stream. 4. Hide the loading indicator on the first token, not at the end of the stream. 5. Wrap the stream in `try/finally` so `is_streaming` resets even when the API call fails. +6. Guard overlapping submits with `is_generating` and update the assistant message by its + captured index (not `self.messages[-1]`) — background events run concurrently, so a + second stream could otherwise append tokens to the wrong message.