Channel list UI
The channel list is the column of channels a user picks from. It is the UI of a ChannelPaginator: the paginator holds the query and the channels it loaded, and ChannelList renders them. This page covers what renders the list and its rows, what each row reads, and which parts you can replace. The props are in the ChannelList reference.

The ChannelList component
<ChannelList paginator={paginator} /> renders the channels of one paginator:
- When it mounts and the paginator has never loaded, it loads the first page. When the paginator is reset to that state later, for example when the user disconnects and connects again, it loads the first page again.
- It subscribes to
paginator.stateand renders a row for each channel. - When the user scrolls near the end, it loads the next page.
loadMoreThresholdPxandloadMoreDebounceMstune when. - It starts the channel manager's event subscriptions, so the list stays up to date as messages and other events arrive. Every list shares one set of subscriptions.
The list holds no selection. Clicking a row calls openChannel(channel) from useWorkspaceNavigation(), and the layout decides where the channel opens. Without a layout, openChannel does nothing; see Navigation.
The paginator should be registered on client.channelManager. Registering is what keeps it live; Channel list paginators explains it.
Several lists
Two components render every registered list for you:
ChannelListsrenders oneChannelListper paginator onclient.channelManager, in their order.ChannelNavigationrenders the list header, the search bar andChannelLists. While a search is active, the search results replace the lists.
To place lists yourself, render one ChannelList per paginator. This sidebar shows an "Unread" list above the full list:
import { useEffect, useMemo } from "react";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import { ChannelList, useChatContext } from "stream-chat-react";
const userId = "YOUR_USER_ID";
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
const allFilters: ChannelFilters = {
members: { $in: [userId] },
type: "messaging",
};
const unreadFilters: ChannelFilters = { ...allFilters, has_unread: true };
export const TwoChannelLists = () => {
const { client } = useChatContext();
const [unread, all] = useMemo(
() => [
new ChannelPaginator({
client,
filters: unreadFilters,
id: "unread",
sort,
}),
new ChannelPaginator({ client, filters: allFilters, id: "all", sort }),
],
[client],
);
// Registered lists are kept up to date by the channel manager.
useEffect(() => {
client.channelManager.insertPaginator({ paginator: unread });
client.channelManager.insertPaginator({ paginator: all });
return () => {
client.channelManager.removePaginator(unread);
client.channelManager.removePaginator(all);
};
}, [all, client, unread]);
return (
<div className="sidebar">
<h2>Unread</h2>
<ChannelList paginator={unread} />
<h2>All channels</h2>
<ChannelList paginator={all} />
</div>
);
};A channel can match both lists. The channel manager decides which lists keep it; see Channel list paginators.
Rows
Each row is a ChannelListItem. It reads its channel's stores, so a row re-renders only when its own channel changes:
| The row shows | Read from |
|---|---|
| Title, image and group member avatars | channel.state: the channel data and members |
| Last message and its time | channel.messagePaginator.aggregateState: the latest message |
| Unread badge | channel.state: the user's read state. Hidden while the channel is muted. |
| Muted and pinned icons | channel.state: the mute status and the user's membership |
| Sent, delivered or read mark on the user's own last message | channel.messageReceiptsTracker.snapshotStore |
| Highlight | isChannelActive(cid) from useWorkspaceNavigation(): the channel is open |
ChannelListItem computes these values and renders ChannelListItemUI, the row's look, with them as props. Anything rendered inside a row can read its channel with useChannelListItemContext().
Clicking a row opens the channel and passes the click event on, so a layout can open it beside the current one on ⌘/ctrl-click. See ⌘/ctrl-click opens beside.
To preview a different message than the latest, pass previewedMessage. Search results do this to show the matched message.
Header and row actions
ChannelListHeader shows the "Chats" title. ChannelNavigation renders it above the lists. At its end it renders the HeaderEndContent override, but only while a channel is open. The thread list header renders the same override, while a thread is open.
Each row has an actions menu, ChannelListItemActionButtons: a mute button, and a dropdown with archive, block (in a direct message), pin and leave. Actions the user can't take are left out: mute needs the mute-channel capability, block needs ban-channel-members, leave needs leave-channel, and archive and pin need the user to be a member. The menu is an action set with no prop, so you change it by replacing ChannelListItemActionButtons; Channel-list row actions shows how.
Loading, empty and error states
| State | What the list renders | Override key |
|---|---|---|
| Loading the first page | Placeholder rows | FirstPageLoadingIndicator |
| Loading the next page | A spinner after the last row. The component gets isLoading. |
LoadingNextPageIndicator |
| No channels | "No conversations yet" | EmptyListIndicator |
| The start or end of the list | Nothing. The component gets hasEnded and reached ("top" or "bottom"). |
EndReachedIndicator |
When a query fails, the paginator retries it, three times by default. Each failure is recorded as lastQueryError in paginator.state, and ChannelList publishes an error notification for it. The notification names no panel, so it shows in the open channel's notification list, not in the one under the channel list; Displaying notifications explains where notifications appear. The list has no error state of its own: if the first page fails, the first-page loading indicator stays.
Replacing parts
Register replacements with WithComponents. Overriding components explains how overrides apply.
| Override key | Replaces |
|---|---|
ListItem |
The whole row. Gets the channel as item. The default renders ChannelListItem. |
ChannelListItemUI |
The row's look. Gets the values in Rows as props. |
SummarizedMessagePreview |
The last-message line of a row |
ChannelListItemActionButtons |
The row actions menu |
ChannelListHeader |
The header that ChannelNavigation renders |
HeaderEndContent |
The end of the list header, while a channel is open |
Search |
The search bar and results in ChannelNavigation |
FirstPageLoadingIndicator, LoadingNextPageIndicator, EmptyListIndicator, EndReachedIndicator |
The states above |
This sidebar keeps the default row, adds a label to frozen channels, and replaces the empty state:
import type { ChannelStateData } from "stream-chat";
import type { ChannelListItemUIProps } from "stream-chat-react";
import {
ChannelListItemUI,
ChannelNavigation,
useStateStore,
WithComponents,
} from "stream-chat-react";
const frozenSelector = ({ data }: ChannelStateData) => ({
frozen: !!data?.frozen,
});
const RowWithFrozenLabel = (props: ChannelListItemUIProps) => {
const { frozen } = useStateStore(props.channel.state, frozenSelector);
return (
<div className="channel-row">
<ChannelListItemUI {...props} />
{frozen && <span className="channel-row__label">Frozen</span>}
</div>
);
};
const NoChannels = () => (
<p className="no-channels">You aren't in any channels yet.</p>
);
// Declared outside the component, so WithComponents gets the same object on every render.
const overrides = {
ChannelListItemUI: RowWithFrozenLabel,
EmptyListIndicator: NoChannels,
};
export const Sidebar = () => (
<WithComponents overrides={overrides}>
<ChannelNavigation />
</WithComponents>
);The label reads frozen from channel.state through useStateStore, so it updates when the channel is frozen. Reading channel.data.frozen during render would not: the getter isn't reactive. See Reading state in components.