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

Preview UI

A channel list renders one row per channel. This recipe replaces the look of the row and keeps everything else the SDK does for it: reading the channel's stores, highlighting the open channel, and opening a channel on click. What a row reads and which parts you can replace are explained in Channel list UI.

Best practices

  • Replace ChannelListItemUI, the row's look. ChannelListItem still wraps it: it subscribes to the channel and passes the title, image, latest message, unread count and delivery status as props.
  • Open the channel the way the default row does. Call onSelect when the row gets one, and otherwise openChannel(channel, { event }). Passing the click event keeps ⌘/ctrl-click opens beside working.
  • Keep role="option" and aria-selected on the row's button. The list is a listbox, and its arrow-key navigation moves between options.
  • Keep the str-chat__channel-list-item-container class on the row's root if you render the row actions. The SDK's styles show the actions while the row is hovered or focused, and the right arrow key moves from the row to them.
  • Preview previewedMessage, not a message you read yourself. A search result passes the matched message there instead of the latest one.
  • Build the last-message line with SummarizedMessagePreview or useLatestMessagePreview. They handle deleted and failed messages, attachments, polls and the sender's name.
  • Keep rows light. Every row re-renders when its own channel changes.

Custom channel preview

This sidebar shows each channel as a row with the avatar, the title, the time of the last message, an unread badge and the last message:

src/Sidebar.tsx
import type { ChannelPaginator } from "stream-chat";
import type { ChannelListItemUIProps } from "stream-chat-react";
import {
  ChannelAvatar,
  ChannelList,
  ChannelListItemActionButtons,
  ChannelListItemTimestamp,
  SummarizedMessagePreview,
  useWorkspaceNavigation,
  WithComponents,
} from "stream-chat-react";
import "./Sidebar.css";

const CompactChannelRow = ({
  active,
  channel,
  displayImage,
  displayTitle,
  groupChannelDisplayInfo,
  messageDeliveryStatus,
  onSelect,
  previewedMessage,
  unread,
}: ChannelListItemUIProps) => {
  const { openChannel } = useWorkspaceNavigation();

  return (
    <div className="str-chat__channel-list-item-container">
      <button
        aria-selected={active || undefined}
        className="channel-row__button"
        onClick={(event) => {
          if (onSelect) onSelect(event);
          else openChannel(channel, { event });
        }}
        role="option"
        type="button"
      >
        <ChannelAvatar
          displayMembers={groupChannelDisplayInfo?.members}
          imageUrl={displayImage}
          size="lg"
          userName={displayTitle}
        />
        <div className="channel-row__main">
          <div className="channel-row__top">
            <span className="channel-row__title">{displayTitle}</span>
            <ChannelListItemTimestamp previewedMessage={previewedMessage} />
          </div>
          <SummarizedMessagePreview
            latestMessage={previewedMessage}
            messageDeliveryStatus={messageDeliveryStatus}
            participantCount={channel.data?.member_count}
          />
        </div>
        {!!unread && <span className="channel-row__unread">{unread}</span>}
      </button>
      <ChannelListItemActionButtons />
    </div>
  );
};

// Declared once, so WithComponents gets the same object on every render.
const overrides = { ChannelListItemUI: CompactChannelRow };

export const Sidebar = ({ paginator }: { paginator: ChannelPaginator }) => (
  <WithComponents overrides={overrides}>
    <ChannelList paginator={paginator} />
  </WithComponents>
);
src/Sidebar.css
.channel-row__button {
  display: flex;
  align-items: center;
  width: 100%;
  gap: 12px;
  min-width: 0;
  padding: 8px 12px;
  border: 0;
  background: none;
  text-align: start;
  cursor: pointer;
}

.channel-row__button[aria-selected="true"] {
  background: #eef2ff;
}

.channel-row__main {
  flex: 1;
  min-width: 0;
}

.channel-row__top {
  display: flex;
  justify-content: space-between;
  gap: 8px;
}

.channel-row__title {
  overflow: hidden;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.channel-row__unread {
  min-width: 20px;
  padding: 2px 6px;
  border-radius: 10px;
  background: #005fff;
  color: #ffffff;
  font-size: 12px;
  text-align: center;
}

What happens for each channel:

  1. ChannelList renders a ChannelListItem for the channel. It reads the channel's data, members, read state and latest message, and renders the ChannelListItemUI override, CompactChannelRow, with them as props.
  2. displayTitle and displayImage fall back to the other member's name and image in a direct message, and groupChannelDisplayInfo lets ChannelAvatar show the members of a group without an image.
  3. unread is the user's unread count, or 0 while the channel is muted. active is true while the channel is open in the layout.
  4. SummarizedMessagePreview renders the last-message line. ChannelListItemTimestamp renders its time.
  5. ChannelListItemActionButtons renders the row's actions: a mute button, and a menu with archive, block (in a direct message), pin and leave. It reads the row's channel from context, so it needs no props. Leave it out to drop the actions.

Using display helpers

Outside a channel list there is no ChannelListItem to compute the props. The hooks it uses are exported, so a channel shown anywhere else can look the same. This chip shows a channel the app holds, for example a "Recent" strip above the list:

src/ChannelChip.tsx
import type { Channel, MessagePaginatorAggregateState } from "stream-chat";
import {
  ChannelAvatar,
  useChannelPreviewInfo,
  useLatestMessagePreview,
  useStateStore,
  useWorkspaceNavigation,
} from "stream-chat-react";

const latestMessageSelector = ({
  latestMessage,
}: MessagePaginatorAggregateState) => ({
  latestMessage: latestMessage ?? undefined,
});

export const ChannelChip = ({ channel }: { channel: Channel }) => {
  const { openChannel } = useWorkspaceNavigation();
  const { displayImage, displayTitle, groupChannelDisplayInfo } =
    useChannelPreviewInfo({ channel });
  const { latestMessage } = useStateStore(
    channel.messagePaginator.aggregateState,
    latestMessageSelector,
  );
  const { senderName, text, type } = useLatestMessagePreview({
    latestMessage,
    participantCount: channel.data?.member_count,
  });

  return (
    <button
      className="channel-chip"
      onClick={(event) => openChannel(channel, { event })}
      type="button"
    >
      <ChannelAvatar
        displayMembers={groupChannelDisplayInfo.members}
        imageUrl={displayImage}
        size="md"
        userName={displayTitle}
      />
      <span className="channel-chip__title">{displayTitle}</span>
      <span className="channel-chip__preview">
        {type === "giphy" ? "🎞️ " : null}
        {senderName ? `${senderName}: ` : null}
        {text}
      </span>
    </button>
  );
};
  • useChannelPreviewInfo({ channel }) returns the title, the image and the group members the default row uses. It subscribes to the channel, so a renamed channel shows its new name.
  • channel.messagePaginator.aggregateState holds the channel's latest message. Read it with useStateStore and a selector declared outside the component; Reading state in components explains the rules.
  • useLatestMessagePreview turns the message into the line's text. type says what the message holds: text, image, giphy, video, voice, file, link, location, poll, deleted, error, empty or unsupported. A Giphy has its own type, so give it its own branch rather than treating it as an image. senderName is "You" for the user's own message, and the author's name in a group.

Going deeper

When replacing the row's look isn't the right size of change:

  1. Only the last-message line. Override SummarizedMessagePreview and keep the default row around it.
  2. The whole row, wrapper included. Override ListItem. It gets the channel as item, and the default renders ChannelListItem.
  3. The row actions. Override ChannelListItemActionButtons; Channel-list row actions shows how.
  4. The empty and loading states. Override EmptyListIndicator, FirstPageLoadingIndicator and LoadingNextPageIndicator.
  5. When the next page loads. Set loadMoreThresholdPx and loadMoreDebounceMs on ChannelList.

Every key is listed in Channel list UI → Replacing parts, and the props in the Channel Preview reference.