Troubleshooting
This page collects common problems and what causes them. Coming from v9? Many problems after an upgrade are covered in Upgrade to v10. For a problem that isn't listed here, search the SDK's GitHub issues.
Context providers
The SDK's components share values through React context, so a component reads the client, the channel or the message from the provider above it. How components connect to state lists which component provides what.
A hook whose provider is required throws when it's used outside that provider, and the error names the hook and the provider. For example, useChatContext() outside Chat throws an error that starts with:
The useChatContext hook was called outside the ChatContext Provider.Move the component inside the provider. The usual cases are a custom component rendered outside the message list, a loading or empty state rendered outside Chat, and a screen rendered outside OverlayProvider. useChannel(), useChannelContext() and useMessagePaginator() throw outside a Channel or a Thread, and so do the hooks built on them. A component that renders both inside and outside a channel, such as a channel-list row, takes the channel as a prop instead. Context hooks that throw has the details.
A channel shows no messages, or doesn't update
Channel loads its channel when it mounts. It watches the channel with Channel.watch(), which loads the latest messages and subscribes to the channel's events, so a channel from client.channel() needs no request before you pass it to Channel. See Channel UI.
- The list shows an error. When the first page of messages fails to load,
ChannelrendersLoadingErrorIndicatorin place of its children, with a retry button that reloads the latest messages. Check the network and the error it shows. - The list shows messages, but new ones don't arrive. With
initializeOnMount={false},Channeldoesn't watch the channel. It still loads a page of messages when it has none, but the channel receives no events. CallChannel.watch()yourself.
channel is a required prop. <Channel> without a channel throws, so render your own placeholder while no channel is selected.
A thread renders nothing, or throws
Thread shows the thread passed as its thread prop. <Thread> without one throws TypeError: Cannot read property 'messagePaginator' of undefined. Pass the thread, and render its parts as children:
import type { Thread as StreamThread } from "stream-chat";
import { MessageComposer, MessageList, Thread } from "stream-chat-react-native";
export const ThreadScreen = ({ thread }: { thread: StreamThread }) => (
<Thread thread={thread}>
<MessageList />
<MessageComposer />
</Thread>
);Render Thread on its own screen, or next to Channel, not inside it. Get the thread from the onThreadSelect callback of MessageList or ThreadList, from client.threads.get(id) once it's open, or from client.threads.ensure({ channel, parentMessage }). Don't create it with new Thread(). client.threads.ensure() returns the one instance the client keeps up to date.
Thread loads the first page of replies when it mounts. If that fails, it renders LoadingErrorIndicator with a retry button. See Thread UI.
Tapping a channel or a thread does nothing
The SDK doesn't navigate. The channel list, the replies button and the thread list call back with what the user picked, and your navigator shows it. Without the callback, a tap does nothing:
- A channel-list row calls
onSelectonChannelListwith the channel. - The replies button and the "Thread Reply" action of a message call
onThreadSelectonMessageListwith theThread. - A thread-list row calls
onThreadSelectonThreadListwith theThread.
Navigate to the channel or thread screen from these callbacks. The Quick start and Navigation show how with React Navigation and Expo Router.
A channel list shows the first page and never updates
A ChannelPaginator stays current only while it's registered on client.channelManager, which routes events into the lists it holds. A paginator that's only passed to ChannelList loads its first page, and then never adds, moves or removes a channel when messages arrive:
client.channelManager.insertPaginator({ paginator });Remove it with client.channelManager.removePaginator(paginator) when the list goes away. ChannelList never registers or disposes the paginator it renders. Channel list paginators explains both.
A component re-renders too often, or misses changes
useStateStore follows three rules, and each mistake shows a different symptom:
- A selector defined inline is a new function on every render, so the hook unsubscribes and subscribes again each time. Define it at module scope, or wrap it in
useCallbackwhen it needs a prop. - A selector that returns the slice itself, such as
(state) => state.members, compiles but misses changes. Return an object with fixed keys:(state) => ({ members: state.members }). - A selector that maps or filters builds a new array on every run, so the component re-renders on every change to the store. Select the fields as they are and compute from them in the component.
import { Text } from "react-native";
import type { ChannelStateData } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react-native";
// Declared at module scope, so the hook subscribes once.
const typingSelector = (state: ChannelStateData) => ({ typing: state.typing });
export const TypingCount = () => {
const channel = useChannel();
const { typing } = useStateStore(channel.state, typingSelector);
return <Text>{Object.keys(typing).length}</Text>;
};Getters such as channel.state.members and channel.data return the value at the moment you read them, and don't tell you when it changes, so a component that reads them doesn't re-render. The useStateStore rules explains each rule.
Translations stopped applying
In v10 a translation key is a dotted identifier, such as messageInput.sendMessage.accessibilityLabel, not the English copy. A dictionary still keyed on v9 keys never matches, and the SDK renders its English copy, with no error. Rename the keys, and type the dictionary as TranslationDictionary so TypeScript flags any you missed. See Translation keys are dotted identifiers in the upgrade guide and Localization.
Dates are blank or show "Invalid Date"
Timestamps from the server are nanosecond numbers. A date library reads a number as milliseconds, so dayjs(message.created_at) is an invalid date and renders "Invalid Date". TypeScript doesn't catch that, because a timestamp is a number. new Date(message.created_at) is marked deprecated, and using its result as a Date doesn't compile. The SDK's date formatters return null for an invalid date, so a timestamp passed to them unconverted shows as a blank. Convert first, with nsToDate() or convertTimestampToDate() from stream-chat:
import dayjs from "dayjs";
import { nsToDate } from "stream-chat";
import type { LocalMessage } from "stream-chat";
export const formatSentAt = (message: LocalMessage) =>
dayjs(nsToDate(message.created_at)).format("LT");Data conventions lists the helpers.
A deleted channel stays on screen
When a channel is deleted, the client disconnects its channel object and removes it from client.channelManager. Channel keeps rendering the messages it has, so a user who has the channel open stays on a channel that no longer exists. Listen for the event on the channel screen and navigate away:
import { useEffect } from "react";
import type { Channel as StreamChannel } from "stream-chat";
import { useChatContext } from "stream-chat-react-native";
export const useLeaveWhenDeleted = (
channel: StreamChannel,
leave: () => void,
) => {
const { client } = useChatContext();
useEffect(() => {
const deleted = client.on("channel.deleted", (event) => {
if (event.cid === channel.cid) leave();
});
const removed = client.on("notification.channel_deleted", (event) => {
if (event.cid === channel.cid) leave();
});
return () => {
deleted.unsubscribe();
removed.unsubscribe();
};
}, [channel, client, leave]);
};Pass a stable leave, such as navigation.goBack or a function wrapped in useCallback. A disconnected channel object never comes back: to open the conversation again, get a new one with client.channel(type, id). A deleted channel keeps rendering shows how to read the channel's pendingDisposal state instead.
"No connection id is available"
Watching a channel and subscribing to presence are both server-side subscriptions, and the server keys them to your WebSocket by its connection id. A request carrying either is therefore held until the handshake has produced one, rather than sent to a server that would answer 200 and register nothing.
Usually the request waits, the socket connects, and the request goes out, so you never see this. The error appears only when no amount of waiting would help, because there is no socket and none is being opened:
No connection id is available: there is no WebSocket connection, and none is being established. A request that watches a channel or subscribes to presence needs one. Call `client.connectUser()` if no user is connected, or `client.openConnection()` if the socket was closed with `client.closeConnection()`.Two situations reach it. No user was connected, which in tests usually means a fixture that fakes a connected user without a connection. Or the socket was closed with client.closeConnection(), and nothing has reopened it. Chat closes the socket when the app goes to the background, and opens it again when the app comes back to the foreground. A request that watches a channel while the app is in the background, for example from a push notification handler, gets this error. Set closeConnectionOnBackground={false} on Chat to keep the socket open in the background. For apps created before 2022-01-18, the server sends push notifications only while the user has no open socket; newer apps get them whether the user is online or not.
A request that's waiting can be abandoned. The abort signal you pass reaches the wait, not only the HTTP call:
const controller = new AbortController();
client.queryChannels({ filter_conditions: {} }, { signal: controller.signal });
controller.abort();A query that asks for neither a watch nor presence is never held, so it works while the socket is down:
client.queryChannels({ filter_conditions: {}, presence: false, watch: false });See Connection for reading the socket's status directly.
User roles and permission policies
If a user can do something they shouldn't, or can't do something they should, check their roles and the channel type's permissions.
Every channel has a type, such as livestream, messaging, team, gaming or commerce, set when the channel is created. Each type comes with a default permission policy, which you can change. A policy allows or denies a role access to an action. For example, the admin role is allowed to create a channel and to ban a user.
The UI reads the result as the user's capabilities in the channel, and components hide what the user can't do. Inside a Channel or a Thread, useOwnCapabilitiesContext() returns them as booleans, such as sendReply. Anywhere else, useChannelOwnCapabilities(channel) returns the channel's capability names. overrideOwnCapabilities on Channel changes what the UI shows, not what the server allows. Change permissions on the server, not in the UI. See Permission policies and the Permissions reference.
You can change the permission policies of a channel type in the Stream Dashboard. You can also make a user an admin or a moderator through the API, but only from your server.
Watching channels
Watching a channel loads it and subscribes this connection to its events: new messages, member changes, typing and the rest. Channel watches its channel when it mounts, so you call these yourself only for a channel you use outside Channel, or one rendered with initializeOnMount={false}.
Channel.watch()creates the channel if it doesn't exist, loads it with its latest messages and watches it. Calling it again is safe, even from several places at once. On a channel that's already watched and loaded, it sends a request only when you pass options it wasn't watched with, and a call made while the same watch is in flight joins it. Use the channel it resolves with. To refresh a watched channel, callChannel.reload().Channel.create()creates the channel without watching it.Channel.query()returns the channel's data and updates its state, but doesn't add messages to its message list. Withwatch: trueit also watches the channel. To show a channel's messages, useChannel.watch().
See watch and query.
Watcher vs member
A watcher is a user subscribed to a channel's updates, such as new messages. Watching doesn't need membership: a user who isn't a member can watch a livestream channel, for example.
A member belongs to the channel, and usually has more permissions than a user who isn't one. For example, the default permissions of the messaging type let members read and write in the channel, and receive push notifications.
Errors after installing or upgrading
- The SDK's peer dependencies and most of its optional packages are native modules. A JavaScript reload doesn't load them, so rebuild the app after adding one. Run
npx pod-installand rebuild for React Native CLI, ornpx expo prebuildand rebuild for an Expo app that uses prebuild. - When modules resolve to old code or fail to resolve, clear the Metro cache with
npx react-native start --reset-cache, ornpx expo start -c. - The SDK and your app must use the same copy of
stream-chat. With two, the client and the objects your app creates come from different classes. List the copies withnpm ls stream-chatoryarn why stream-chat, and installstream-chatat the version the SDK uses. See Installation. - Expo Go can't load native packages that aren't part of it, such as
react-native-teleportand@op-engineering/op-sqlite. Use a development build.
Errors about worklets
The SDK animates with react-native-reanimated. Errors that mention worklets, such as a worklet that couldn't be created, usually mean the Babel plugin is missing or isn't the last plugin in babel.config.js. With react-native-reanimated 4 the plugin is react-native-worklets/plugin, and with version 3 it's react-native-reanimated/plugin:
module.exports = {
presets: [
// your presets
],
plugins: [
// your other plugins
"react-native-worklets/plugin", // must be listed last
],
};Clear the Metro cache after changing the Babel config. See the Reanimated installation guide.
Touches and gestures don't work
The SDK's gestures, such as swiping a message to reply, swiping a channel-list row and paging through the image gallery, come from react-native-gesture-handler. They work only inside a GestureHandlerRootView. Wrap the root of your app in it, above OverlayProvider:
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { OverlayProvider } from "stream-chat-react-native";
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<OverlayProvider>{/* Chat and your screens */}</OverlayProvider>
</GestureHandlerRootView>
);
}On Android, the content of a React Native Modal renders outside the root view, so wrap it in its own GestureHandlerRootView too.
A pressable inside a custom message or channel-list row sits inside the row's swipe gesture. If it doesn't receive its taps, or the wrong element does, check whether the gesture is the cause: turn swiping off with enableSwipeToReply={false} on Channel, or swipeActionsEnabled={false} on ChannelList. Nested gestures behave differently on iOS and Android, so test both. The Gesture Handler docs cover how gestures and touchables interact.
The composer is hidden by the keyboard
Channel and Thread wrap their content in KeyboardCompatibleView, which moves the composer above the keyboard. It compares the keyboard's position on the screen with its own layout, so it needs to know how much is drawn above it. Pass that height as keyboardVerticalOffset: the height of the navigation header, or 0 when nothing is above the channel. Use the same value on iOS and Android, and on every screen that renders the channel. When the prop is left out, KeyboardCompatibleView applies its own default offset (86.5 on iOS, -300 on Android) instead of 0.
import { useHeaderHeight } from "@react-navigation/elements";
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => {
const headerHeight = useHeaderHeight();
return (
<Channel channel={channel} keyboardVerticalOffset={headerHeight}>
<MessageList />
<MessageComposer />
</Channel>
);
};On Android, KeyboardCompatibleView detects whether the app draws edge to edge, which apps targeting Android 15 (API 35) do by default. An app that doesn't is resized by the system when the keyboard opens, and the view adds no offset of its own. An app that does isn't resized, and the view moves the composer itself. Remove inset listeners in MainActivity and other keyboard workarounds added for older SDK versions, and test again with only keyboardVerticalOffset.
To handle the keyboard yourself, set disableKeyboardCompatibleView on Channel. See Keyboard and insets.
The attachment picker opens at the wrong height
The attachment picker is a bottom sheet that opens below the composer. When something is drawn below the channel, such as a tab bar or the bottom safe-area inset, pass its height to Channel as bottomInset, so the picker and the message list account for it. When the channel is below a navigation header, pass the header's height as topInset, so the open picker stays below it:
<Channel bottomInset={tabBarHeight} channel={channel} topInset={headerHeight}>
{/* ... */}
</Channel>Both default to 0. useAttachmentPickerContext() returns the values in use, and only Channel and Thread set them. See Keyboard and insets.
The message menu or the image gallery doesn't cover the screen
OverlayProvider draws the message menu and the image gallery over everything inside it. If they open below a header or above a tab bar, OverlayProvider is placed too low in the tree, for example inside one screen. Place it once, above your navigator and your screens, as the Quick start does.
The message menu doesn't show inside a modal
If a long press on a message inside a modal hides the message without showing the menu, the menu is drawn behind the modal. On iOS, a modal presented natively, for example by a native stack navigator, is drawn in front of the root OverlayProvider. Give the chat inside the modal its own OverlayProvider. See Chat in modals.
The camera or the photo library doesn't open
Each picker needs its package and the platform permissions:
- The built-in media library needs
@react-native-camera-roll/camera-roll(React Native CLI) orexpo-media-library(Expo). Without it, the attachment picker doesn't show its own image grid. On iOS, it needsNSPhotoLibraryUsageDescription, andNSPhotoLibraryAddUsageDescriptionto save images. On Android, it needsREAD_MEDIA_IMAGESandREAD_MEDIA_VIDEO, andREAD_EXTERNAL_STORAGEup to API 32. - The camera needs
react-native-image-picker(React Native CLI) orexpo-image-picker(Expo). On iOS, it needsNSCameraUsageDescription, andNSMicrophoneUsageDescriptionto record videos. On Android, it needs theCAMERApermission.
Installation shows each package with its permissions, for React Native CLI and for Expo config plugins.
Google Play rejects the photo and video permissions
Google Play's photo and video permissions policy allows READ_MEDIA_IMAGES and READ_MEDIA_VIDEO only for apps that need broad access to the user's photos and videos. The built-in media library needs them. Remove @react-native-camera-roll/camera-roll or expo-media-library, and the two permissions, and install react-native-image-picker or expo-image-picker. The attachment picker then opens the system picker, which needs no Android permission. Check the merged manifest for packages that still add them. In an Expo app, list them under android.blockedPermissions in app.json. See Built-in media library.
Links don't open on Android
The SDK opens a link only after Linking.canOpenURL() confirms that an app can handle it. Apps that target Android 11 (API 30) or later see other apps only when they declare it, so canOpenURL() returns false for web links until the manifest declares them. Add this to AndroidManifest.xml:
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" android:host="*" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" android:host="*" />
</intent>
</queries>GIFs and WebP images don't show on Android
The SDK renders images with React Native's Image, which shows GIFs and WebP images on Android only with extra Fresco modules. In a React Native CLI app, add them to android/app/build.gradle, at the Fresco version your React Native version uses. The version is fresco in node_modules/react-native/gradle/libs.versions.toml:
dependencies {
// Animated GIFs
implementation("com.facebook.fresco:animated-gif:3.7.0")
// WebP, and animated WebP
implementation("com.facebook.fresco:animated-webp:3.7.0")
implementation("com.facebook.fresco:webpsupport:3.7.0")
}An Expo app adds them through android/gradle.properties: expo.gif.enabled and expo.webp.enabled are true in a new project, and expo.webp.animated turns on animated WebP.
op-sqlite doesn't build with static frameworks
With offline support, @op-engineering/op-sqlite can fail to build when the Podfile uses use_frameworks! :linkage => :static, which some libraries, such as React Native Firebase, require. The errors look like these:
error: Multiple commands produce '<some-path>/op-sqlite/op_sqlite.framework/Headers/libsql.h'
include of non-modular header inside framework moduleBuild op-sqlite as a static library instead. In a React Native CLI app, add this inside the app's target in ios/Podfile:
pre_install do |installer|
installer.pod_targets.each do |pod|
if pod.name.eql?('op-sqlite')
def pod.build_type
Pod::BuildType.static_library
end
end
end
endIn an Expo app, add the same block through a config plugin, listed last in app.json so it runs after the other plugins. The ExpoMessaging example app has one in plugins/opSqliteSwiftPlugin.js. See the op-sqlite installation docs and this GitHub issue for more context.
Notifee doesn't build in an Expo app
If Notifee fails to build on Android in an Expo app, Gradle doesn't find Notifee's own Maven repository. Add it with the expo-build-properties config plugin in app.json:
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"android": {
"extraMavenRepos": [
"$rootDir/../node_modules/@notifee/react-native/android/libs"
]
}
}
]
]
}
}The path is relative to the android folder. In a monorepo, point it at the node_modules folder that holds @notifee/react-native.
React Native Firebase doesn't build on Expo 54
React Native Firebase needs static frameworks on iOS. On Expo 54, turn them on with the expo-build-properties config plugin, and link the Firebase modules you use statically:
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"ios": {
"useFrameworks": "static",
"forceStaticLinking": ["RNFBApp", "RNFBMessaging"]
}
}
]
]
}
}With offline support, static frameworks also need the op-sqlite fix. For push notifications, see Push notifications.