Channel List UI

ChannelList is the primary navigation in a chat app. Because chat is real-time, it subscribes to many event types to keep the list updated (new messages, updates, channel changes, presence, etc.).

That’s a lot of logic to reimplement. We recommend building on the SDK’s ChannelList even if you need heavy customization. It supports:

  1. Custom channel preview
  2. Custom channel list wrapper
  3. Custom channel list renderer
  4. Custom paginator

Best Practices

  • Use ChannelList as the base and override only the preview UI you need.
  • Keep preview rendering fast to avoid sluggish navigation.
  • Read channel state from Preview props (for example channel) instead of re-querying.
  • Preserve selection handling so active channel updates stay consistent.
  • Add timestamps or metadata only when it improves scanability.

This guide takes a deep dive into these customization options.

Custom Channel Preview

A channel preview is a single item in the list. It should reflect current channel state and handle selection.

Customize previews by passing a component to Preview. ChannelList wraps each item in ChannelPreview, which handles events (new/updated/deleted messages). You can just read the latest state from props.

<ChannelList Preview={CustomChannelPreview} />
// Don't forget to provide filter and sort options as well!

Let's implement a simple custom preview:

const CustomChannelPreview = ({
  displayImage,
  displayTitle,
  latestMessagePreview,
}) => (
  <div className="channel-preview">
    <img className="channel-preview__avatar" src={displayImage} alt="" />
    <div className="channel-preview__main">
      <div className="channel-preview__header">{displayTitle}</div>
      <div className="channel-preview__message">{latestMessagePreview}</div>
    </div>
  </div>
);

(See also the complete reference of the available preview component props.)

The preview props are usually enough. If you need more data, read it from the channel state. In this example we add the timestamp of the latest message:

const CustomChannelPreview = (props) => {
  const { channel, displayImage, displayTitle, latestMessagePreview } = props;
  const { userLanguage } = useTranslationContext();
  const latestMessageAt = channel.state.last_message_at;

  const timestamp = useMemo(() => {
    if (!latestMessageAt) {
      return "";
    }
    const formatter = new Intl.DateTimeFormat(userLanguage, {
      timeStyle: "short",
    });
    return formatter.format(latestMessageAt);
  }, [latestMessageAt, userLanguage]);

  return (
    <div className="channel-preview">
      <img className="channel-preview__avatar" src={displayImage} alt="" />
      <div className="channel-preview__main">
        <div className="channel-preview__header">
          {displayTitle}
          <time
            dateTime={latestMessageAt?.toISOString()}
            className="channel-preview__timestamp"
          >
            {timestamp}
          </time>
        </div>
        <div className="channel-preview__message">{latestMessagePreview}</div>
      </div>
    </div>
  );
};

One more thing we should add is the click event handler, which should change the currently active channel. That's easy enough to do:

const CustomChannelPreview = (props) => {
  const {
    channel,
    activeChannel,
    displayImage,
    displayTitle,
    latestMessagePreview,
    setActiveChannel,
  } = props;
  const latestMessageAt = channel.state.last_message_at;
  const isSelected = channel.id === activeChannel?.id;
  const { userLanguage } = useTranslationContext();

  const timestamp = useMemo(() => {
    if (!latestMessageAt) {
      return "";
    }
    const formatter = new Intl.DateTimeFormat(userLanguage, {
      timeStyle: "short",
    });
    return formatter.format(latestMessageAt);
  }, [latestMessageAt, userLanguage]);

  const handleClick = () => {
    setActiveChannel?.(channel);
  };

  return (
    <button
      className={`channel-preview ${isSelected ? "channel-preview_selected" : ""}`}
      disabled={isSelected}
      onClick={handleClick}
    >
      <img className="channel-preview__avatar" src={displayImage} alt="" />
      <div className="channel-preview__main">
        <div className="channel-preview__header">
          {displayTitle}
          <time
            dateTime={latestMessageAt?.toISOString()}
            className="channel-preview__timestamp"
          >
            {timestamp}
          </time>
        </div>
        <div className="channel-preview__message">{latestMessagePreview}</div>
      </div>
    </button>
  );
};

We also add a class when the channel is active (by comparing the active channel ID with the current channel ID).

Custom Channel List Wrapper

The channel list wrapper renders items and handles loading/error states. It’s a good place to add a custom loader or extra UI like a header/footer.

You can do this by providing a custom component in the List prop of the ChannelList component. It will get a bunch of props from the parent ChannelList, including a list of loaded channels, a loading flag, and an error object (if any).

<ChannelList List={CustomChannelList} />
// Don't forget to provide filter and sort options as well!

The simplest implementation of the custom channel list wrapper looks like this:

const CustomChannelList = ({ children, loading, error }) => {
  if (loading) {
    return <div className="channel-list__placeholder">⏳ Loading...</div>;
  }

  if (error) {
    return (
      <div className="channel-list__placeholder">
        💣 Error loading channels
        <br />
        <button
          className="channel-list__button"
          onClick={() => window.location.reload()}
        >
          Reload page
        </button>
      </div>
    );
  }

  return (
    <div className="channel-list">
      {loadedChannels && (
        <div className="channel-list__counter">
          {loadedChannels.length} channels:
        </div>
      )}
      {children}
    </div>
  );
};

Loading state

Error state

Normal state

If you need the channel array, use loadedChannels. It updates frequently, so you must opt in by setting sendChannelsToList on ChannelList:

<ChannelList List={CustomChannelList} sendChannelsToList />;
// Don't forget to provide filter and sort options as well!

const CustomChannelList = ({
  loadedChannels,
  children,
  loading,
  error,
}: React.PropsWithChildren<ChannelListMessengerProps>) => {
  if (loading) {
    return <div className="channel-list__placeholder">⏳ Loading...</div>;
  }

  if (error) {
    return (
      <div className="channel-list__placeholder">
        💣 Error loading channels
        <br />
        <button
          className="channel-list__button"
          onClick={() => window.location.reload()}
        >
          Reload page
        </button>
      </div>
    );
  }

  if (loadedChannels?.length === 0) {
    return (
      <div className="channel-list__placeholder">
        🤷 You have no channels... yet
      </div>
    );
  }

  return (
    <div className="channel-list">
      {loadedChannels && (
        <div className="channel-list__counter">
          {loadedChannels.length} channels:
        </div>
      )}
      {children}
    </div>
  );
};

Custom Channel List Renderer

By default, ChannelList renders previews in query order. To inject custom grouping or subheadings, provide a custom renderChannels function.

The function receives the loaded channels and a preview renderer. That renderer is the Preview component wrapped with ChannelPreview so event listeners are already wired. renderChannels runs only when the list is loaded and non-empty.

This example adds a separator between read and unread channels:

const renderChannels = (channels, getChannelPreview) => {
  const unreadChannels = [];
  const readChannels = [];

  for (const channel of channels) {
    const hasUnread = channel.countUnread();
    (hasUnread ? unreadChannels : readChannels).push(channel);
  }

  return [unreadChannels, readChannels]
    .filter((group) => group.length > 0)
    .map((group, index) => (
      <div key={index} className="channel-group">
        {group.map((channel) => (
          <div key={channel.id}>{getChannelPreview(channel)}</div>
        ))}
      </div>
    ));
};