Quick start
This page builds a chat app in four steps. Each step is a complete App.tsx. For a guided version, follow the React Native Chat tutorial.
Before you start:
- Install the SDK, its peer dependencies and
stream-chat, and configure the app, as described in Installation. - Create a Stream Chat app and generate a token for a user. The examples use its API key, the user's ID and the token in place of
YOUR_API_KEY,YOUR_USER_IDandYOUR_USER_TOKEN.
The samples import from stream-chat-react-native. In an Expo app, import the same names from stream-chat-expo.
Create the client
import { StyleSheet, Text } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import {
Chat,
OverlayProvider,
useCreateChatClient,
} from "stream-chat-react-native";
const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: { id: userId },
});
return (
<SafeAreaProvider>
<GestureHandlerRootView style={styles.container}>
{client ? (
<OverlayProvider>
<Chat client={client}>
<SafeAreaView
edges={["top", "left", "right"]}
style={styles.container}
>
<Text>The client is connected.</Text>
</SafeAreaView>
</Chat>
</OverlayProvider>
) : (
<Text>Connecting...</Text>
)}
</GestureHandlerRootView>
</SafeAreaProvider>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
});
export default App;useCreateChatClient creates a StreamChat client and connects the user. It returns null until the connection is open, and then the client. When the component unmounts, it disconnects the user.
Chat gives the client to every SDK component inside it. Your own components read it with useChatContext(). OverlayProvider hosts what the SDK draws over the whole screen, such as the message menu and the image gallery, so it goes above Chat and above your screens.
The hook creates a new client when apiKey, the user's id or tokenOrProvider changes. So a token provider function must not be re-created on every render: memoize it with useCallback, or declare it outside the component.
To configure the client and the objects it creates, pass an options object to useCreateChatClient. It takes the same tree as client.config.set(), and the hook reads it once, on the first render. This one turns on message drafts:
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
options: { messageComposer: { drafts: { enabled: true } } },
userData: { id: userId },
});The settings and their defaults are on Configuration.
Show one channel
A single channel needs no navigation. Get the channel from the client and pass it to Channel:
import { useMemo } from "react";
import { StyleSheet, Text } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import type { StreamChat } from "stream-chat";
import {
Channel,
Chat,
MessageComposer,
MessageList,
OverlayProvider,
useCreateChatClient,
} from "stream-chat-react-native";
const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
const ChannelScreen = ({ client }: { client: StreamChat }) => {
// The server creates the channel the first time it's watched.
const channel = useMemo(
() =>
client.channel("messaging", "react-native-quick-start", {
members: [{ user_id: userId }],
}),
[client],
);
return (
// Nothing is above the channel, so the keyboard offset is 0.
<Channel channel={channel} keyboardVerticalOffset={0}>
<MessageList />
<MessageComposer />
</Channel>
);
};
const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: { id: userId },
});
return (
<SafeAreaProvider>
<GestureHandlerRootView style={styles.container}>
{client ? (
<OverlayProvider>
<Chat client={client}>
<SafeAreaView
edges={["top", "left", "right"]}
style={styles.container}
>
<ChannelScreen client={client} />
</SafeAreaView>
</Chat>
</OverlayProvider>
) : (
<Text>Connecting...</Text>
)}
</GestureHandlerRootView>
</SafeAreaProvider>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
});
export default App;Here is what happens:
client.channel("messaging", "react-native-quick-start", ...)returns the object for that channel. The client keeps one object per channel, so a second call returns the same one.Channelwatches the channel when it mounts: it loads the latest messages, the members and the read state, and subscribes to the channel's events. The server creates the channel the first time.Channelalso marks the channel as active while it's mounted, so the client reloads it after a reconnect, and it marks the channel read. See Channel UI.MessageListrenders the channel's messages, andMessageComposerwrites new ones. A component insideChannelreads the channel withuseChannel().
The channel type, messaging, decides which features and permissions the channel has. See channel types.
MessageComposer keeps itself above the bottom safe area, so the SafeAreaView leaves out the bottom edge. Channel resizes its content when the keyboard opens. keyboardVerticalOffset is the height of what sits above the channel, such as a navigation header. Pass it on every screen: without it, Channel uses a default sized for a header, 86.5 on iOS and -300 on Android. Here nothing is above the channel, so it's 0.
Without a place to show a thread, the reply button on a message does nothing. The next steps add navigation, a channel list and a thread screen.
Add a channel list with navigation
The SDK doesn't navigate. It renders a channel list, calls back when the user picks a channel, and your navigator shows the channel screen. This step uses React Navigation, with its native stack:
yarn add @react-navigation/native @react-navigation/native-stack @react-navigation/elements react-native-screensnpx expo install @react-navigation/native @react-navigation/native-stack @react-navigation/elements react-native-screensChannels are queried from the server and kept in lists, matching filters and a sort order, by controllers called ChannelPaginator. Your app creates the paginator and registers it on client.channelManager, which keeps the list up to date as messages and other events arrive. ChannelList renders it, and loads its first page when it mounts.
import { useEffect, useMemo } from "react";
import { StyleSheet, Text } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { useHeaderHeight } from "@react-navigation/elements";
import { NavigationContainer } from "@react-navigation/native";
import {
createNativeStackNavigator,
type NativeStackScreenProps,
} from "@react-navigation/native-stack";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import {
Channel,
ChannelList,
Chat,
MessageComposer,
MessageList,
OverlayProvider,
useChatContext,
useCreateChatClient,
} from "stream-chat-react-native";
const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
// The user's messaging channels, latest message first.
const filters: ChannelFilters = {
members: { $in: [userId] },
type: "messaging",
};
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
type StackParamList = {
ChannelList: undefined;
Channel: { cid: string };
};
const Stack = createNativeStackNavigator<StackParamList>();
const ChannelListScreen = ({
navigation,
}: NativeStackScreenProps<StackParamList, "ChannelList">) => {
const { client } = useChatContext();
// One paginator per client. The channel list is the first screen of the stack, so it
// stays mounted, and keeps its paginator, until the client goes away.
const paginator = useMemo(
() => new ChannelPaginator({ client, filters, id: "my-channels", sort }),
[client],
);
// Registers the list on the channel manager, which keeps it up to date. Removes and
// disposes it when the client goes away.
useEffect(() => {
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
paginator.dispose();
};
}, [client, paginator]);
return (
<ChannelList
onSelect={(channel) =>
navigation.navigate("Channel", { cid: channel.cid })
}
paginator={paginator}
/>
);
};
const ChannelScreen = ({
route,
}: NativeStackScreenProps<StackParamList, "Channel">) => {
const { client } = useChatContext();
const headerHeight = useHeaderHeight();
// The list put the channel in the client, so it can be looked up by its cid.
const channel = client.channelManager.get(route.params.cid);
if (!channel) return null;
return (
<Channel
channel={channel}
keyboardVerticalOffset={headerHeight}
topInset={headerHeight}
>
<MessageList />
<MessageComposer />
</Channel>
);
};
const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: { id: userId },
});
return (
<SafeAreaProvider>
<GestureHandlerRootView style={styles.container}>
{client ? (
<OverlayProvider>
<Chat client={client}>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen
component={ChannelListScreen}
name="ChannelList"
/>
<Stack.Screen component={ChannelScreen} name="Channel" />
</Stack.Navigator>
</NavigationContainer>
</Chat>
</OverlayProvider>
) : (
<Text>Connecting...</Text>
)}
</GestureHandlerRootView>
</SafeAreaProvider>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
});
export default App;Here is what happens when the user taps a channel:
ChannelListrenders a row for each channel in the paginator. Tapping a row callsonSelectwith the channel.onSelectnavigates to the channel screen with the channel's cid. Route params should hold values that can be saved, such as strings, rather than objects.- The channel screen looks the channel up with
client.channelManager.get(cid). It's the same object the list holds. A screen opened from a push notification or a deep link, before any list loaded the channel, gets it withclient.channel(type, id)instead. keyboardVerticalOffsetmoves the composer above the keyboard by the height of the navigation header, andtopInsetkeeps the attachment picker below the header.
The paginator has to be registered to receive events: an unregistered list shows its first page and then never changes. ChannelList never disposes the paginator, so the app does, once nothing shows it: here, when the user logs out and the client goes away. A paginator that lives above the navigator, for example in a context, keeps its channels when the list's screen unmounts, and the list doesn't query again when it mounts. While no ChannelList is mounted, though, the channel manager doesn't follow events, so hold its subscriptions yourself with client.channelManager.registerSubscriptions() if the list must stay current; see Navigation.
Add threads
Threads open in two places: from a message, when the user opens its replies, and from a thread list that shows the user's threads. This step adds both:
import { useEffect, useMemo } from "react";
import { Button, StyleSheet, Text } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { useHeaderHeight } from "@react-navigation/elements";
import { NavigationContainer, useIsFocused } from "@react-navigation/native";
import {
createNativeStackNavigator,
type NativeStackScreenProps,
} from "@react-navigation/native-stack";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import {
Channel,
ChannelList,
Chat,
MessageComposer,
MessageList,
OverlayProvider,
Thread,
ThreadList,
useChatContext,
useCreateChatClient,
} from "stream-chat-react-native";
const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
const filters: ChannelFilters = {
members: { $in: [userId] },
type: "messaging",
};
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
type StackParamList = {
ChannelList: undefined;
Channel: { cid: string };
Thread: { threadId: string };
ThreadList: undefined;
};
const Stack = createNativeStackNavigator<StackParamList>();
const ChannelListScreen = ({
navigation,
}: NativeStackScreenProps<StackParamList, "ChannelList">) => {
const { client } = useChatContext();
const paginator = useMemo(
() => new ChannelPaginator({ client, filters, id: "my-channels", sort }),
[client],
);
useEffect(() => {
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
paginator.dispose();
};
}, [client, paginator]);
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<Button
onPress={() => navigation.navigate("ThreadList")}
title="Threads"
/>
),
});
}, [navigation]);
return (
<ChannelList
onSelect={(channel) =>
navigation.navigate("Channel", { cid: channel.cid })
}
paginator={paginator}
/>
);
};
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();
// The client keeps the thread that was opened, so it can be looked up by its id.
const thread = client.threads.get(route.params.threadId);
if (!thread) return null;
return (
<Thread
keyboardVerticalOffset={headerHeight}
thread={thread}
topInset={headerHeight}
>
<MessageList />
<MessageComposer />
</Thread>
);
};
const ThreadListScreen = ({
navigation,
}: NativeStackScreenProps<StackParamList, "ThreadList">) => {
// The list loads its threads, or catches up on new ones, whenever its screen comes into focus.
const isFocused = useIsFocused();
return (
<ThreadList
isFocused={isFocused}
onThreadSelect={(thread) =>
navigation.navigate("Thread", { threadId: thread.id })
}
/>
);
};
const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: { id: userId },
});
return (
<SafeAreaProvider>
<GestureHandlerRootView style={styles.container}>
{client ? (
<OverlayProvider>
<Chat client={client}>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen
component={ChannelListScreen}
name="ChannelList"
/>
<Stack.Screen component={ChannelScreen} name="Channel" />
<Stack.Screen component={ThreadScreen} name="Thread" />
<Stack.Screen
component={ThreadListScreen}
name="ThreadList"
/>
</Stack.Navigator>
</NavigationContainer>
</Chat>
</OverlayProvider>
) : (
<Text>Connecting...</Text>
)}
</GestureHandlerRootView>
</SafeAreaProvider>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
});
export default App;What changed:
onThreadSelectonMessageListis called with aThreadobject when the user opens a message's replies. The screen navigates with the thread's id, which is the id of its parent message.Threadrenders next to the channel, on a screen of its own, not insideChannel. It takes theThreadobject and gives it to the components inside it, soMessageListshows the replies, with the parent message above them, andMessageComposersends replies. It takes the same configuration props asChannel, such askeyboardVerticalOffset.- The thread screen looks the thread up with
client.threads.get(threadId). The client keeps a thread it opened for the rest of the session. ThreadListshows the user's threads, and callsonThreadSelectwhen one is tapped.isFocusedtells it whether its screen is on top. Each time it comes into focus, the list loads its first page, or reloads to show threads with replies that arrived while it was hidden.