Thread header
This UI cookbook builds a thread header of its own: a back button, the avatars of the people in the thread, the title, and a subtitle with the channel's name and the reply count, or who is typing a reply. The SDK has no thread header component; Thread UI explains the options. The header follows the same pattern as the Channel header UI cookbook.
Best practices
- Render the header as the first child of
Thread, and hide the navigator's header for the thread screen. The header then reads the thread withuseThreadContext(), andThreadstarts at the top of the screen, so passkeyboardVerticalOffset={0}. - Add the top safe area inset to the header's padding, from
useSafeAreaInsets(), so the status bar and the notch don't cover it. - Wrap the header in
PortalWhileClosingView, so a reply animating back from the message menu passes under it, not over it. - Read the participants and the reply count from
thread.statewithuseStateStore, and declare the selector at module scope. - Read typing with
useTypingString(). InsideThread, it counts only the people typing a reply in that thread.
Implementation
import { useMemo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { ThreadState, UserResponse } from "stream-chat";
import {
ChevronLeft,
PortalWhileClosingView,
useChannel,
useChannelPreviewDisplayName,
UserAvatarStack,
useStateStore,
useTheme,
useThreadContext,
useTypingString,
} from "stream-chat-react-native";
const threadStateSelector = ({ participants, replyCount }: ThreadState) => ({
participants,
replyCount,
});
type ThreadHeaderProps = {
onBack: () => void;
};
export const ThreadHeader = ({ onBack }: ThreadHeaderProps) => {
const { thread } = useThreadContext();
const channel = useChannel();
const { top } = useSafeAreaInsets();
const {
theme: { semantics },
} = useTheme();
const { participants, replyCount = 0 } =
useStateStore(thread?.state, threadStateSelector) ?? {};
const channelName = useChannelPreviewDisplayName(channel);
const typing = useTypingString();
// A new array only when the participants change, so the avatars don't re-render on typing.
const users = useMemo(
() =>
(participants ?? []).flatMap(({ user }): UserResponse[] =>
user ? [user] : [],
),
[participants],
);
const replies = replyCount === 1 ? "1 reply" : `${replyCount} replies`;
const subtitle = typing || `${channelName}, ${replies}`;
return (
<PortalWhileClosingView
portalHostName="thread-header"
portalName="thread-header"
>
<View
style={[
styles.container,
{
backgroundColor: semantics.backgroundCoreElevation1,
borderBottomColor: semantics.borderCoreSubtle,
paddingTop: top + 8,
},
]}
>
<Pressable
accessibilityLabel="Back"
accessibilityRole="button"
hitSlop={8}
onPress={onBack}
style={styles.back}
>
<ChevronLeft height={24} stroke={semantics.textPrimary} width={24} />
</Pressable>
{users.length > 0 ? (
<UserAvatarStack
avatarSize="sm"
maxVisible={3}
overlap={0.3}
users={users}
/>
) : null}
<View style={styles.title}>
<Text
numberOfLines={1}
style={[styles.name, { color: semantics.textPrimary }]}
>
Thread
</Text>
<Text
numberOfLines={1}
style={[styles.subtitle, { color: semantics.textSecondary }]}
>
{subtitle}
</Text>
</View>
</View>
</PortalWhileClosingView>
);
};
const styles = StyleSheet.create({
back: {
alignItems: "center",
height: 40,
justifyContent: "center",
width: 40,
},
container: {
alignItems: "center",
borderBottomWidth: 1,
flexDirection: "row",
gap: 8,
paddingBottom: 8,
paddingHorizontal: 8,
},
name: { fontSize: 16, fontWeight: "600" },
subtitle: { fontSize: 13 },
title: { flex: 1 },
});![]() | ![]() |
Here is what each part reads:
useThreadContext()returns the thread thatThreadrenders.thread.stateholds the participants, each with itsuser, andreplyCount, the number of replies, the deleted ones included. The thread takes both from its parent message each time the client updates that message, and the header re-renders with them. What channels and threads hold lists the rest ofthread.state.useChannel()returns the thread's channel insideThread, anduseChannelPreviewDisplayName(channel)gives the name the channel list shows: the channel's name, or its other members' names when it has none.UserAvatarStackoverlaps the first three avatars and shows a count for the others.useTypingString()returns "Cleo is typing", or "Cleo and 2 more are typing", and an empty string while nobody is. It readschannel.state.typing, which holds the latest typing event of each user in the channel. An event sent from a thread carries the thread's id asparent_id, and insideThreadthe hook keeps only the events of this thread and leaves out the current user. To list the typing users yourself,useChannelTypingUsers(channel, thread.id)returns them, and it works outsideThreadtoo.
The title, the reply count and the fallback subtitle are plain English here. useTypingString() is translated already. To translate the rest, use t from useTranslationContext(), as Localization explains.
Final code
The thread screen hides the navigator's header and renders ThreadHeader above the replies:
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
MessageComposer,
MessageList,
Thread,
useChatContext,
} from "stream-chat-react-native";
import { ThreadHeader } from "./ThreadHeader";
type StackParamList = {
Channel: { cid: string };
Thread: { threadId: string };
};
export const ThreadScreen = ({
navigation,
route,
}: NativeStackScreenProps<StackParamList, "Thread">) => {
const { client } = useChatContext();
const thread = client.threads.get(route.params.threadId);
if (!thread) return null;
return (
<Thread keyboardVerticalOffset={0} thread={thread}>
<ThreadHeader onBack={() => navigation.goBack()} />
<MessageList />
<MessageComposer />
</Thread>
);
};Register the screen with options={{ headerShown: false }}. The header is part of the screen's views, so Thread gets a keyboardVerticalOffset of 0 and no topInset; see Keyboard and insets. client.threads.get() returns the thread that onThreadSelect passed to the channel screen, as Navigation explains.
When the first page of replies fails to load, Thread renders LoadingErrorIndicator in place of its children, so the header doesn't show. The user leaves the screen with the system back gesture or the Android back button, or taps the indicator to load the replies again.
Read more
- Thread UI: what
Threadrenders and the parts you can replace. - Channel header: the same pattern for a channel, with the connection status and an unread badge.
- Overlays and bottom sheets: why the header goes in a
PortalWhileClosingView.

