Navigation
The SDK doesn't navigate. Its components tell your app where the user wants to go, and your navigator shows the screen. A row in ChannelList calls onSelect with the channel, the replies button calls onThreadSelect with the thread, and your handler pushes the channel screen or the thread screen. React Native apps navigate with React Navigation or Expo Router, and this page shows both.
Every callback the SDK uses to move the user is listed on Moving between channels, threads and messages. How the screens avoid the keyboard and the navigation header is on Keyboard and insets.
Where the providers go
The providers go around the navigator, in this order:
SafeAreaProvider, because the composer, the message menu and the image gallery read the safe area insets.GestureHandlerRootView, for the gestures of the message list, the message menu and the bottom sheets.OverlayProvider, which hosts the message menu and the image gallery over everything it wraps.Chat, which gives the client, the translations and the theme to the SDK components below it.- The navigator, which is
NavigationContainerwith your navigators, or, with Expo Router, theStackinapp/_layout.tsx.
OverlayProvider has to wrap the navigator, so the message menu and the image gallery draw over every screen, header and tab bar. It works on either side of NavigationContainer, which renders no view of its own. The difference is which providers the components that OverlayProvider renders itself can read: the image gallery, and a custom MessageActions from WithComponents. Above NavigationContainer, a custom gallery component or MessageActions can't call React Navigation hooks such as useNavigation(). Put OverlayProvider inside NavigationContainer when one of them needs to. With Expo Router, the root layout is already inside the router's navigation container.
Mount one OverlayProvider. A screen presented in a separate native window, such as a native modal on iOS, needs one of its own, as described in Chat in modals. Never mount one for each screen or tab.
Keep Chat mounted while the user is connected. While it's mounted, it keeps the event subscriptions of the thread list, polls and reminders, and closes and reopens the WebSocket as the app goes to the background and comes back. See How components connect to state.
A channel list, a channel and a thread
The three screens are the same with both navigators:
- The channel list screen renders
ChannelListand navigates with the channel'scidinonSelect. - The channel screen looks the channel up by its
cidand renders it inChannel, withMessageListandMessageComposer.onThreadSelectonMessageListnavigates with the thread's id, which is the id of its parent message. - The thread screen looks the thread up by its id and renders it in
Thread, withMessageListandMessageComposer.Threadgoes on a screen of its own, not insideChannel.
Both screens pass the height of the navigation header to keyboardVerticalOffset and topInset, because a native header sits above the screen's views. See Keyboard and insets.
Expo Router builds the stack from the files in app/. The root layout creates the client and renders the providers around the Stack:
import { StyleSheet, Text } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { Stack } from "expo-router";
import { Chat, OverlayProvider, useCreateChatClient } from "stream-chat-expo";
const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
export default function RootLayout() {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: { id: userId },
});
return (
<SafeAreaProvider>
<GestureHandlerRootView style={styles.container}>
{client ? (
<OverlayProvider>
<Chat client={client}>
<Stack />
</Chat>
</OverlayProvider>
) : (
<Text>Connecting...</Text>
)}
</GestureHandlerRootView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});The channel list is the first screen, app/index.tsx:
import { useEffect, useMemo } from "react";
import { Stack, useRouter } from "expo-router";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import { ChannelList, useChatContext } from "stream-chat-expo";
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
export default function ChannelListScreen() {
const router = useRouter();
const { client } = useChatContext();
const paginator = useMemo(() => {
const filters: ChannelFilters = {
members: { $in: [client.userId ?? ""] },
type: "messaging",
};
return new ChannelPaginator({ client, filters, id: "my-channels", sort });
}, [client]);
useEffect(() => {
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
paginator.dispose();
};
}, [client, paginator]);
return (
<>
<Stack.Screen options={{ title: "Channels" }} />
<ChannelList
onSelect={(channel) =>
router.push({
params: { cid: channel.cid },
pathname: "/channel/[cid]",
})
}
paginator={paginator}
/>
</>
);
}The channel screen, app/channel/[cid].tsx, reads the cid from the route:
import { useHeaderHeight } from "@react-navigation/elements";
import { useLocalSearchParams, useRouter } from "expo-router";
import {
Channel,
MessageComposer,
MessageList,
useChatContext,
} from "stream-chat-expo";
export default function ChannelScreen() {
const { cid } = useLocalSearchParams<{ cid: string }>();
const router = useRouter();
const { client } = useChatContext();
const headerHeight = useHeaderHeight();
const channel = client.channelManager.get(cid);
if (!channel) return null;
return (
<Channel
channel={channel}
keyboardVerticalOffset={headerHeight}
topInset={headerHeight}
>
<MessageList
onThreadSelect={(thread) =>
router.push({
params: { threadId: thread.id },
pathname: "/thread/[threadId]",
})
}
/>
<MessageComposer />
</Channel>
);
}The thread screen is app/thread/[threadId].tsx:
import { useHeaderHeight } from "@react-navigation/elements";
import { useLocalSearchParams } from "expo-router";
import {
MessageComposer,
MessageList,
Thread,
useChatContext,
} from "stream-chat-expo";
export default function ThreadScreen() {
const { threadId } = useLocalSearchParams<{ threadId: string }>();
const { client } = useChatContext();
const headerHeight = useHeaderHeight();
const thread = client.threads.get(threadId);
if (!thread) return null;
return (
<Thread
keyboardVerticalOffset={headerHeight}
thread={thread}
topInset={headerHeight}
>
<MessageList />
<MessageComposer />
</Thread>
);
}useHeaderHeight comes from @react-navigation/elements, which Expo Router uses. Versions of expo-router that bundle React Navigation export it from expo-router/react-navigation instead, as the ExpoMessaging example app imports it.
The channel list stays mounted under the screens pushed on top of it, so it keeps its paginator and its scroll position. When the user goes back, Channel and Thread unmount. The channel and the thread stay in the client: the next time the user opens them, they show what is already loaded, and Channel loads the latest messages only when the loaded ones don't reach them.
Passing channels and threads between screens
A screen needs the Channel or Thread object to render. There are two ways to hand it over.
Ids in the route params
The examples above pass the channel's cid and the thread's id, and the next screen looks the object up in the client:
client.channelManager.get(cid)returns the channel the client keeps for thatcid, orundefinedwhen it keeps none. A channel from a channel list is always there while the list holds it.client.channel(type, id)returns the same stored channel, or creates and stores one.Channelwatches a new one when it mounts, so a screen that can open before any list loaded its channel, from a push notification or a deep link, uses it.client.threads.get(threadId)returns the thread the client keeps for that id: one in the thread list, or one opened during the session.onThreadSelectpasses a thread the client keeps, so it's there when the thread screen renders.client.getThreadAndHydrate({ message_id: threadId })loads a thread from the server, for a thread screen that opens cold.
Params with ids can be saved and restored by the navigator, and the same screens serve deep links. These hooks resolve the objects for screens that can open cold:
import { useEffect, useMemo, useState } from "react";
import type { Thread } from "stream-chat";
import { useChatContext } from "stream-chat-react-native";
export const useChannelByCid = (cid: string) => {
const { client } = useChatContext();
return useMemo(() => {
// A cid is "type:id", and a channel type can't contain ":".
const separator = cid.indexOf(":");
return client.channel(cid.slice(0, separator), cid.slice(separator + 1));
}, [client, cid]);
};
export const useThreadById = (threadId: string) => {
const { client } = useChatContext();
const [loaded, setLoaded] = useState<Thread>();
const kept = client.threads.get(threadId);
useEffect(() => {
if (kept) return;
let cancelled = false;
client.getThreadAndHydrate({ message_id: threadId }).then(
(thread) => {
if (!cancelled) setLoaded(thread);
},
(error: unknown) => console.warn("Loading the thread failed", error),
);
return () => {
cancelled = true;
};
}, [client, kept, threadId]);
return kept ?? (loaded?.id === threadId ? loaded : undefined);
};Thread registers the thread it renders in client.threads, so once the loaded thread is on screen, client.threads.get(threadId) returns it.
A cid is the stable address of a channel, with one exception. A channel created from its members, such as client.channel("messaging", { members }), has a temporary cid until its first query returns the real one. Watch it before navigating, and pass the cid of the channel Channel.watch() resolves with:
const channel = await client
.channel("messaging", {
members: [{ user_id: userId }, { user_id: otherUserId }],
})
.watch();
navigation.navigate("Channel", { cid: channel.cid });An app context holding the objects
The other way keeps the open channel and thread in React state above the navigator, and the screens read them from there. The ExpoMessaging example app does this in context/AppContext.tsx:
import {
createContext,
useContext,
useState,
type PropsWithChildren,
} from "react";
import type { Channel, Thread } from "stream-chat";
type AppContextValue = {
channel?: Channel;
setChannel: (channel?: Channel) => void;
setThread: (thread?: Thread) => void;
thread?: Thread;
};
const AppContext = createContext<AppContextValue>({
setChannel: () => undefined,
setThread: () => undefined,
});
export const AppProvider = ({ children }: PropsWithChildren) => {
const [channel, setChannel] = useState<Channel>();
const [thread, setThread] = useState<Thread>();
return (
<AppContext.Provider value={{ channel, setChannel, setThread, thread }}>
{children}
</AppContext.Provider>
);
};
export const useAppContext = () => useContext(AppContext);The list sets the channel and navigates, onThreadSelect sets the thread and navigates, and the thread screen clears it with onThreadDismount, which Thread calls when it unmounts:
import { MessageComposer, MessageList, Thread } from "stream-chat-react-native";
import { useAppContext } from "../context/AppContext";
export const ThreadScreen = () => {
const { setThread, thread } = useAppContext();
if (!thread) return null;
return (
<Thread onThreadDismount={() => setThread(undefined)} thread={thread}>
<MessageList />
<MessageComposer />
</Thread>
);
};A context holds one channel and one thread, so a stack with the same screen twice, such as a channel opened from another channel, shows the latest one on both. It's empty after the app restarts, so a screen opened from a deep link or from restored navigation state still needs the ids.
React Navigation accepts the objects themselves as route params, but warns that the params can't be serialized, and the navigation state then can't be saved or linked to.
Where the channel list's paginator lives
ChannelList renders a ChannelPaginator that your app creates and registers with client.channelManager.insertPaginator(). It loads the first page when the paginator has none, and never disposes it. Where the paginator is created decides how long the loaded channels last:
- In the list's screen, as in the examples above. The paginator lives as long as the screen. That suits the first screen of a stack, which stays mounted under every screen pushed on it.
- Above the navigator, in a provider inside
Chat. The paginator outlives the list's screen, so a list screen that unmounts and mounts again shows the same channels without querying again. TheExpoMessagingexample app keeps its paginator inAppContext.
The channel manager moves channels between lists and reorders them as events arrive, but only while it's subscribed to the client's events. Each mounted ChannelList holds that subscription, so with no list on screen, a paginator above the navigator keeps its channels but stops following events. A provider that keeps lists for screens that unmount holds the subscription itself:
import {
createContext,
useContext,
useEffect,
useMemo,
type PropsWithChildren,
} from "react";
import { ChannelPaginator } from "stream-chat";
import type { SortParamRequest } from "stream-chat";
import { useChatContext } from "stream-chat-react-native";
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
type ChannelLists = { archived: ChannelPaginator; inbox: ChannelPaginator };
const ChannelListsContext = createContext<ChannelLists | undefined>(undefined);
export const ChannelListsProvider = ({ children }: PropsWithChildren) => {
const { client } = useChatContext();
const lists = useMemo(() => {
const members = { $in: [client.userId ?? ""] };
return {
archived: new ChannelPaginator({
client,
filters: { archived: true, members, type: "messaging" },
id: "archived",
sort,
}),
inbox: new ChannelPaginator({
client,
filters: { archived: false, members, type: "messaging" },
id: "inbox",
sort,
}),
};
}, [client]);
useEffect(() => {
const paginators = [lists.inbox, lists.archived];
paginators.forEach((paginator) =>
client.channelManager.insertPaginator({ paginator }),
);
// Keeps the lists following events while no ChannelList is mounted.
const releaseSubscriptions = client.channelManager.registerSubscriptions();
return () => {
releaseSubscriptions();
paginators.forEach((paginator) => {
client.channelManager.removePaginator(paginator);
paginator.dispose();
});
};
}, [client, lists]);
return (
<ChannelListsContext.Provider value={lists}>
{children}
</ChannelListsContext.Provider>
);
};
export const useChannelLists = () => {
const lists = useContext(ChannelListsContext);
if (!lists)
throw new Error("useChannelLists is used outside ChannelListsProvider");
return lists;
};The provider goes inside Chat, around the navigator. It creates the paginators again when the client changes, for example after the user logs in as someone else, and disposes the old ones.
Each list has its own id. The channel manager keeps every registered list current, and a channel can be in several lists at once: it's the same Channel object in each. How lists are queried and kept current is on Channel list paginators.
Tabs and multiple channel lists
A tab navigator usually sits inside a stack. The tabs hold the lists, and the channel and thread screens are pushed on the stack above the tabs, so they cover the tab bar. With bottom tabs from @react-navigation/bottom-tabs, this app has an inbox, the archived channels and the thread list, using the ChannelListsProvider above:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useHeaderHeight } from "@react-navigation/elements";
import { useIsFocused, useNavigation } from "@react-navigation/native";
import {
createNativeStackNavigator,
type NativeStackNavigationProp,
type NativeStackScreenProps,
} from "@react-navigation/native-stack";
import type { ChannelPaginator } from "stream-chat";
import {
Channel,
ChannelList,
MessageComposer,
MessageList,
Thread,
ThreadList,
useChatContext,
} from "stream-chat-react-native";
import { useChannelLists } from "./context/ChannelListsContext";
type StackParamList = {
Tabs: undefined;
Channel: { cid: string };
Thread: { threadId: string };
};
const Stack = createNativeStackNavigator<StackParamList>();
const Tab = createBottomTabNavigator();
const useStackNavigation = () =>
useNavigation<NativeStackNavigationProp<StackParamList>>();
const ChannelListTab = ({ paginator }: { paginator: ChannelPaginator }) => {
const navigation = useStackNavigation();
return (
<ChannelList
onSelect={(channel) =>
navigation.navigate("Channel", { cid: channel.cid })
}
paginator={paginator}
/>
);
};
const InboxTab = () => <ChannelListTab paginator={useChannelLists().inbox} />;
const ArchivedTab = () => (
<ChannelListTab paginator={useChannelLists().archived} />
);
const ThreadsTab = () => {
const navigation = useStackNavigation();
// A visited tab stays mounted, so the thread list is told when it's on screen.
const isFocused = useIsFocused();
return (
<ThreadList
isFocused={isFocused}
onThreadSelect={(thread) =>
navigation.navigate("Thread", { threadId: thread.id })
}
/>
);
};
const Tabs = () => (
<Tab.Navigator>
<Tab.Screen component={InboxTab} name="Inbox" />
<Tab.Screen component={ArchivedTab} name="Archived" />
<Tab.Screen component={ThreadsTab} name="Threads" />
</Tab.Navigator>
);
const ChannelScreen = ({
navigation,
route,
}: NativeStackScreenProps<StackParamList, "Channel">) => {
const { client } = useChatContext();
const headerHeight = useHeaderHeight();
const channel = client.channelManager.get(route.params.cid);
if (!channel) return null;
return (
<Channel
channel={channel}
keyboardVerticalOffset={headerHeight}
topInset={headerHeight}
>
<MessageList
onThreadSelect={(thread) =>
navigation.navigate("Thread", { threadId: thread.id })
}
/>
<MessageComposer />
</Channel>
);
};
const ThreadScreen = ({
route,
}: NativeStackScreenProps<StackParamList, "Thread">) => {
const { client } = useChatContext();
const headerHeight = useHeaderHeight();
const thread = client.threads.get(route.params.threadId);
if (!thread) return null;
return (
<Thread
keyboardVerticalOffset={headerHeight}
thread={thread}
topInset={headerHeight}
>
<MessageList />
<MessageComposer />
</Thread>
);
};
export const Navigation = () => (
<Stack.Navigator>
<Stack.Screen
component={Tabs}
name="Tabs"
options={{ headerShown: false }}
/>
<Stack.Screen component={ChannelScreen} name="Channel" />
<Stack.Screen component={ThreadScreen} name="Thread" />
</Stack.Navigator>
);Navigation renders inside NavigationContainer, Chat and ChannelListsProvider. A few things to keep in mind with tabs:
- A tab stays mounted once visited. Each list keeps its channels while the user switches tabs.
ThreadListtakesisFocused: when its tab comes into focus, it loads its first page, or reloads to show threads that changed while it was hidden. - Each list has its own paginator. Give each one an
id, and register all of them with the channel manager. A channel that stops matching a list's filters, such as a channel the user archives, moves to the lists it matches. - One
OverlayProvidercovers every tab. Keep it above the navigator. AnOverlayProviderfor each tab breaks the message menu, because the one mounted last takes the overlay over, even while its tab is hidden. - A channel screen inside a tab sits above the tab bar. Its keyboard offset is the same, the header height, and the composer still pads itself for the bottom safe area. See Keyboard and insets.
With Expo Router, the same layout is a (tabs) group with a Tabs layout inside the root Stack, and the channel and thread routes next to the group.