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

Moving between channels, threads and messages

The SDK's components take users from one place to another in several ways: a row in a list, the replies button, a quoted message, the unread messages notification. This page lists each one, what the SDK does by itself, and what your app does through a callback.

The SDK moves the user within a message list on its own. To open another screen, it calls back, and your navigator shows the screen, as described in Navigation. The examples use the stack from that page: a ChannelList screen, a Channel screen with the cid in its params and a Thread screen with the thread's id.

What the user taps What happens Callback
A row in the channel list Your app opens the channel onSelect on ChannelList
The replies button under a message Your app opens the message's thread onThreadSelect on MessageList
Thread Reply in the message menu Your app opens the message's thread onThreadSelect on MessageList
A row in the thread list Your app opens the thread onThreadSelect on ThreadList
View on "Also sent in channel", in a thread Your app opens the channel at the reply onAlsoSentToChannelHeaderPress on Thread
View on "Replied to a thread", in a channel Your app opens the thread at the reply onAlsoSentToChannelHeaderPress on Channel
A quoted message The list jumps to the original message none
The unread messages notification The list jumps to the first unread message UnreadMessagesNotification's onPressHandler
The scroll to bottom button The list shows the latest message none
A pinned message in channel details Nothing, until your app makes the row open it your own PinnedMessageItem

MessageFlashList takes the same onThreadSelect as MessageList.

Opening a channel

Tapping a row in ChannelList calls onSelect with the Channel. Without onSelect, the tap does nothing. When a row's swipe actions are open, the tap closes them instead.

<ChannelList
  onSelect={(channel) => navigation.navigate("Channel", { cid: channel.cid })}
  paginator={paginator}
/>

The channel the list passes is loaded, so Channel opens it without a request, and only loads the latest messages when the loaded ones don't reach them. A channel opened from anywhere else, such as a notification, is covered in Opening from a push notification or a deep link.

Opening a thread

Three things open a thread:

  • The replies button, such as "3 replies", under a message that has replies. It shows in a channel's message list, not in a thread.
  • Thread Reply in the message menu that opens on a long press. It's offered when the user can reply (the send-reply capability), and not inside a thread, on a thread reply shown in the channel, or on a message that failed to send.
  • A row in ThreadList.

The first two call onThreadSelect on the MessageList (or MessageFlashList) with the thread of the message, from client.threads.ensure({ channel, parentMessage }). A row in ThreadList calls its own onThreadSelect with the thread it shows. Without an onThreadSelect, none of them does anything. Both pass a thread the client keeps, so the thread screen can look it up with client.threads.get(threadId).

<MessageList
  onThreadSelect={(thread) =>
    navigation.navigate("Thread", { threadId: thread.id })
  }
/>

The thread's id is the id of its parent message. Thread loads the replies when the thread has none loaded.

Two more hooks change what the replies button and the menu action do:

  • handleThreadReply on Channel is called with the message when the user picks Thread Reply, before the thread opens.
  • onPressMessage on Channel receives taps on the replies button with emitter: "messageReplies". Its defaultHandler opens the thread, so a handler that doesn't call it replaces the navigation.

A custom message component opens its message's thread with onOpenThread from useMessageContext(). It calls the list's onThreadSelect the same way:

import { Pressable, Text } from "react-native";
import { useMessageContext } from "stream-chat-react-native";

export const ReplyInThreadButton = () => {
  const { onOpenThread } = useMessageContext();

  return (
    <Pressable accessibilityRole="button" onPress={onOpenThread}>
      <Text>Reply in thread</Text>
    </Pressable>
  );
};

Reply also sent in channel

A reply sent from a thread with the composer's "also send to channel" option shows in both message lists. In the thread, SentToChannelHeader marks it "Also sent in channel". In the channel, it marks it "Replied to a thread". The header shows a View link only when onAlsoSentToChannelHeaderPress is set on the Thread or Channel that renders it. The SDK doesn't navigate: it calls the callback with an AlsoSentToChannelHeaderPressPayload:

  • From the thread, the payload is { targetedMessageId }, the id of the reply. Open the channel and jump to it there.
  • From the channel, the payload is { targetedMessageId, thread }, the id of the reply and its thread. When the client doesn't keep the thread yet, the header gets the parent message, from the loaded messages or from the server, and the thread from client.threads.ensure(). Open the thread and jump to the reply in it.

The two screens pass the reply's id to each other, and each screen jumps to it when it opens, as in Jumping when a screen opens:

Screens.tsx
import { useLayoutEffect } from "react";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
  Channel,
  DEFAULT_HIGHLIGHT_DURATION,
  MessageComposer,
  MessageList,
  Thread,
  useChatContext,
} from "stream-chat-react-native";

type StackParamList = {
  Channel: { cid: string; messageId?: string };
  Thread: { messageId?: string; threadId: string };
};

export const ChannelScreen = ({
  navigation,
  route,
}: NativeStackScreenProps<StackParamList, "Channel">) => {
  const { cid, messageId } = route.params;
  const { client } = useChatContext();
  const channel = client.channelManager.get(cid);

  useLayoutEffect(() => {
    if (!channel || !messageId) return;
    void channel.messagePaginator.jumpToMessage(messageId, {
      focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
    });
  }, [channel, messageId]);

  if (!channel) return null;

  return (
    <Channel
      channel={channel}
      onAlsoSentToChannelHeaderPress={({ targetedMessageId, thread }) => {
        if (!thread) return;
        navigation.navigate("Thread", {
          messageId: targetedMessageId,
          threadId: thread.id,
        });
      }}
    >
      <MessageList
        onThreadSelect={(thread) =>
          navigation.navigate("Thread", { threadId: thread.id })
        }
      />
      <MessageComposer />
    </Channel>
  );
};

export const ThreadScreen = ({
  navigation,
  route,
}: NativeStackScreenProps<StackParamList, "Thread">) => {
  const { messageId, threadId } = route.params;
  const { client } = useChatContext();
  const thread = client.threads.get(threadId);

  useLayoutEffect(() => {
    if (!thread || !messageId) return;
    void thread.messagePaginator.jumpToMessage(messageId, {
      focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
    });
  }, [thread, messageId]);

  if (!thread) return null;

  return (
    <Thread
      onAlsoSentToChannelHeaderPress={({ targetedMessageId }) =>
        // Goes back to the channel screen below, or replaces this one when there is none.
        navigation.popTo("Channel", {
          cid: thread.channel.cid,
          messageId: targetedMessageId,
        })
      }
      thread={thread}
    >
      <MessageList />
      <MessageComposer />
    </Thread>
  );
};

The keyboard and inset props are left out here. Set them as on Keyboard and insets.

Jumping to a message

A jump moves a message list to one message. When the message isn't loaded, the list loads the messages around it first. The list then scrolls to it and highlights it. The jump itself belongs to the list's MessagePaginator, in stream-chat: see Message paginators.

The SDK jumps by itself in two places:

  • A quoted message. Tapping the quote in a message jumps to the original, in the list the message is in. onPressMessage on Channel receives the tap with emitter: "messageContent", and its defaultHandler jumps.
  • The unread messages notification at the top of a channel's message list, which jumps to the first unread message. See First unread and latest.

MessagePaginator.jumpToMessage(messageId, options) resolves with true when the message is shown, and with false when it can't be, for example when it doesn't exist. Then it also publishes an error notification. A component inside Channel or Thread jumps in the list on screen with useMessagePaginator(), which returns the thread's paginator inside a Thread and the channel's otherwise:

import { Button } from "react-native";
import {
  DEFAULT_HIGHLIGHT_DURATION,
  useMessagePaginator,
} from "stream-chat-react-native";

export const ShowMessageButton = ({ messageId }: { messageId: string }) => {
  const messagePaginator = useMessagePaginator();

  return (
    <Button
      onPress={() =>
        void messagePaginator.jumpToMessage(messageId, {
          focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
        })
      }
      title="Show message"
    />
  );
};

The highlight comes from the paginator's messageFocusSignal, which a jump sets for focusSignalTtlMs milliseconds. DEFAULT_HIGHLIGHT_DURATION is the 3000 ms the SDK uses for its own jumps. The message list scrolls to the message in the signal, and useIsTargetedMessage(messageId) returns true for that message while the signal lasts, so a custom message component can draw its own highlight. A message list clears the signal when it unmounts, so a highlight still running when the user leaves doesn't run again on return.

Jumping when a screen opens

To open a channel or a thread at a message, pass the message's id in the route params and jump in a useLayoutEffect on the screen, as the screens in Reply also sent in channel do. A layout effect runs before the message list picks where it starts, so the list starts at the message instead of scrolling to it.

Channel doesn't open at the first unread message when a jump has already set the focus signal. A jump sets the signal only once its page has loaded, so a jump still loading when Channel mounts doesn't stop it. A screen that jumps turns the first unread off for that open, as the SampleApp does:

<Channel channel={channel} initialScrollToFirstUnreadMessage={!messageId}>

A pinned reply or a reply in a notification can belong to a thread. Its parent_id is the thread's id: open the thread and jump to the reply there, or jump to parent_id in the channel.

Pinned messages

PinnedMessageList lists a channel's pinned messages, inside ChannelDetails or on a screen of your own wrapped in ChannelDetailsContextProvider. Its rows aren't pressable. To open the message, replace the PinnedMessageItem slot with a row that navigates, as the SampleApp does:

PinnedMessages.tsx
import { Pressable } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { Channel as StreamChannel } from "stream-chat";
import {
  ChannelDetailsContextProvider,
  PinnedMessageItem,
  PinnedMessageList,
  type PinnedMessageItemProps,
  WithComponents,
} from "stream-chat-react-native";

type StackParamList = {
  Channel: { cid: string; messageId?: string };
};

const PressablePinnedMessage = (props: PinnedMessageItemProps) => {
  const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>();

  return (
    <Pressable
      accessibilityRole="button"
      onPress={() =>
        navigation.popTo("Channel", {
          cid: props.channel.cid,
          // A pinned thread reply isn't in the channel's list, so the channel jumps to its parent.
          messageId: props.message.parent_id ?? props.message.id,
        })
      }
    >
      <PinnedMessageItem {...props} />
    </Pressable>
  );
};

// Read once, when WithComponents mounts, so the object stays the same.
const overrides = { PinnedMessageItem: PressablePinnedMessage };

export const PinnedMessages = ({ channel }: { channel: StreamChannel }) => (
  <ChannelDetailsContextProvider channel={channel}>
    <WithComponents overrides={overrides}>
      <PinnedMessageList />
    </WithComponents>
  </ChannelDetailsContextProvider>
);

The channel screen jumps to messageId when it opens, as in Jumping when a screen opens. More on pinning is on Pinned messages.

First unread and latest

A channel can open at the user's first unread message, and the message list has two controls for moving through unread messages:

  • initialScrollToFirstUnreadMessage on Channel makes the channel open at the first unread message when it has unread messages, and highlights it. It's false by default, and the list opens at the latest message.
  • The unread messages notification shows at the top of a channel's message list when the channel has unread messages and the last message the user read is above the messages on screen. Tapping it calls MessagePaginator.jumpToTheFirstUnreadMessage(). Its close button marks the channel read. To change either, replace the UnreadMessagesNotification slot through WithComponents with a component that renders the SDK's UnreadMessagesNotification with your own onPressHandler or onCloseHandler. A thread's message list doesn't show it.
  • The scroll to bottom button shows when the user has scrolled away from the latest message, or when the latest message isn't loaded. Tapping it calls MessagePaginator.jumpToTheLatestMessage() when the latest message isn't loaded, and otherwise scrolls to it. In a channel, it then marks the channel read.

Your own controls call the same paginator methods: MessagePaginator.jumpToTheFirstUnreadMessage({ focusSignalTtlMs }) and MessagePaginator.jumpToTheLatestMessage(). How unread state is counted is on Read state, unread and receipts.

A push notification or a link can open a channel the client hasn't loaded, sometimes before any screen has mounted. The screen that opens it gets the channel with client.channel(type, id), which returns the channel the client keeps for that cid, or creates one. Channel watches a new channel when it mounts. The useChannelByCid hook on Navigation resolves a cid this way.

To open at a message, pass its id too and jump to it. The example apps watch a channel that opens cold before they jump in it:

ChannelScreen.tsx
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  DEFAULT_HIGHLIGHT_DURATION,
  MessageComposer,
  MessageList,
  useChatContext,
} from "stream-chat-react-native";

type StackParamList = {
  Channel: { cid: string; messageId?: string };
};

export const ChannelScreen = ({
  route,
}: NativeStackScreenProps<StackParamList, "Channel">) => {
  const { cid, messageId } = route.params;
  const { client } = useChatContext();
  const [channel, setChannel] = useState<StreamChannel>();

  const requested = useMemo(() => {
    const separator = cid.indexOf(":");
    return client.channel(cid.slice(0, separator), cid.slice(separator + 1));
  }, [client, cid]);

  useEffect(() => {
    let cancelled = false;
    requested.watch().then(
      (watched) => {
        if (!cancelled) setChannel(watched);
      },
      (error: unknown) => console.warn("Opening the channel failed", error),
    );
    return () => {
      cancelled = true;
    };
  }, [requested]);

  useLayoutEffect(() => {
    if (!channel || !messageId) return;
    void channel.messagePaginator.jumpToMessage(messageId, {
      focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
    });
  }, [channel, messageId]);

  if (!channel) return null;

  return (
    <Channel channel={channel} initialScrollToFirstUnreadMessage={!messageId}>
      <MessageList />
      <MessageComposer />
    </Channel>
  );
};

Channel.watch() sends nothing for a channel that is already watched and loaded, such as one from a channel list, unless you pass it options that differ from its last watch.

With React Navigation, map the URLs to the screens with the linking prop of NavigationContainer. The route params come from the path:

App.tsx
import type { PropsWithChildren } from "react";
import {
  NavigationContainer,
  type LinkingOptions,
} from "@react-navigation/native";

type StackParamList = {
  ChannelList: undefined;
  Channel: { cid: string; messageId?: string };
  Thread: { messageId?: string; threadId: string };
};

const linking: LinkingOptions<StackParamList> = {
  config: {
    screens: {
      Channel: "channel/:cid",
      Thread: "thread/:threadId",
    },
  },
  prefixes: ["myapp://"],
};

export const AppNavigationContainer = ({ children }: PropsWithChildren) => (
  <NavigationContainer linking={linking}>{children}</NavigationContainer>
);

myapp://channel/messaging:general?messageId=abc then opens the channel screen with cid and messageId. React Navigation reads the URL that opened the app and the URLs that arrive while it runs. See Deep linking for the native setup of the URL scheme and of universal and app links.

With Expo Router, every file in app/ is already a URL: app/channel/[cid].tsx opens for myapp://channel/messaging:general, and query parameters such as messageId arrive through useLocalSearchParams(). The scheme is set in app.json.

Either way, the app has to be registered for its links on each platform. This setup belongs to React Native, not to the SDK:

  • Android. In AndroidManifest.xml, give the activity an intent filter with the android.intent.action.VIEW action, the DEFAULT and BROWSABLE categories, and your scheme or host. For links to your website, set android:autoVerify="true" on the filter, and host /.well-known/assetlinks.json on the domain. See App Links.
  • iOS. In the AppDelegate, pass incoming links to RCTLinkingManager, from application(_:open:options:) for the URL scheme and from application(_:continue:restorationHandler:) for universal links. Add the scheme under URL Types in the target's Info tab. For universal links, add the Associated Domains capability with applinks: and your domain, and host /.well-known/apple-app-site-association on the domain. See Linking and Supporting universal links.
  • Expo. scheme in app.json registers the URL scheme, and android.intentFilters and ios.associatedDomains add links to your website. See Linking.

React Navigation and Expo Router read the links themselves. Without them, read the link that started the app with Linking.getInitialURL(), and the links that arrive while it runs with Linking.addEventListener("url", handler).

Push notifications

The SDK doesn't handle a tap on a notification. Read the channel from the notification's data in your notification library's tap handler, then navigate. Outside a screen, React Navigation navigates through a ref passed to NavigationContainer, and Expo Router through the router object from expo-router:

navigation.ts
import { createNavigationContainerRef } from "@react-navigation/native";

type StackParamList = {
  ChannelList: undefined;
  Channel: { cid: string; messageId?: string };
};

// Passed to <NavigationContainer ref={navigationRef}>.
export const navigationRef = createNavigationContainerRef<StackParamList>();

export const openChannelFromNotification = (
  cid: string,
  messageId?: string,
) => {
  if (!navigationRef.isReady()) return false;
  navigationRef.navigate("Channel", { cid, messageId });
  return true;
};

A notification that starts the app arrives before the navigator is ready. Keep it, and navigate in NavigationContainer's onReady, or start the stack at the channel screen as the SampleApp does with initialRouteName and initialParams. The channel screen can also be the first screen, so give it a way back to the list when navigation.canGoBack() is false. Push setup, and what a notification carries, is on Push notifications.

When a channel goes away

The client ends a channel when it's deleted, or when the current user is removed from it. The channel's pendingDisposal turns true, it stops receiving events, and the channel lists drop it. Channel keeps rendering it, so the screen that shows it decides where the user goes. The SampleApp goes back to the channel list:

useLeaveGoneChannel.ts
import { useEffect } from "react";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { Channel, ChannelLifecycleState } from "stream-chat";
import { useStateStore } from "stream-chat-react-native";

type StackParamList = {
  ChannelList: undefined;
};

const selector = (state: ChannelLifecycleState) => ({
  pendingDisposal: state.pendingDisposal,
});

export const useLeaveGoneChannel = (channel: Channel) => {
  const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>();
  const { pendingDisposal } = useStateStore(channel.state, selector);

  useEffect(() => {
    if (pendingDisposal) navigation.popTo("ChannelList");
  }, [navigation, pendingDisposal]);
};

Call it on the channel screen and on the thread screen, with thread.channel there. A channel the client ended stays ended: client.channel(type, id) returns a new object for the same cid, and client.channelManager.get(cid) returns undefined once it's removed.

A channel can also be superseded. A channel created from its members, such as a new direct message, has no cid of its own until its first query. When the server answers with a cid for which the client already keeps another Channel object, that object replaces it, and Channel renders the replacement by itself. A screen that looks its channel up by cid needs the real cid, so watch such a channel before navigating to it, as in Ids in the route params. See Entity lifecycle.

Inside channel details

ChannelDetails renders the channel's profile, its sections and its actions. It doesn't navigate either:

  • The back button in its header calls onBack.
  • The sections, Pinned messages, Photos & videos and Files, open built-in modals. getNavigationItems on ChannelDetailsNavigationSection sets an onPress on a row to open your own screen instead.
  • Leaving, deleting or blocking the channel calls onChannelDismiss on ChannelDetailsActionsSection once it succeeds, so your app can leave the channel's screens.

The two sections take these props through a replacement in WithComponents. See Custom navigation and Channel details and members.

Used in