Channel header
This UI cookbook builds a channel header of its own: a back button with a badge for the unread messages in the user's other channels, the channel's avatar and name, and a subtitle that shows the connection status, who is typing, or the member and online counts. The SDK has no channel header component; how a header gets onto a channel screen, and the hooks it reads, are explained in Channel UI.
Best practices
- Render the header as the first child of
Channel, and hide the navigator's header for that screen. The header then reads the channel withuseChannel(), andChannelstarts at the top of the screen, so passkeyboardVerticalOffset={0}. Left out, it's86.5on iOS and-300on Android. - 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 message animating back from the message menu passes under it, not over it. - Read the name, the avatar, the counts and typing through the SDK's hooks, which follow the channel's stores.
channel.dataread during render doesn't update the header. - Keep the header light. It re-renders on every typing event, and the badge counts again on every client event.
Implementation
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Channel, NetworkConnectionState } from "stream-chat";
import {
BadgeNotification,
ChannelAvatar,
ChevronLeft,
PortalWhileClosingView,
useChannel,
useChannelMemberCount,
useChannelOnlineMemberCount,
useChannelPreviewDisplayName,
useChatContext,
useNetworkConnectionStateSelector,
useSettledWSConnectionHealth,
useTheme,
useTypingString,
} from "stream-chat-react-native";
const isOnlineSelector = ({ isOnline }: NetworkConnectionState) => ({
isOnline,
});
// The unread messages of every other channel the client holds, except the muted ones.
const useOtherChannelsUnreadCount = (channel: Channel) => {
const { client } = useChatContext();
const [count, setCount] = useState(0);
useEffect(() => {
const countUnread = () =>
client.channelManager.values().reduce((total, other) => {
// A channel that was deleted, or that the user was removed from, is disconnected.
if (other.cid === channel.cid || other.pendingDisposal) return total;
if (other.state.getLatestValue().muteStatus.muted) return total;
return total + other.state.unreadCount;
}, 0);
setCount(countUnread());
// The client applies each event to the channels before it calls its listeners.
const { unsubscribe } = client.on(() => setCount(countUnread()));
return unsubscribe;
}, [channel, client]);
return count;
};
type ChannelHeaderProps = {
onBack: () => void;
onOpenDetails: () => void;
};
export const ChannelHeader = ({
onBack,
onOpenDetails,
}: ChannelHeaderProps) => {
const channel = useChannel();
const { top } = useSafeAreaInsets();
const {
theme: { semantics },
} = useTheme();
const name = useChannelPreviewDisplayName(channel);
const memberCount = useChannelMemberCount(channel);
const onlineCount = useChannelOnlineMemberCount(channel);
const typing = useTypingString();
const unreadElsewhere = useOtherChannelsUnreadCount(channel);
const { isOnline } =
useNetworkConnectionStateSelector(isOnlineSelector) ?? {};
const isConnected = useSettledWSConnectionHealth();
// `=== false`: `undefined` means the network status isn't known yet.
const subtitle =
isOnline === false
? "Waiting for network..."
: !isConnected
? "Connecting..."
: typing || `${memberCount} members, ${onlineCount} online`;
return (
<PortalWhileClosingView
portalHostName="channel-header"
portalName="channel-header"
>
<View
style={[
styles.container,
{
backgroundColor: semantics.backgroundCoreElevation1,
borderBottomColor: semantics.borderCoreSubtle,
paddingTop: top + 8,
},
]}
>
<Pressable
accessibilityLabel={
unreadElsewhere > 0 ? `Back, ${unreadElsewhere} unread` : "Back"
}
accessibilityRole="button"
hitSlop={8}
onPress={onBack}
style={styles.back}
>
<ChevronLeft height={24} stroke={semantics.textPrimary} width={24} />
{unreadElsewhere > 0 ? (
<View style={styles.badge}>
<BadgeNotification
count={unreadElsewhere}
size="xs"
type="primary"
/>
</View>
) : null}
</Pressable>
<Pressable
accessibilityLabel="Channel details"
accessibilityRole="button"
onPress={onOpenDetails}
style={styles.details}
>
<ChannelAvatar channel={channel} size="lg" />
<View style={styles.title}>
<Text
numberOfLines={1}
style={[styles.name, { color: semantics.textPrimary }]}
>
{name}
</Text>
<Text
numberOfLines={1}
style={[styles.subtitle, { color: semantics.textSecondary }]}
>
{subtitle}
</Text>
</View>
</Pressable>
</View>
</PortalWhileClosingView>
);
};
const styles = StyleSheet.create({
back: {
alignItems: "center",
height: 40,
justifyContent: "center",
width: 40,
},
badge: { left: 22, position: "absolute", top: 2 },
container: {
alignItems: "center",
borderBottomWidth: 1,
flexDirection: "row",
gap: 4,
paddingBottom: 8,
paddingHorizontal: 8,
},
details: { alignItems: "center", flex: 1, flexDirection: "row", gap: 8 },
name: { fontSize: 16, fontWeight: "600" },
subtitle: { fontSize: 13 },
title: { flex: 1 },
});![]() | ![]() |
Here is what each part reads:
useChannelPreviewDisplayName(channel)gives the name the channel list shows: the channel's name, or its other members' names when it has none.ChannelAvatarshows the channel's image, or the members' avatars, with the online dot in a direct message.useChannelMemberCount(channel)readsmemberCountfromchannel.state.useChannelOnlineMemberCount(channel)readswatcherCount, the number of users watching the channel right now.useTypingString()returns "Cleo is typing", or "Cleo and 2 more are typing", for the people typing in the channel itself, and an empty string while nobody is. It leaves out the current user and the replies typed in threads. It reads the channel fromChannel, so the header has to be inside it.useNetworkConnectionStateSelectorreads whether the device has a network, anduseSettledWSConnectionHealth()whether the connection to Stream is up. A drop of the connection shorter than five seconds doesn't count, so a short reconnect doesn't flash "Connecting...". Connection status explains both.
The unread badge
The badge counts the messages the user hasn't read in other channels, so it stays useful after they leave this one. No hook returns that number, so useOtherChannelsUnreadCount adds it up:
- It sums
channel.state.unreadCount, the user's unread count in each channel, over the channels inclient.channelManager: the channels the client has loaded, such as the ones in your channel lists. Channels the client hasn't loaded don't count. - It leaves out this channel and the muted ones, as the channel list's badges do. It also leaves out a channel that was deleted or that the user was removed from: the client disconnects it and keeps it until its listeners have run.
- It counts again on every client event. The client updates a channel's state before it calls the listeners, so the count includes the event's message or read.
For a total over all the user's channels, loaded or not, read total_unread_count from the server: client.user?.total_unread_count after connecting, and the same field on the events that carry it, such as notification.message_new and notification.mark_read. That total includes this channel while its new messages wait to be marked read.
Final code
The channel screen hides the navigator's header and renders ChannelHeader above the list:
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
Channel,
MessageComposer,
MessageList,
useChatContext,
} from "stream-chat-react-native";
import { ChannelHeader } from "./ChannelHeader";
type StackParamList = {
Channel: { cid: string };
ChannelDetails: { cid: string };
Thread: { threadId: string };
};
export const ChannelScreen = ({
navigation,
route,
}: NativeStackScreenProps<StackParamList, "Channel">) => {
const { client } = useChatContext();
const channel = client.channelManager.get(route.params.cid);
if (!channel) return null;
return (
<Channel channel={channel} keyboardVerticalOffset={0}>
<ChannelHeader
onBack={() => navigation.goBack()}
onOpenDetails={() =>
navigation.navigate("ChannelDetails", { cid: channel.cid })
}
/>
<MessageList
onThreadSelect={(thread) =>
navigation.navigate("Thread", { threadId: thread.id })
}
/>
<MessageComposer />
</Channel>
);
};Register the screen with options={{ headerShown: false }}. The header is part of the screen's views, so Channel gets a keyboardVerticalOffset of 0 and no topInset; see Keyboard and insets. The details screen the avatar opens is on Channel UI, and the thread screen on Navigation.
A thread screen can use the same header pattern inside Thread. There, useTypingString() counts only the people typing in that thread, and useChannel() returns the thread's channel. The Thread header UI cookbook builds one.

