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

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.state with useStateStore, and declare the selectors at module scope.
  • Show typing with TypingIndicatorHeader, the header's "Cleo is typing" text, not the message list's TypingIndicator.
  • Hide the typing text when typing events are off for the channel, in its configuration (channel.configState).
  • Keep the back and close buttons, WorkspaceNavigationBackButton and WorkspaceNavigationCloseButton. 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:

src/CustomChannelHeader.tsx
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>
  );
};
src/CustomChannelHeader.css
.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.members are the members ChannelAvatar draws when the channel has no image. It works out the overflow badge itself.
  • channel.state holds memberCount, watcherCount, the number of users watching the channel right now, and typing, the latest typing event of each user. What channels and threads hold explains the rest.
  • channel.configState holds the channel's resolved configuration. typingEvents.enabled is false when the client configuration or the channel type turns typing events off.
  • TypingIndicatorHeader renders "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:

src/App.tsx
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.