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.ChannelListItemstill 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
onSelectwhen the row gets one, and otherwiseopenChannel(channel, { event }). Passing the click event keeps ⌘/ctrl-click opens beside working. - Keep
role="option"andaria-selectedon the row's button. The list is a listbox, and its arrow-key navigation moves between options. - Keep the
str-chat__channel-list-item-containerclass 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
SummarizedMessagePrevieworuseLatestMessagePreview. 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:
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>
);.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:
ChannelListrenders aChannelListItemfor the channel. It reads the channel's data, members, read state and latest message, and renders theChannelListItemUIoverride,CompactChannelRow, with them as props.displayTitleanddisplayImagefall back to the other member's name and image in a direct message, andgroupChannelDisplayInfoletsChannelAvatarshow the members of a group without an image.unreadis the user's unread count, or0while the channel is muted.activeistruewhile the channel is open in the layout.SummarizedMessagePreviewrenders the last-message line.ChannelListItemTimestamprenders its time.ChannelListItemActionButtonsrenders 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:
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.aggregateStateholds the channel's latest message. Read it withuseStateStoreand a selector declared outside the component; Reading state in components explains the rules.useLatestMessagePreviewturns the message into the line's text.typesays what the message holds:text,image,giphy,video,voice,file,link,location,poll,deleted,error,emptyorunsupported. A Giphy has its own type, so give it its own branch rather than treating it as an image.senderNameis "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:
- Only the last-message line. Override
SummarizedMessagePreviewand keep the default row around it. - The whole row, wrapper included. Override
ListItem. It gets the channel asitem, and the default rendersChannelListItem. - The row actions. Override
ChannelListItemActionButtons; Channel-list row actions shows how. - The empty and loading states. Override
EmptyListIndicator,FirstPageLoadingIndicatorandLoadingNextPageIndicator. - When the next page loads. Set
loadMoreThresholdPxandloadMoreDebounceMsonChannelList.
Every key is listed in Channel list UI → Replacing parts, and the props in the Channel Preview reference.