Channel header
This recipe builds a channel header of its own: the avatar on the left, then the title, and a subtitle with who is typing, or the member and online counts. The default header and its props are explained in Channel UI.
ChannelHeader has no override key. Channel renders only its children, so a header of your own goes where ChannelHeader would.
Best practices
- Read the title, image and group avatar from
useChannelPreviewInfo(). It follows changes to the channel's name, image and members. - Read counts and typing from
channel.statewithuseStateStore, and declare the selectors at module scope. - Show typing with
TypingIndicatorHeader, the header's "Cleo is typing" text, not the message list'sTypingIndicator. - Hide the typing text when typing events are off for the channel, in its configuration (
channel.configState). - Keep the back and close buttons,
WorkspaceNavigationBackButtonandWorkspaceNavigationCloseButton. They render only when the layout allows going back or closing the panel. - Keep the header light: it re-renders on every typing event.
Implementation
The default header switches its subtitle between typing and the online status. This one does the same:
import type {
ChannelConfig,
ChannelStateData,
EventPayload,
} from "stream-chat";
import {
ChannelAvatar,
TypingIndicatorHeader,
useChannel,
useChannelPreviewInfo,
useChatContext,
useStateStore,
WorkspaceNavigationBackButton,
WorkspaceNavigationCloseButton,
} from "stream-chat-react";
import "./CustomChannelHeader.css";
const channelStateSelector = ({
memberCount,
typing,
watcherCount,
}: ChannelStateData) => ({ memberCount, typing, watcherCount });
const typingEventsSelector = ({ typingEvents }: ChannelConfig) => ({
typingEventsEnabled: typingEvents.enabled,
});
export const CustomChannelHeader = () => {
const { client } = useChatContext();
const channel = useChannel();
const { memberCount, typing, watcherCount } = useStateStore(
channel.state,
channelStateSelector,
);
const { typingEventsEnabled } = useStateStore(
channel.configState,
typingEventsSelector,
);
const { displayImage, displayTitle, groupChannelDisplayInfo } =
useChannelPreviewInfo({ channel });
// Others typing in the channel itself; replies typed in threads carry a parent_id.
const someoneIsTyping = (
Object.values(typing) as EventPayload<"typing.start">[]
).some(({ parent_id, user }) => !parent_id && user?.id !== client.userID);
return (
<div className="custom-channel-header">
<WorkspaceNavigationBackButton />
<ChannelAvatar
displayMembers={groupChannelDisplayInfo.members}
imageUrl={displayImage}
size="lg"
userName={displayTitle}
/>
<div className="custom-channel-header__content">
<div className="custom-channel-header__title">{displayTitle}</div>
<div className="custom-channel-header__subtitle">
{typingEventsEnabled && someoneIsTyping ? (
<TypingIndicatorHeader />
) : (
`${memberCount} members · ${watcherCount} online`
)}
</div>
</div>
<WorkspaceNavigationCloseButton />
</div>
);
};.custom-channel-header {
align-items: center;
display: flex;
gap: 12px;
padding: 12px 16px;
}
.custom-channel-header__content {
flex: 1;
min-width: 0;
}
.custom-channel-header__title {
font-weight: 600;
}
.custom-channel-header__subtitle {
color: #6b7280;
font-size: 0.875rem;
}Here is what each part reads:
useChannelPreviewInfo({ channel })gives the title and image the channel list shows: the channel's name, or its members' names when it has none.groupChannelDisplayInfo.membersare the membersChannelAvatardraws when the channel has no image. It works out the overflow badge itself.channel.stateholdsmemberCount,watcherCount, the number of users watching the channel right now, andtyping, the latest typing event of each user. What channels and threads hold explains the rest.channel.configStateholds the channel's resolved configuration.typingEvents.enabledisfalsewhen the client configuration or the channel type turns typing events off.TypingIndicatorHeaderrenders "Cleo is typing", "Cleo and Bob are typing" or "3 people are typing" from the same events. Typing indicators covers typing end to end.
To open channel details from the avatar, render AvatarWithChannelDetail from stream-chat-react/channel-detail in place of ChannelAvatar. See Channel details.
Final code
Render the header where ChannelHeader would go. With ChatView, that's inside ChannelSlot:
import {
ChannelNavigation,
Chat,
MessageComposer,
MessageList,
ThreadHeader,
} from "stream-chat-react";
import {
ChannelSlot,
ChatView,
ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { CustomChannelHeader } from "./CustomChannelHeader";
const layouts: LayoutDescriptor[] = [
{ id: "channels", slots: ["channel", "thread"] },
];
const ChannelsView = () => (
<>
<ChannelNavigation />
<ChannelSlot slot="channel">
<CustomChannelHeader />
<MessageList />
<MessageComposer />
</ChannelSlot>
<ThreadSlot slot="thread">
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
</>
);
export const App = () => (
<Chat client={client}>
<ChatView layouts={layouts} views={{ channels: <ChannelsView /> }} />
</Chat>
);The channel list comes from the paginators registered on client.channelManager, as in the Quick start. Without a layout, render the header inside <Channel channel={channel}> the same way.