Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

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_ID and YOUR_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

App.tsx
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:

App.tsx
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:

App.tsx
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:

  1. 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.
  2. Channel watches 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. Channel also 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.
  3. MessageList renders the channel's messages, and MessageComposer writes new ones. A component inside Channel reads the channel with useChannel().

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:

Terminal
yarn add @react-navigation/native @react-navigation/native-stack @react-navigation/elements react-native-screens

Channels 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.

App.tsx
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:

  1. ChannelList renders a row for each channel in the paginator. Tapping a row calls onSelect with the channel.
  2. onSelect navigates to the channel screen with the channel's cid. Route params should hold values that can be saved, such as strings, rather than objects.
  3. 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 with client.channel(type, id) instead.
  4. keyboardVerticalOffset moves the composer above the keyboard by the height of the navigation header, and topInset keeps 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:

App.tsx
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:

  • onThreadSelect on MessageList is called with a Thread object when the user opens a message's replies. The screen navigates with the thread's id, which is the id of its parent message.
  • Thread renders next to the channel, on a screen of its own, not inside Channel. It takes the Thread object and gives it to the components inside it, so MessageList shows the replies, with the parent message above them, and MessageComposer sends replies. It takes the same configuration props as Channel, such as keyboardVerticalOffset.
  • 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.
  • ThreadList shows the user's threads, and calls onThreadSelect when one is tapped. isFocused tells 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.

Next steps