# Moving between channels, threads and messages

> **Info:** This is release candidate documentation for **Stream Chat React Native SDK v10**. For the latest stable version, see the [latest version (v9)](https://getstream.io/chat/docs/sdk/react-native/).

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](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/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.

```tsx
<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-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)`.

```tsx
<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:

```tsx
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](#jumping-when-a-screen-opens):

```tsx title="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](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/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](https://getstream.io/chat/docs/sdk/react-native/v10/state/loading-data/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](#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:

```tsx
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](#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:

```tsx
<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:

```tsx title="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](#jumping-when-a-screen-opens). More on pinning is on [Pinned messages](https://getstream.io/chat/docs/sdk/react-native/v10/features/messaging/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](https://getstream.io/chat/docs/sdk/react-native/v10/features/messaging/read-state-unread-and-receipts/).

## Opening from a push notification or a deep link

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](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/navigation/#ids-in-the-route-params) 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:

```tsx title="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.

### Deep links

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

```tsx title="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](https://reactnavigation.org/docs/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](https://developer.android.com/training/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](https://reactnative.dev/docs/linking#enabling-deep-links) and [Supporting universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app).
- **Expo.** `scheme` in `app.json` registers the URL scheme, and `android.intentFilters` and `ios.associatedDomains` add links to your website. See [Linking](https://docs.expo.dev/linking/overview/).

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`:

```tsx title="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](https://getstream.io/chat/docs/sdk/react-native/v10/features/app-experience/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:

```tsx title="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](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/navigation/#ids-in-the-route-params). See [Entity lifecycle](https://getstream.io/chat/docs/sdk/react-native/v10/state/state-and-entities/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](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/channel-details/custom-navigation/) and [Channel details and members](https://getstream.io/chat/docs/sdk/react-native/v10/features/channels/channel-details-and-members/).

## Used in

- [Replies and threads](https://getstream.io/chat/docs/sdk/react-native/v10/features/messaging/replies-and-threads/)
- [Quoted replies](https://getstream.io/chat/docs/sdk/react-native/v10/features/messaging/quoted-replies/)
- [Pinned messages](https://getstream.io/chat/docs/sdk/react-native/v10/features/messaging/pinned-messages/)
- [Push notifications](https://getstream.io/chat/docs/sdk/react-native/v10/features/app-experience/push-notifications/)
- [Channel lists](https://getstream.io/chat/docs/sdk/react-native/v10/features/channels/channel-lists/)

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/moving-between-channels-threads-and-messages/](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/moving-between-channels-threads-and-messages/).