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

Thread header

This recipe builds a thread header of its own: 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 default header is explained in Thread UI.

Best practices

  • Read the thread from useThreadContext() and subscribe to thread.state for the reply count and the participants. Declare the selectors at module scope.
  • Take the channel's name from useChannelPreviewInfo(), as the default header does.
  • Keep the back and close buttons, WorkspaceNavigationBackButton and WorkspaceNavigationCloseButton. They render only when the layout allows going back or closing the thread's panel. A button of your own closes the thread with useCloseThread().
  • Show typing from channel.state.typing, filtered by the thread's id. TypingIndicatorHeader shows nothing inside a thread, because only the channel's composer receives typing events.
  • Keep the header compact, so the replies keep the space.

Create the component

src/CustomThreadHeader.tsx
import type { ChannelStateData, EventPayload, ThreadState } from "stream-chat";
import {
  Avatar,
  useChannel,
  useChannelPreviewInfo,
  useChatContext,
  useStateStore,
  useThreadContext,
  WorkspaceNavigationBackButton,
  WorkspaceNavigationCloseButton,
} from "stream-chat-react";
import "./CustomThreadHeader.css";

const threadStateSelector = ({ participants, replyCount }: ThreadState) => ({
  participants,
  replyCount,
});
const typingSelector = ({ typing }: ChannelStateData) => ({ typing });

export const CustomThreadHeader = () => {
  const { client } = useChatContext();
  const channel = useChannel();
  const thread = useThreadContext();
  const { participants = [], replyCount = 0 } =
    useStateStore(thread?.state, threadStateSelector) ?? {};
  const { typing } = useStateStore(channel.state, typingSelector);
  const { displayTitle } = useChannelPreviewInfo({ channel });

  // Others typing a reply in this thread. Typing events carry the thread's id as parent_id.
  const typingNames = (Object.values(typing) as EventPayload<"typing.start">[])
    .filter(
      ({ parent_id, user }) =>
        parent_id === thread?.id && user?.id !== client.userID,
    )
    .map(({ user }) => user?.name || user?.id);

  return (
    <div className="custom-thread-header">
      <WorkspaceNavigationBackButton />
      <div className="custom-thread-header__participants">
        {participants.map(({ user }) =>
          user ? (
            <Avatar
              imageUrl={user.image}
              key={user.id}
              size="sm"
              userName={user.name ?? user.id}
            />
          ) : null,
        )}
      </div>
      <div className="custom-thread-header__content">
        <div className="custom-thread-header__title">Thread</div>
        <div className="custom-thread-header__subtitle">
          {typingNames.length > 0
            ? `${typingNames.join(", ")} ${typingNames.length === 1 ? "is" : "are"} typing…`
            : `${displayTitle} · ${replyCount} replies`}
        </div>
      </div>
      <WorkspaceNavigationCloseButton />
    </div>
  );
};
src/CustomThreadHeader.css
.custom-thread-header {
  align-items: center;
  display: flex;
  gap: 12px;
  padding: 12px 16px;
}

.custom-thread-header__participants {
  display: flex;
  gap: 4px;
}

.custom-thread-header__content {
  flex: 1;
  min-width: 0;
}

.custom-thread-header__title {
  font-weight: 600;
}

.custom-thread-header__subtitle {
  color: #6b7280;
  font-size: 0.875rem;
}

Here is what each part reads:

  • useThreadContext() returns the thread that Thread or ThreadSlot renders. Its state holds the participants, each with a user, and the reply count. What channels and threads hold explains the rest.
  • useChannel() returns the thread's channel, so useChannelPreviewInfo() gives its name.
  • channel.state.typing holds the latest typing event of each user in the channel. An event sent from a thread carries the thread's id as parent_id. The filter is the one from Typing in threads, which also shows a typing indicator for the thread's message list.

The typing text here is plain English. To translate it, use t from useTranslationContext(), as Localization explains.

Render it in the thread

ThreadHeader has no override key. Thread renders only its children, so put the header where ThreadHeader would go. With ChatView, that's inside ThreadSlot:

src/ThreadPanel.tsx
import { MessageComposer, MessageList } from "stream-chat-react";
import { ThreadSlot } from "stream-chat-react/slot-layout";
import { CustomThreadHeader } from "./CustomThreadHeader";

export const ThreadPanel = () => (
  <ThreadSlot slot="thread">
    <CustomThreadHeader />
    <MessageList />
    <MessageComposer />
  </ThreadSlot>
);

Render ThreadPanel in the views of ChatView, as in Add threads. Without a layout, render the header inside <Thread thread={thread}> the same way.