Channel List UI

ChannelList is a primary navigation component of a chat, used to display and switch between available channels. Due to the dynamic, real-time nature of chat, the ChannelList component needs to subscribe to and handle many different types of events to keep the list state up to date. This includes handling new message events and updates to the existing ones, channel update events, channel member presence events, and so on.

That’s a lot of work, and if done incorrectly, it’s easy to miss an important event. That is why we always recommend building on top of the ChannelList component provided by the SDK, even if you need to do some major customization. Fortunately, the component itself is very flexible, and basically everything about it can be customized. It supports

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

This guide takes a deep dive into these customization options.

Custom Channel Preview

You can think of a channel preview as a single item of the channel list. The preview should accurately display the current channel state and handle user interactions to switch to the selected channel.

You can customize the look and behavior of the channel previews by providing a custom component in the Preview prop of the ChannelList component. When rendering previews, ChannelList wraps each item with a ChannelPreview wrapper that handles channel events such as new, updated and deleted messages. This way, you don’t have to subscribe to these events yourself, and you can just grab the latest state from the props instead.

<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 props provided to the preview component are usually sufficient to render the preview. However, if you need additional data, you can always get it from the channel state. In this next example, we will add the timestamp of the latest message in the channel:

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>
  );
};

Note that we’ve also added an additional class to the preview element if its channel is currently active (we check this by comparing the id of the active channel to the id of the current channel.)

Custom Channel List Wrapper

Channel list wrapper is responsible for rendering the actual items of the channel list, as well as handling the loading and error states. This is a great place to plug in your custom component if you want to have a custom loader, or to add some additional content (like header and footer) to the channel list.

You can do this by providing a custom 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 access to the array of channel objects, you can use the loadedChannels prop. Keep in mind, however, that it can update frequently, resulting in excessive rendering. For this reason, you have to explicitly opt-in by setting the sendChannelsToList prop on the 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<StreamChatGenerics>>) => {
  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, the ChannelList component provided by the SDK renders channel previews in the same order as the channels were queried. If you want to hook into the list rendering process, e.g. to add subheadings, group channels, etc., you can provide a custom list renderer in the renderChannels prop of the ChannelList component.

The function passed in this prop takes a list of loaded channels and a channel preview component. Note that it is not exactly the component passed in the Preview prop, but rather a version of that component wrapped with the ChannelPreview helper to ensure that all channel event listeners are set up properly. The renderChannels function is only called if the channel list was successfully loaded and is not 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>
    ));
};

© Getstream.io, Inc. All Rights Reserved.