AI Integrations
The AI UI components are built for AI-first React Native apps. Paired with our real-time Chat API, they make it easier to render LLM responses (ChatGPT, Gemini, Anthropic, or your backend) with rich UI: markdown, syntax highlighting, tables, thinking indicators, charts, and more.
Core components include:
StreamingMessageView- renders text/markdown/code in real time with a typewriter effectComposerView- a fully featured prompt composer with attachments and speech inputSpeechToTextButton- a reusable button that records voice input and streams the recognized transcript back into your UIAITypingIndicatorView- a component that can display different states of the LLM (thinking, checking external sources, etc)VoiceWaveandVoiceGlow- animated visualizations of what a voice agent is doing, with theuseDictationAudioLevelhook to move them with the microphone
We'll keep adding components. If you want to see something added, open an issue.
The components ship with the SDK, in a separate entry point: stream-chat-react-native/ai-components, or stream-chat-expo/ai-components with Expo. Apps that never import it don't bundle any of it, and apps that only use the AI components don't bundle the rest of the chat SDK.
You can find a complete ChatGPT clone sample that uses these components here.
Installation
The AI components are available from version 9.10.0 of stream-chat-react-native and stream-chat-expo. Their JavaScript dependencies are installed with the SDK, so there's no separate package to add.
Charts and the voice visualizations draw with Skia. They need two native libraries, which only apps that use the AI components have to install:
yarn add victory-native @shopify/react-native-skiaWith Expo, use npx expo install victory-native @shopify/react-native-skia instead. react-native-reanimated, react-native-gesture-handler and react-native-svg are already required by the SDK.
The components depend on zod, which uses export * as syntax that the React Native Babel preset doesn't transform. Install the export-namespace-from plugin:
yarn add -D @babel/plugin-transform-export-namespace-fromAnd add it to babel.config.js:
module.exports = {
presets: ["module:@react-native/babel-preset"],
plugins: [
// ... rest of your @babel plugins
"@babel/plugin-transform-export-namespace-from",
// react-native-worklets/plugin has to be the last one
"react-native-worklets/plugin",
],
};Then import the components from the AI entry point:
// React Native CLI
import {
ComposerView,
StreamingMessageView,
} from "stream-chat-react-native/ai-components";
// Expo
import {
ComposerView,
StreamingMessageView,
} from "stream-chat-expo/ai-components";Speech-to-text
SpeechToTextButton, and the microphone button of ComposerView, use the platform speech recognizers. Each platform needs its own setup.
iOS
Speech-to-text is opt-in on iOS. App Store Connect requires a speech recognition usage description from every app whose binary references the Speech framework, so the SDK only compiles the speech-to-text module into apps that ask for it. That keeps apps that don't use it from being rejected for a permission they never request.
To enable it, add this line to your Podfile, before use_native_modules!, and run pod install:
$StreamChatEnableDictation = trueRunning STREAM_CHAT_ENABLE_DICTATION=1 pod install works as well.
Then add the usage descriptions to Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) would like to access your microphone to capture your voice.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice.</string>Choose any permission strings you want.
Without the opt-in, the microphone button is hidden and a development-only warning explains how to enable it. isDictationAvailable() tells you at runtime whether speech-to-text is part of the build.
Android
Add the permission to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO" />Expo
Add the stream-chat-expo config plugin to your app.json/app.config.[js|ts]. It enables the iOS speech-to-text module, adds the iOS usage descriptions and adds the Android permission:
"plugins": [
// ... rest of your plugins
[
"stream-chat-expo",
{
"dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.",
"dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice."
}
]
],Both descriptions are optional, and the plugin uses default texts when you leave them out. Pass "enableDictation": false to leave speech-to-text out of the app. The plugin applies on npx expo prebuild, so run it again after changing the options.
If you maintain the native projects yourself instead of generating them with prebuild, follow the iOS and Android steps above.
Migrating from @stream-io/chat-react-native-ai
The components used to ship as the standalone @stream-io/chat-react-native-ai package, which is now deprecated. Their API is unchanged. To migrate:
-
Uninstall
@stream-io/chat-react-native-aiand import from the new entry point:- import { ComposerView, StreamingMessageView } from "@stream-io/chat-react-native-ai"; + import { ComposerView, StreamingMessageView } from "stream-chat-react-native/ai-components";With Expo, import from
stream-chat-expo/ai-components. -
Enable speech-to-text on iOS, as described in Speech-to-text. With Expo, replace the
@stream-io/chat-react-native-aiconfig plugin withstream-chat-expo. -
Keep the
export-namespace-fromBabel plugin. The@babel/plugin-proposal-export-namespace-frompackage you may already have works too. -
ShimmeringViewandtoHexFromRNColorwere removed.AITypingIndicatorViewnow uses the SDK's native shimmer, so@shopify/react-native-skiais only needed for charts. -
SpeechToTextButtonandisDictationAvailableare now exported.
Optional features
Optional dependencies enable extra features.
Media Picker
Media picker lets users select images or take photos.
The SDK supports two libraries:
react-native-image-picker
This RN CLI library is meant to be used in vanilla React Native projects.
To install it, you can run:
yarn add react-native-image-pickerFor image capture, add these permissions:
Info.plist:
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) would like to use your camera to share an image in a message.</string>AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />Respectively.
expo-image-picker
This Expo library is meant to be used in Expo projects.
To install it, you can run:
npx expo install expo-image-pickerThen, you can refer to their documentation about adding permissions and add the photosPermission and cameraPermission fields.
Clipboard
Clipboard support lets users copy code blocks from rendered markdown.
The SDK has built-in support for 2 libraries that allow you to achieve this:
@react-native-clipboard/clipboard(for RN CLI apps)expo-clipboard(forExpoapps)
Components
All components integrate with the React Native Chat SDK. See the developer guide to get started.
Wrap them in StreamTheme so theming is applied. Place it high in your component tree.
StreamingMessageView
StreamingMessageView renders markdown efficiently with syntax highlighting for common languages. It supports standard markdown: tables, inline code, headings, lists, etc.
It uses a letter-by-letter typewriter animation similar to ChatGPT.
| Name | Type | Required | Description |
|---|---|---|---|
text |
string |
yes | The text we want to pass as markdown. |
paragraphNumberOfLines |
number |
no | The numberOfLines applied to markdown Paragraph and Text components. Particularly useful if we want to display the same message, but in a "cut" fashion (for example when replying to someone). |
rules |
MarkdownRules |
no | An object of MarkdownRules that is then going to be deeply merged with our default rules, based on the SimpleMarkdown parsing engine. Can be used to add custom rules or change existing rules. You can disable a rule by passing { [ruleName]: { match: () => null }}. |
onLink |
(url: string) => void |
no | A function that is going to be invoked whenever a link is pressed within markdown parsed text. |
letterInterval |
number |
no | A number signifying the interval at which the typewriter animation is going to render characters. Defaults to 0 |
renderingLetterCount |
number |
no | A number signifying the number of letters that are going to be rendered per tick of the interval during the typewriter animation. Defaults to 2 |
Example
Example:
const markdownText = ```
# Heading
some text
## Another heading
```;
<StreamingMessageView
text={markdownText}
letterInterval={5} // every 5ms
renderingLetterCount={3} // render 3 letters at a time
/>;AITypingIndicatorView
The AITypingIndicatorView is used to represent different states of the LLM, such as Thinking, Checking External Sources and so on, depending on the states you've defined on your backend. The only thing that needs to be passed to the component is the text property, which will then be displayed with a shimmering animation.
| Name | Type | Required | Description |
|---|---|---|---|
text |
string |
yes | The text we want to be displayed inside of the view. |
Example
<AITypingIndicatorView text={"Thinking of an answer..."} />ComposerView
ComposerView provides a modern composer with attachments, a bottom sheet, speech-to-text, and a send button.
| Name | Type | Required | Description |
|---|---|---|---|
onSendMessage |
(opts: { text: string; attachments?: MediaPickerState['assets']; custom?: Record<string, unknown>; }) => Promise<void> |
yes | A callback that will be invoked whenever the send button is pressed. The text, attachments and any custom data we've added to the state will be passed to it. |
bottomSheetOptions |
BottomSheetOption[] |
no | An array of BottomSheetOption objects that will render the extra options in the bottom sheet. |
bottomSheetInsets |
{ top: number; bottom: number; left: number; right: number } |
no | An object containing extra insets we can pass to the ComposerView in order to make sure the bottom sheet can extend properly beyond them. |
isGenerating |
boolean |
no | A boolean signifying whether the LLM is currently generating a response or not. It will be used to render the stop-generating button in the composer instead of the send button whenever this happens. |
stopGenerating |
() => Promise<void> |
no | A callback that is going to be invoked if the stop-generating button is clicked. |
mediaPickerService |
AbstractMediaPickerService |
no | An instance of the MediaPickerService we may decide to inject from the outside for more fine-grained control over attachment state. You can create an instance as const customInstance = MediaPickerService() and it will automatically detect which library you're using. |
state |
StateStore<ComposerState> |
no | A state store of the ComposerState we may decide to inject from the outside for more fine-grained control over the composer state. You can create an instance as const customComposerState = createNewComposerStore(). |
Example
import { Alert } from "react-native";
import { Flag, PhotoIcon } from "stream-chat-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const options = [
{
title: "Create Image",
subtitle: "Visualize anything",
action: () => Alert.alert("Pressed on Create Image !"),
Icon: PhotoIcon,
},
{
title: "Thinking",
subtitle: "Think longer for better answers",
action: () => Alert.alert("Pressed on Thinking !"),
Icon: Flag,
},
];
const insets = useSafeAreaInsets();
<ComposerView
onSendMessage={sendMessage}
bottomSheetOptions={options}
bottomSheetInsets={insets}
/>;SpeechToTextButton
The SpeechToTextButton turns voice input into text using native implementations of the iOS and Android speech frameworks, respectively. When tapped it asks for microphone access, records audio and forwards the recognized transcript to the ComposerState directly.
It uses the useDictation hook under the hood, which can also be used without the button as well for voice transcription purposes outside of the button.
It takes a single property named options that has the following keys:
| Name | Type | Required | Description |
|---|---|---|---|
language |
string |
no | The language we want to transcribe from. It will default to en-US. |
interimResults |
boolean |
no | A boolean signifying whether we want to receive the intermediate results from the transcription or just the final result when the transcription is deemed done. Defaults to true |
silenceTimeoutMs |
number |
no | A number signifying the number of milliseconds of silence until transcription is deemed finished. Defaults to 2500. |
const options = {
language: 'de-DE', // set the language to german
interimResults: false, // disable intermediate results and only use the final results
silenceTimeoutMs: 3500 // set the silence timeout to 3.5 seconds
}
<SpeechToTextButton options={options} />The SpeechToTextButton is already integrated within the ComposerView, however feel free to use it elsewhere as well. It renders nothing when speech-to-text isn't part of the build, for example on iOS without the opt-in.
VoiceWave
VoiceWave shows what a voice agent is doing as a single string carrying a wave packet, which erupts while the agent speaks. It's the React Native counterpart of AIVoiceWave in the Android and iOS AI components and VoiceWave on the web, with the same motion. Everything in this section also applies to VoiceGlow, which takes the same props. Both are available from version 9.11.0.
| Name | Type | Required | Description |
|---|---|---|---|
state |
VoiceAgentState |
yes | What the agent is doing: 'idle', 'connecting', 'listening', 'thinking' or 'speaking'. The states differ only in motion, never in color, and a change eases from one motion into the next. |
colors |
string[] |
no | One to five colors that flow through the shape, in any format React Native understands. Colors past the fifth, and colors that can't be read, are ignored. When omitted or empty, the theme's voiceVisualization.colors are used, which default to the accent color. |
expressivity |
number |
no | From 0 to 2: how far and how fast it moves. Colors never change with it. Defaults to 1. |
audioLevel |
SharedValue<number | null> |
no | The level of whoever is talking, from 0 to 1, as a Reanimated shared value. It's read once a frame on the UI thread, so a level that changes many times a second re-renders nothing. Update it at 15 Hz or more to follow the words. null, or leaving it out, animates speech on its own. |
stateLabels |
Partial<Record<VoiceAgentState, string>> |
no | What screen readers read as the value for each state, for example to translate them. Defaults to "Ready", "Connecting", "Listening", "Thinking" and "Speaking". |
...ViewProps |
no | Any other View prop (style, testID, accessibilityLabel...) is passed to the root view. |
Each state has its own motion:
idle- waiting to be spoken to: a calm, barely-there shimmer.connecting- not ready yet: a loader sweep with a pulsing breath.listening- hearing somebody: a gentle expand and contract.thinking- working on an answer: a slow turn or a travelling wave.speaking- talking: strong motion that ebbs and pauses like speech.
To move with the user's voice while they dictate, pass the shared value useDictationAudioLevel returns. For any other source, such as a remote participant's audio level from Stream Video, keep a shared value of your own and write the level into it.
Size
Neither component has a size of its own. They draw in the middle of the view you give them, so size them with style: VoiceWave is about 80% as wide as the view's shorter side, and VoiceGlow about half. Give the glow a square and the wave a square or a wide box.
Behavior
- It runs on the UI thread. The motion is advanced in a Reanimated frame callback and drawn by Skia, so it stays smooth while the JS thread is busy, for example while a long answer streams in.
- Reduced motion. With Reduce Motion on iOS, or animations turned off on Android, the motion slows to a crawl and taps are ignored.
- Only the shape takes touches. Over the string or, for
VoiceGlow, the globe, a tap bounces the shape and sends out a ripple, and a hovering mouse or pen ripples it too. Touches are only observed, never claimed, so aPressablearound the visualization still gets its press. Beside the shape, touches reach whatever is behind it. - It follows the theme. The default colors come from the theme's
voiceVisualization.colors, and the light or dark look fromvoiceVisualization.backdropColor, or the system color scheme when it isn't set. See Theming. - It fades in. The first frame fades in over 0.3 seconds, so the component never flashes empty while it starts.
- It keeps running while mounted. Unlike on iOS and the web, it doesn't pause while scrolled off screen; it only pauses while the app is in the background. Unmount it when it isn't needed.
Accessibility
The root is an image named "Voice agent", with the state as its value: "Ready", "Connecting", "Listening", "Thinking" or "Speaking", as on iOS and Android. To translate them, pass stateLabels for the state values and accessibilityLabel for the name. accessibilityValue replaces the value outright.
<VoiceGlow
accessibilityLabel={t("Voice agent")}
state={state}
stateLabels={{
connecting: t("Connecting"),
idle: t("Ready"),
listening: t("Listening"),
speaking: t("Speaking"),
thinking: t("Thinking"),
}}
/>Example
import { VoiceWave } from "stream-chat-react-native/ai-components";
<VoiceWave
colors={["#005fff", "#7c3aed"]}
expressivity={1.4}
state="listening"
style={{ height: 96, width: "100%" }}
/>;VoiceGlow
VoiceGlow shows the same states as a soft globe whose color masses orbit inside it and swell with speech. It's the React Native counterpart of AIVoiceGlow in the Android and iOS AI components and VoiceGlow on the web, and it takes the VoiceWave props.
The glow is drawn by a Skia shader that computes every pixel: noise-marbled color masses, concentric waves, light and a halo, with ripples from a tap or a hovering pointer. It's the same shader as Android's, and every glow shares one compiled copy. Skia runs it on every Android version, so unlike Android's AIVoiceGlow there is no Canvas fallback and no rendering option.
Example
import { VoiceGlow } from "stream-chat-react-native/ai-components";
<VoiceGlow
accessibilityLabel="Assistant"
state="thinking"
style={{ width: 240, height: 240 }}
/>;useDictationAudioLevel
useDictationAudioLevel gives the microphone's live level while dictation is listening, for the audioLevel prop of VoiceWave and VoiceGlow. It works with the SpeechToTextButton and the useDictation hook, and needs speech-to-text to be set up.
const useDictationAudioLevel: () => SharedValue<number | null>;It returns a Reanimated shared value holding the level, from 0 to 1, so nothing re-renders as the level changes. The value is null while dictation isn't listening, and the visualization then animates speech on its own. The native side measures the level about 10 to 30 times a second: on Android from the speech recognizer, and on iOS from the microphone input.
Example
Show the user's voice while they dictate:
import { Pressable } from "react-native";
import {
useDictation,
useDictationAudioLevel,
useDictationState,
VoiceWave,
} from "stream-chat-react-native/ai-components";
const DictationWave = () => {
const { toggle } = useDictation();
const { isRecording } = useDictationState();
const level = useDictationAudioLevel();
return (
<Pressable onPress={toggle}>
<VoiceWave
audioLevel={level}
state={isRecording ? "listening" : "idle"}
style={{ height: 96, width: "100%" }}
/>
</Pressable>
);
};Theming
Each one of the components in the SDK is fully theme-compatible. The StreamTheme provider takes care of this for you.
In order to modify the theme, you may refer to our full fledged theme object as seen here.
Example
In the example below, we introduce a dark color scheme through the theming system.
const customTheme = {
colors: colorScheme === 'dark'
? {
accent_blue: '#4C9DFF',
accent_red: '#FF636E',
black: '#FFFFFF',
code_block: '#1E1E22',
grey: '#A1A1AA',
grey_neutral: '#C5C5C8',
grey_dark: '#71717A',
grey_gainsboro: '#3F3F46',
grey_whisper: '#27272F',
overlay: '#000000CC',
transparent: 'transparent',
white: '#050509',
white_smoke: '#121214',
shimmer: '#FFFFFF',
} : {}
}
<StreamTheme style={customTheme}>{children}</StreamTheme>VoiceWave and VoiceGlow read the voiceVisualization keys: colors, one to five colors used when a visualization has no colors of its own, and backdropColor, the color they sit on, which tells them to draw for a light or a dark background. Without backdropColor they follow the system color scheme.
<StreamTheme
style={{
voiceVisualization: {
backdropColor: "#0b0b10",
colors: ["#005fff", "#7b5cff", "#00c2ff"],
},
}}
>
{children}
</StreamTheme>Best practices
- Keep AI components wrapped in
StreamThemefor consistent styling. - Stream tokens progressively to
StreamingMessageViewto avoid large re-renders. - Gate speech-to-text behind explicit permissions and user consent.
- Use a single source of truth for LLM state to drive typing indicators.
- Test markdown rendering with long responses and code blocks.
Mark assistant messages with ai_generated
The Chat SDK treats a reply from your AI bot like any other message. To render it with StreamingMessageView and its character-by-character animation, the app needs a way to tell AI replies apart from the rest.
The convention used by the AI components, the sample apps and the Stream backend SDKs is a custom field, ai_generated: true, set by the backend when it creates the assistant's placeholder message:
const { message } = await channel.sendMessage({
text: "",
user_id: botId,
ai_generated: true,
});placeholder = channel.send_message(
MessageRequest(text="", user_id=bot_id, custom={"ai_generated": True}),
).data.messageIn React Native the field arrives as message.ai_generated. Read it in a function and pass that function to Chat as isMessageAIGenerated:
import type { LocalMessage } from "stream-chat";
import { Chat } from "stream-chat-react-native";
// Defined once, outside any component.
const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated;
const App = () => (
<Chat client={client} isMessageAIGenerated={isMessageAIGenerated}>
{/* ... */}
</Chat>
);With TypeScript, message.ai_generated only compiles once you declare it on CustomMessageData, as described in TypeScript. Declare generating there too:
import type { DefaultMessageData } from "stream-chat-react-native";
declare module "stream-chat" {
interface CustomMessageData extends DefaultMessageData {
ai_generated?: boolean;
generating?: boolean;
}
}The flag matters in three places:
- Rendering. For AI messages,
MessageContentrenders theStreamingMessageViewcomponent in place ofMessageTextContainer. This is what gives the reply its character-by-character animation. The SDK ships a defaultStreamingMessageView. To render the markdown view from the AI components instead, override it throughWithComponentsas shown in the SDK integration. A message without the flag renders as a regular text bubble that jumps to the new text on every update. - The "Edited" label. Every update the backend makes to the text counts as an edit.
MessageFooterhides the label for AI messages, so there's nothing to configure. - Your backend. The bot usually listens for new messages in the channel. Checking
ai_generatedlets it skip its own replies instead of answering itself.
Two details make the flag silently stop working:
- Set it on
Chat, notChannel.Channelaccepts anisMessageAIGeneratedprop too, but always replaces it with the value from theChatcontext, so a function set only onChannelis ignored. The SDK also isn't guaranteed to pick up a new function on a later render, so define it once, outside your components, and base it only on the message. - Keep
'ai_text'inmessageContentOrder. AI messages render their text through the'ai_text'entry, and the regular'text'entry skips them. If you pass your ownmessageContentOrdertoChannel, include'ai_text', or AI replies render without any text.
The second field the backend sets is generating: true on every interim update and false on the final one. Neither StreamingMessageView, the SDK's default or the one from the AI components, reads it. The animation follows the text alone: the view shows whatever text the message has when it mounts, then types out only what's added after that. That's why stored replies don't replay the animation when the user scrolls back to them, and why someone who opens the channel mid-reply sees the text so far at once. generating arrives with every message update, so read it in your own components when they need to know whether a reply is still in progress.
Drive the AI indicator from events
Before the first token arrives, the LLM can spend a while thinking or calling tools, and the message is still empty. The backend reports what it's doing with ai_indicator.* custom events on the channel, and they reach the app as regular stream-chat channel events:
| Event type | Sent by | Use it to |
|---|---|---|
ai_indicator.update |
Backend | Show the current state of the LLM. Carries the ai_state, the cid, the message_id of the reply and an optional ai_message. |
ai_indicator.clear |
Backend | Hide the indicator. Sent after the final text is stored. |
ai_indicator.stop |
App | Ask the backend to stop the reply. See Let users stop the response. |
useAIState(channel) from stream-chat-react-native listens for the first two and returns the current state as aiState. The SDK's own AITypingIndicatorView and the MessageComposer stop button are built on it, and the SDK integration shows how to drive the AI components' AITypingIndicatorView with it.
Both entry points export an AITypingIndicatorView and a StreamingMessageView, with different props. The ones from stream-chat-react-native read the AI state and the message themselves, while the ones from stream-chat-react-native/ai-components take the text to show. The sample app imports useAIState and AIStates from the first and both views from the second.
Compare aiState with the AIStates constants. The "SDK default" column is what the SDK's AITypingIndicatorView (if you render it) and MessageComposer show:
ai_state |
AIStates |
SDK default | Suggested UI |
|---|---|---|---|
AI_STATE_THINKING |
AIStates.Thinking |
"Thinking..." and the stop button | <AITypingIndicatorView text="Thinking" /> and the stop button |
AI_STATE_EXTERNAL_SOURCES |
AIStates.ExternalSources |
No indicator, no stop button | <AITypingIndicatorView text="Checking external sources" /> and the stop button |
AI_STATE_GENERATING |
AIStates.Generating |
"Generating..." and the stop button | The text is now streaming into the message, so the indicator is optional. Keep the stop button. |
AI_STATE_ERROR |
AIStates.Error |
No indicator, no stop button | Hide the indicator. The backend writes the error into the message itself. |
AI_STATE_IDLE |
AIStates.Idle |
No indicator, no stop button | Nothing. The backend SDKs never send it: it's where useAIState starts, and where a clear takes it. |
A typical reply sends AI_STATE_THINKING right after the placeholder, AI_STATE_EXTERNAL_SOURCES while a tool call runs, AI_STATE_GENERATING with the first token, and ai_indicator.clear once the final text is stored.
A few things about useAIState and the defaults aren't obvious:
- It only knows about events it has seen. Every call starts at
AI_STATE_IDLEwhen its component mounts and changes only when an event arrives. It goes back to idle onai_indicator.clearand when the connection drops, but not when you pass it a different channel: it subscribes to the new channel and keeps the old state until that channel sends an event. The sample app remounts its channel screen withkey={channel.id}, which also resets the state. Do the same if one screen shows several channels in turn. - An error isn't followed by a clear. The Stream Chat AI SDK and the Stream Chat LangChain SDK send
AI_STATE_ERRORand then write the error into the message without sendingai_indicator.clear, soaiStatestaysAI_STATE_ERRORuntil the next reply starts. Treat it as a final state. - Tool calls hide the default UI. The SDK's
AITypingIndicatorViewand stop button only react toAI_STATE_THINKINGandAI_STATE_GENERATING. While a tool runs, both the indicator and the stop button disappear. If your agent uses tools, show the AI components'AITypingIndicatorViewforAIStates.ExternalSourcestoo and, withComposerView, include that state inisGenerating. - Match tool calls with
AIStates.ExternalSources. TheAIStatetype instream-chatlistsAI_STATE_CHECKING_SOURCES, but the backend SDKs sendAI_STATE_EXTERNAL_SOURCES, which is the value ofAIStates.ExternalSources. The type accepts any string, so a comparison with the wrong literal compiles and never matches.
useAIState returns only the state. When you also need the id of the reply, for example to show a cursor or a stop control on that message, listen with channel.on yourself. A channel's listeners only receive that channel's events, so no cid check is needed:
import { useEffect, useState } from "react";
import type { AIState, Channel } from "stream-chat";
import { AIStates } from "stream-chat-react-native";
export const useAIIndicator = (channel?: Channel) => {
const [aiState, setAIState] = useState<AIState>(AIStates.Idle);
const [messageId, setMessageId] = useState<string>();
useEffect(() => {
// Start from idle whenever the channel changes.
setAIState(AIStates.Idle);
setMessageId(undefined);
if (!channel) return;
const update = channel.on("ai_indicator.update", (event) => {
setAIState(event.ai_state ?? AIStates.Idle);
setMessageId(event.message_id);
});
const clear = channel.on("ai_indicator.clear", () => {
setAIState(AIStates.Idle);
setMessageId(undefined);
});
return () => {
update.unsubscribe();
clear.unsubscribe();
};
}, [channel]);
return { aiState, messageId };
};Render the AI components' AITypingIndicatorView for the states you want to show, between MessageList and the composer, as in the SDK integration. The sample app has a complete channel screen.
Indicator events only reach clients that are connected when they're sent. They aren't stored or replayed, so a user who opens the channel halfway through a reply never gets the earlier AI_STATE_THINKING or AI_STATE_GENERATING: useAIState reports idle, and the default composer shows no stop button. Use them for the indicator only, and rely on the message's text and generating fields, which arrive with every message update, for the reply itself.
Let users stop the response
A long reply can take a while, and the user may already have what they need or see the model heading the wrong way. The default MessageComposer already handles this. While useAIState reports AI_STATE_THINKING or AI_STATE_GENERATING, it shows StopMessageStreamingButton in place of the send button, and a tap calls channel.stopAIResponse(). With the AI components' ComposerView, pass isGenerating and a stopGenerating callback that calls the same method, as shown in the SDK integration.
stopAIResponse() comes from stream-chat and sends an ai_indicator.stop event with no other fields. It doesn't change any state in the app. useAIState has no setter, so the stop button stays on screen until the backend sends ai_indicator.clear or a new ai_indicator.update.
The stop event names only the channel, not a message, so on the backend treat it as "stop whatever reply is in flight in this channel". Abort the LLM stream, then finish the same way a normal reply finishes: store the text generated so far with a regular partial update and generating: false, then send ai_indicator.clear. Skipping that final update leaves the message stuck in its last stored state, as described in the next section, and skipping the clear leaves the stop button on screen. The Stream Chat AI SDK and the Stream Chat LangChain SDK already listen for ai_indicator.stop and do this for you.
Use ephemeral updates while the response is streaming
The components above render whatever your backend writes into the assistant's message. A typical backend posts an empty placeholder message as the AI bot, then keeps updating it as the LLM streams tokens back. Each update reaches the app as a message.updated event, and StreamingMessageView animates the new text in.
How the backend sends those updates matters. A regular partial update (partialUpdateMessage) writes the message to the database on every call. A long reply flushed every few tokens turns into dozens or hundreds of writes, and with several conversations running at once you can quickly hit the rate limits for message updates.
For the updates sent while the response is still streaming, use ephemeralUpdateMessage instead:
- It accepts the same payload as a partial update (
set,unsetand the acting user). - It sends the same
message.updatedevent to everyone watching the channel, so the live typing in the app looks exactly the same. - It doesn't store anything in the database, it only sends the event. Skipping the database write is what keeps high-frequency streaming from getting rate limited.
The last update must not be ephemeral. When the LLM finishes, send the complete text with a regular partial update and set generating to false. Ephemeral updates are never stored, so if the final one is ephemeral too, anyone who opens the channel later or relaunches the app loads the last stored version of the message: the empty placeholder, still marked as generating. The same applies to whatever else ends the stream, such as an error message or the user stopping the generation.
ephemeralUpdateMessage is available only on the server side. Call it from your backend, not from the React Native app. In Node.js it ships in stream-chat 9.27.0 and later, and every server-side SDK exposes an equivalent:
// While the LLM is streaming: send the latest text without storing it.
await client.ephemeralUpdateMessage(
messageId,
{ set: { text, generating: true } },
botId,
);
// Once the LLM has finished: store the final text.
await client.partialUpdateMessage(
messageId,
{ set: { text, generating: false } },
botId,
);# While the LLM is streaming: send the latest text without storing it.
client.chat.ephemeral_message_update(
id=message_id,
set={"text": text, "generating": True},
user_id=bot_id,
)
# Once the LLM has finished: store the final text.
client.chat.update_message_partial(
id=message_id,
set={"text": text, "generating": False},
user_id=bot_id,
)# While the LLM is streaming: send the latest text without storing it.
client.chat.ephemeral_message_update(message_id, Models::UpdateMessagePartialRequest.new(
set: { 'text' => text, 'generating' => true },
user_id: bot_id,
))
# Once the LLM has finished: store the final text.
client.chat.update_message_partial(message_id, Models::UpdateMessagePartialRequest.new(
set: { 'text' => text, 'generating' => false },
user_id: bot_id,
))// While the LLM is streaming: send the latest text without storing it.
$client->ephemeralMessageUpdate($messageId, new Models\UpdateMessagePartialRequest(
set: (object)["text" => $text, "generating" => true],
userID: $botId,
));
// Once the LLM has finished: store the final text.
$client->updateMessagePartial($messageId, new Models\UpdateMessagePartialRequest(
set: (object)["text" => $text, "generating" => false],
userID: $botId,
));// While the LLM is streaming: send the latest text without storing it.
_, err := client.Chat().EphemeralMessageUpdate(ctx, messageID, &getstream.EphemeralMessageUpdateRequest{
Set: getstream.PtrTo(map[string]any{
"text": text,
"generating": true,
}),
UserID: getstream.PtrTo(botID),
})
// Once the LLM has finished: store the final text.
_, err = client.Chat().UpdateMessagePartial(ctx, messageID, &getstream.UpdateMessagePartialRequest{
Set: getstream.PtrTo(map[string]any{
"text": text,
"generating": false,
}),
UserID: getstream.PtrTo(botID),
})// While the LLM is streaming: send the latest text without storing it.
await chat.EphemeralMessageUpdateAsync(messageId, new UpdateMessagePartialRequest
{
Set = new Dictionary<string, object>
{
{ "text", text },
{ "generating", true },
},
UserID = botId,
});
// Once the LLM has finished: store the final text.
await chat.UpdateMessagePartialAsync(messageId, new UpdateMessagePartialRequest
{
Set = new Dictionary<string, object>
{
{ "text", text },
{ "generating", false },
},
UserID = botId,
});Even ephemeral updates are delivered to every device watching the channel, so don't send one per token. Flushing every 20 or so chunks, or at most every 50 to 100 ms, keeps the typing smooth without wasting bandwidth. The AI message streaming guide covers the full backend flow, including throttling and the AI indicator events that drive AITypingIndicatorView.
If you use the Stream Chat AI SDK or the Stream Chat LangChain SDK on your backend, this is already handled for you: both send ephemeral updates while streaming and store the final text with a regular partial update.