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

Thread UI

A thread is a message and its replies, shown apart from the channel. Two parts of the UI show threads: Thread, which shows one thread, and ThreadList, which lists the user's threads. This page covers both, the parent message, a thread header, replies sent to the channel too, and the parts you can replace. The props are in the Thread and ThreadList references.

The Thread component

<Thread thread={thread}> shows the thread passed as thread. Like Channel, it renders the parts you give it as children:

src/ThreadScreen.tsx
import type { Thread as StreamThread } from "stream-chat";
import { MessageComposer, MessageList, Thread } from "stream-chat-react-native";

export const ThreadScreen = ({ thread }: { thread: StreamThread }) => (
  <Thread thread={thread}>
    <MessageList />
    <MessageComposer />
  </Thread>
);

Here is what Thread does:

  • It gives the thread to its children. useThreadContext() returns it, and useChannel() returns the thread's channel, so a Thread needs no Channel around it. Render it on a screen of its own, next to the channel's screen.
  • The children then work on the thread's objects. MessageList renders the replies in thread.messagePaginator, and MessageComposer writes into thread.messageComposer.
  • Around the children it renders what Channel renders: the keyboard handling, the contexts the message list and composer read, and the attachment picker. It takes the same configuration props as Channel, which it doesn't inherit from one; see Configuration shared with Thread.
  • It loads the first page of replies, unless the thread already holds some. A thread from the thread list arrives with its latest replies.
  • It reloads the thread while its state is stale: when the thread was created from a message that has replies or in a channel that isn't watched, or after the thread stopped receiving its channel's events.
  • It watches the thread's channel once when nobody has loaded it, which happens for a thread opened from the thread list. That gives the user's capabilities in the channel to the message menu, the reactions and the composer.
  • It activates the thread while it's mounted. An active thread receives its events even when the thread list doesn't hold it, and marks new replies read as they arrive.
  • When it unmounts, it calls onThreadDismount, for example to clear your app's record of the open thread.

When the first page of replies fails to load, Thread renders LoadingErrorIndicator in place of its children, "Error loading replies...". Tapping it loads the replies again.

Get the thread from the client, never with new Thread(). onThreadSelect on MessageList and ThreadList gives you the thread the user picked, client.threads.ensure({ channel, parentMessage }) returns the thread for a message and creates it when needed, and client.threads.get(id) returns one the client already has. Either way the client keeps one instance per thread and sends it its events. What channels and threads hold explains the thread's state.

The SDK doesn't navigate: the replies button on a message calls onThreadSelect on the message list, and your app shows the thread's screen, as in Quick start. Without onThreadSelect, the button does nothing.

What's different in a thread

The message list and the composer behave as in a channel, with these differences:

  • The unread notification and the unread count on the scroll-to-bottom button don't show. The thread marks itself read while it's open.
  • A thread with no replies shows its parent message rather than the empty state.
  • The attachment picker has no poll button: polls can't be created in a thread.
  • The composer shows the also-send-to-channel checkbox.
  • The typing indicator shows the users typing in this thread only.

The parent message

The parent message isn't a child you place. The message list renders it above the replies, because it has to scroll with them: the message, a "4 Replies" divider under it, and a spinner while older replies load. It's the list's older-end component, ThreadFooterComponent, which reads the parent message and the reply count from thread.state, so it updates when they change. Tapping or long-pressing the parent message does nothing, and it shows no delivery status.

To change it, pass your own component as the list's prop for the older end, FooterComponent on MessageList or HeaderComponent on MessageFlashList. ThreadFooterComponent is exported, so your component can render it with your additions around it. It takes parentMessagePreventPress={false} to make the parent message pressable.

A thread header

The SDK has no thread header component. Set the native header's title through the screen's options, or render a header of your own as the first child of Thread, as for a channel (see The channel header). Inside Thread, useTypingString() returns who is typing in the thread, and thread.state holds the parent message:

src/ThreadHeader.tsx
import { StyleSheet, Text, View } from "react-native";
import type { ThreadState } from "stream-chat";
import {
  useStateStore,
  useThreadContext,
  useTypingString,
} from "stream-chat-react-native";

const parentMessageSelector = ({ parentMessage }: ThreadState) => ({
  parentMessage,
});

export const ThreadHeader = () => {
  const { thread } = useThreadContext();
  const { parentMessage } =
    useStateStore(thread?.state, parentMessageSelector) ?? {};
  const typing = useTypingString();
  const author = parentMessage?.user?.name ?? parentMessage?.user?.id;

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Thread</Text>
      <Text numberOfLines={1}>
        {typing || (author ? `with ${author}` : "")}
      </Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: { alignItems: "center", paddingVertical: 8 },
  title: { fontSize: 16, fontWeight: "600" },
});

Replies sent to the channel

In a thread, the composer shows a checkbox, "Also send to channel". With it checked, the reply is also posted to the channel's message list. It toggles showReplyInChannel in the thread composer's state. allowThreadMessagesInChannel={false} on Thread hides it.

A reply sent to both places shows a mark above it, SentToChannelHeader: "Replied to a thread" in the channel, and "Also sent in channel" in the thread. The mark gets a "View" link when you pass onAlsoSentToChannelHeaderPress to Channel or Thread. In the channel, the handler gets the reply's id and its thread, so your app can open the thread at that reply. In the thread, it gets the reply's id, so your app can open the channel at that message. A jump to the reply is a paginator call: see Jumps on screen.

The thread list

ThreadList lists the threads the user takes part in. It reads client.threads:

  1. While isFocused is true, it activates client.threads. Activating it loads the first page when the list has none, and reloads the list when threads got replies while it was inactive. When isFocused turns false or the list unmounts, it deactivates it. Pass the focus state of its screen, such as useIsFocused() from React Navigation.
  2. It subscribes to client.threads.paginator.state and renders a row for each thread, in a React Native FlatList. It loads the next page when the user scrolls to the end.
  3. Tapping a row calls onThreadSelect(thread), and your app shows the thread.

Each row reads its thread's stores: thread.state for the channel, the parent message, the user's unread count and whether the parent message was deleted, thread.messagePaginator.aggregateState for the latest reply, and the thread's composer for a draft. A row shows:

  • the avatar of the latest reply's author;
  • the channel's name;
  • the parent message, or "Draft:" and the draft when the user left one in the thread's composer;
  • the participants' avatars, the reply count, and the time of the latest reply;
  • the unread badge.

ThreadListItem renders the row with no props. Anything inside a row reads its thread with useThreadListItemContext().

The unread banner

A thread that isn't in the loaded list can get a reply, for example an older thread someone answers. The thread manager records it in client.threads.state as unseen, and the list shows a banner above the rows: "2 new threads". Tapping it reloads the list, which brings those threads in at their new position. If the reload fails, the banner says so and tapping it tries again.

Loading, empty and error states

State What the list renders Override key
Loading the first page Ten placeholder rows ThreadListLoadingIndicator
Loading the next page A spinner after the last row ThreadListLoadingMoreIndicator
No threads "Reply to a message to start a thread" ThreadListEmptyPlaceholder

A reload keeps the loaded threads on screen until the new ones arrive. When a query fails, the list keeps what it had loaded, or shows the empty placeholder if it had nothing. The error is recorded as lastQueryError in client.threads.paginator.state; only the unread banner shows it.

Replacing parts

Register replacements with WithComponents. Overriding components explains how overrides apply.

Override key Replaces
ThreadListComponent The whole thread list: the banner and the rows. Reads useThreadsContext().
ThreadListItem A row. Gets no props; reads useThreadListItemContext().
ThreadListItemMessagePreview The parent message or draft in a row
ThreadMessagePreviewDeliveryStatus The delivery mark before the parent message in a row
ThreadListUnreadBanner The unread banner
ThreadListLoadingIndicator, ThreadListLoadingMoreIndicator, ThreadListEmptyPlaceholder The states above
SentToChannelHeader The mark on a reply sent to the channel too

The default row, ThreadListItemComponent, isn't exported, so a replacement for it can't render the default inside it. Thread has no override key: render other children in it. The parent message is the list's prop, as The parent message explains. The also-send-to-channel checkbox is part of InputView; replace that key to change it. Inside a thread, the message list and the composer take the same override keys as anywhere else; Message list UI and Message composer UI list them.

This thread list renders compact rows of its own, with the channel's name, the parent message's text and the unread count:

src/ThreadsScreen.tsx
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import type { Thread as StreamThread } from "stream-chat";
import {
  ThreadList,
  useChannelPreviewDisplayName,
  useThreadListItemContext,
  useThreadsContext,
  WithComponents,
} from "stream-chat-react-native";

const CompactThreadRow = () => {
  const { channel, ownUnreadMessageCount, parentMessage, thread } =
    useThreadListItemContext();
  const { onThreadSelect } = useThreadsContext();
  const channelName = useChannelPreviewDisplayName(channel);

  return (
    <Pressable onPress={() => onThreadSelect?.(thread)} style={styles.row}>
      <View style={styles.text}>
        <Text numberOfLines={1} style={styles.channel}>
          {channelName}
        </Text>
        <Text numberOfLines={1}>{parentMessage?.text}</Text>
      </View>
      {ownUnreadMessageCount > 0 ? <Text>{ownUnreadMessageCount}</Text> : null}
    </Pressable>
  );
};

const overrides = { ThreadListItem: CompactThreadRow };

export const ThreadsScreen = ({
  onThreadSelect,
}: {
  onThreadSelect: (thread: StreamThread) => void;
}) => {
  const isFocused = useIsFocused();

  return (
    <WithComponents overrides={overrides}>
      <ThreadList isFocused={isFocused} onThreadSelect={onThreadSelect} />
    </WithComponents>
  );
};

const styles = StyleSheet.create({
  channel: { fontWeight: "600" },
  row: { alignItems: "center", flexDirection: "row", gap: 8, padding: 12 },
  text: { flex: 1 },
});

The row reads the parent message and the unread count from the row's context, which follows thread.state, so it updates when a reply arrives. The Custom thread list UI cookbook builds a thread list of its own.

Used in