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

Channel list UI

The channel list is the screen 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, the swipe actions, and which parts you can replace. The props are in the ChannelList reference.

The ChannelList component

<ChannelList paginator={paginator} onSelect={onSelect} /> renders the channels of one paginator:

  1. When the paginator has no channels loaded and a user is connected, it loads the first page. That happens when it mounts, when you pass it a different paginator, and after client.disconnectUser() resets the paginator. A paginator that already holds channels is shown as it is, without a new query.
  2. It subscribes to paginator.state and renders a row for each channel, in a React Native FlatList.
  3. When the user scrolls near the end, it loads the next page. loadMoreThreshold sets how near, as the FlatList's onEndReachedThreshold.
  4. It starts the event subscriptions of client.channelManager, so the list stays up to date as messages and other events arrive. Every mounted list shares one set of subscriptions, which stops when the last list unmounts.
  5. Pulling the list down refreshes it: the first page is queried again while the loaded channels stay on screen. A pull within 5 seconds of the last refresh, or of the list mounting, is ignored, unless the last query failed.
  6. It renders a NotificationList for the notifications aimed at the channel list. See Displaying notifications.

The list holds no selection and doesn't navigate. Tapping a row calls onSelect(channel), and your navigator shows the channel; see Navigation. While a row's swipe actions are open, a tap closes them instead.

The paginator belongs to your app. Register it on client.channelManager so it receives events: an unregistered paginator shows its first page and then never changes. ChannelList never disposes the paginator. Channel list paginators covers creating, registering and disposing one.

channelRenderFilterFn filters the loaded channels before they render. It runs on every render of the list, so keep it a cheap pure function.

Several lists

The SDK has no component that renders every registered list. Render one ChannelList per paginator. On a phone, one list at a time usually fits, so this screen switches between an "Unread" list and the full list:

src/ChannelListScreen.tsx
import { useEffect, useMemo, useState } from "react";
import { Button, StyleSheet, View } from "react-native";
import { ChannelPaginator } from "stream-chat";
import type { Channel, ChannelFilters, SortParamRequest } from "stream-chat";
import { ChannelList, useChatContext } from "stream-chat-react-native";

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 ChannelListScreen = ({
  onSelect,
}: {
  onSelect: (channel: Channel) => void;
}) => {
  const { client } = useChatContext();
  const [tab, setTab] = useState<"all" | "unread">("all");

  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);
      unread.dispose();
      all.dispose();
    };
  }, [all, client, unread]);

  return (
    <View style={styles.container}>
      <View style={styles.tabs}>
        <Button onPress={() => setTab("all")} title="All" />
        <Button onPress={() => setTab("unread")} title="Unread" />
      </View>
      <ChannelList
        onSelect={onSelect}
        paginator={tab === "all" ? all : unread}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1 },
  tabs: { flexDirection: "row", justifyContent: "center" },
});

Both paginators are registered, so both stay up to date while the other one is on screen. Switching tabs shows the other paginator's channels without a new query once it has loaded. A channel can match both lists; the channel manager decides which lists keep it, as Channel list paginators explains.

Rows

For each channel, the list renders a row that reads the channel's own stores, so a row re-renders only when its channel changes. The row computes the channel's muted, pinned and unread values and its latest message, and renders the ChannelPreview override, by default ChannelPreviewView, with them as props.

The row shows Rendered by Read from
Avatar: the channel image, or its members' avatars ChannelPreviewAvatar channel.state: the channel data and members
Title ChannelPreviewTitle channel.state: the channel's name, or else the other members' names
Muted and pinned icons ChannelPreviewMutedStatus, ChannelPreviewPinnedStatus channel.state: the mute status, and the user's membership (pinned_at)
Time of the latest message ChannelPreviewStatus channel.messagePaginator.aggregateState: the latest message
Unread badge ChannelPreviewUnreadCount channel.state: the user's read state. Hidden while the channel is muted.
The message line ChannelPreviewMessage See below

The title is the channel's name when it has one. Without one, a direct message shows the other member's name, and a group shows the first names of two other members, followed by "and N others" when there are more.

The unread badge caps the count at maxUnreadCount, 255 by default.

ChannelPreviewMessage shows the first of these that applies:

  1. Who is typing, from channel.state.typing, through ChannelPreviewTypingIndicator.
  2. "Draft:" and what the channel's composer holds, when the user left text or attachments in it.
  3. "No messages yet", when the channel has no messages.
  4. The poll's label, when the latest message is a poll.
  5. "Message failed to send", when the user's latest message failed.
  6. Otherwise the latest message, through ChannelPreviewLastMessage, after a sent, delivered or read mark on the user's own message, ChannelPreviewMessageDeliveryStatus. The mark reads channel.messageReceiptsTracker.snapshotStore.

The latest message is translated into the user's language when it has a translation for it.

mutedStatusPosition and pinnedStatusPosition place the muted and pinned icons next to the title ('inlineTitle', the default) or at the end of the message line ('trailingBottom').

The channel preview hooks the row uses, such as useChannelPreviewDisplayName, are exported for rows of your own. See Channel preview hooks.

Swipe actions

Swiping a row toward the start reveals its actions. By default there are two:

  • A "more" button that opens a bottom sheet, ChannelDetailsBottomSheet, with the channel's avatar, name and member count (online status in a direct message) at the top, and a list of actions.
  • Mute or unmute, as a button in the swipe row itself.

The sheet lists pin or unpin, and leave. In a direct message it also lists mute user and block user. The user who created the channel also gets delete. Leaving, deleting and blocking ask for confirmation first.

Each action has a placement: 'swipe' shows it in the swipe row, 'sheet' in the bottom sheet, and 'both' in both. To change the actions, pass getChannelActionItems to ChannelList: it gets the default items and returns the list to show. Actions shows how. swipeActionsEnabled={false} turns swiping off; the rows then have no actions.

Opening a row's actions closes the actions of any other open row.

Loading, empty and error states

State What the list renders Override key
Loading the first page Placeholder rows, numberOfSkeletons of them (8 by default) ChannelListLoadingIndicator, Skeleton
Loading the next page A spinner after the last row ChannelListFooterLoadingIndicator
Refreshing The pull-to-refresh spinner above the rows None
No channels "No conversations yet". The component gets listType="channel". EmptyStateIndicator
The first page failed "Error loading channel list...". Tapping it loads the first page again. LoadingErrorIndicator
A query failed while channels are on screen A banner over the top of the list. Tapping it refreshes the list. ChannelListHeaderErrorIndicator
No network, or the connection is down A "Reconnecting..." banner over the top of the list ChannelListHeaderNetworkDownIndicator

A channel-list paginator retries a failed query three times. When every attempt has failed, the error is recorded as lastQueryError in paginator.state, and the list shows one of the error states above. With offline support on, a failure while the list has channels isn't recorded, so the channels from the local database stay on screen without an error.

EmptyStateIndicator, LoadingErrorIndicator and the network banner are shared with other parts of the SDK. The message list renders EmptyStateIndicator too, with listType="message", so a replacement should check listType, as the sample below does.

Replacing parts

Register replacements with WithComponents. Overriding components explains how overrides apply.

Override key Replaces
ChannelPreview The row's content. Gets channel, muted, pinned, unread and lastMessage. The default is ChannelPreviewView.
ChannelPreviewAvatar The row's avatar. The default is ChannelAvatar.
ChannelPreviewTitle The title
ChannelPreviewStatus The time of the latest message
ChannelPreviewUnreadCount The unread badge
ChannelPreviewMutedStatus, ChannelPreviewPinnedStatus The muted and pinned icons
ChannelPreviewMessage The whole message line
ChannelPreviewLastMessage The latest message, or the draft, in the message line
ChannelPreviewMessageDeliveryStatus The sent, delivered or read mark
ChannelPreviewTypingIndicator Who is typing, in the message line
ChannelDetailsBottomSheet, ChannelDetailsHeader The bottom sheet the "more" button opens, and the header at its top
ListHeaderComponent Nothing by default. Rendered above the first row, and scrolls with the list.
ChannelListLoadingIndicator, Skeleton The first-page placeholder, and each placeholder row in it
ChannelListFooterLoadingIndicator The next-page spinner
EmptyStateIndicator, LoadingErrorIndicator The empty and first-page error states, here and in the message list
ChannelListHeaderErrorIndicator, ChannelListHeaderNetworkDownIndicator The error and connection banners

ChannelList itself has no override key. To render the channels in a list of your own, read paginator.state with useStateStore and render rows from it; see Reading state in components.

This list keeps the default row, adds a label to frozen channels, and replaces the empty state of the channel list only:

src/ChannelListWithLabels.tsx
import { StyleSheet, Text, View } from "react-native";
import type { Channel, ChannelPaginator, ChannelStateData } from "stream-chat";
import type {
  ChannelPreviewViewProps,
  EmptyStateProps,
} from "stream-chat-react-native";
import {
  ChannelList,
  ChannelPreviewView,
  EmptyStateIndicator,
  useStateStore,
  WithComponents,
} from "stream-chat-react-native";

const frozenSelector = ({ data }: ChannelStateData) => ({
  frozen: data?.frozen ?? false,
});

const RowWithFrozenLabel = (props: ChannelPreviewViewProps) => {
  const { frozen } = useStateStore(props.channel.state, frozenSelector);

  return (
    <View>
      <ChannelPreviewView {...props} />
      {frozen ? <Text style={styles.label}>Frozen</Text> : null}
    </View>
  );
};

// The message list renders this override too, so only the channel list's state is replaced.
const EmptyState = (props: EmptyStateProps) =>
  props.listType === "channel" ? (
    <Text style={styles.empty}>You aren't in any channels yet.</Text>
  ) : (
    <EmptyStateIndicator {...props} />
  );

// Declared outside the component, so WithComponents gets the same object on every render.
const overrides = {
  ChannelPreview: RowWithFrozenLabel,
  EmptyStateIndicator: EmptyState,
};

export const ChannelListWithLabels = ({
  onSelect,
  paginator,
}: {
  onSelect: (channel: Channel) => void;
  paginator: ChannelPaginator;
}) => (
  <WithComponents overrides={overrides}>
    <ChannelList onSelect={onSelect} paginator={paginator} />
  </WithComponents>
);

const styles = StyleSheet.create({
  empty: { padding: 16, textAlign: "center" },
  label: { fontSize: 12, paddingHorizontal: 16, paddingBottom: 8 },
});

The label reads frozen from channel.state through useStateStore, so it updates when the channel is frozen or unfrozen. Reading channel.data.frozen during render would not: the getter isn't reactive. See Reading state in components.

The options sheet

The row renders the ChannelDetailsBottomSheet key inside the bottom sheet that the "more" button opens. It receives ChannelDetailsBottomSheetProps:

Prop Type Description
channel Channel The row's channel.
items ChannelActionItem[] The actions whose placement is 'sheet' or 'both', from the list getChannelActionItems returned. Channel actions explains the items.
additionalFlatListProps Partial<FlatListProps<ChannelActionItem>> Spread over the props of the list of actions, after its own. The row doesn't pass it, so only a replacement that renders the default sheet sets it.

The default sheet renders the ChannelDetailsHeader key with the channel, then the items in a list. Each item is a ChannelActionItemView, which runs the item's action, closes the sheet and closes every open row's swipe actions when pressed.

ChannelDetailsHeader receives ChannelDetailsHeaderProps, which hold channel only. The default shows the channel's avatar, its name with the muted icon, and the member and online counts, or "Online" or "Offline" in a direct message.

This sheet keeps the default, and shows a line when getChannelActionItems leaves it no items:

src/ChannelOptionsSheet.tsx
import type { FlatListProps } from "react-native";
import { Text } from "react-native";
import { ChannelDetailsBottomSheet } from "stream-chat-react-native";
import type {
  ChannelActionItem,
  ChannelDetailsBottomSheetProps,
  ComponentOverrides,
} from "stream-chat-react-native";

// Declared once, so the list gets the same props on every render.
const listProps: Partial<FlatListProps<ChannelActionItem>> = {
  ListEmptyComponent: <Text style={{ padding: 16 }}>No actions</Text>,
};

const ChannelOptionsSheet = (props: ChannelDetailsBottomSheetProps) => (
  <ChannelDetailsBottomSheet {...props} additionalFlatListProps={listProps} />
);

// Pass to WithComponents, declared once at module scope.
export const sheetOverrides: ComponentOverrides = {
  ChannelDetailsBottomSheet: ChannelOptionsSheet,
};

Used in