# Preview UI

> **Info:** This is release candidate documentation for **Stream Chat React SDK v15**. For the latest stable version, see the [latest version (v14)](https://getstream.io/chat/docs/sdk/react/).

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](https://getstream.io/chat/docs/sdk/react/v15/ui/components/channel-list-ui/).

## Best practices

- Replace `ChannelListItemUI`, the row's look. `ChannelListItem` still 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 `onSelect` when the row gets one, and otherwise `openChannel(channel, { event })`. Passing the click event keeps [⌘/ctrl-click opens beside](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/ctrl-click-opens-beside/) working.
- Keep `role="option"` and `aria-selected` on the row's button. The list is a listbox, and its arrow-key navigation moves between options.
- Keep the `str-chat__channel-list-item-container` class 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 `SummarizedMessagePreview` or `useLatestMessagePreview`. 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:

```tsx title="src/Sidebar.tsx"
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>
);
```

```css title="src/Sidebar.css"
.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:

1. `ChannelList` renders a `ChannelListItem` for the channel. It reads the channel's data, members, read state and latest message, and renders the `ChannelListItemUI` override, `CompactChannelRow`, with them as props.
2. `displayTitle` and `displayImage` fall back to the other member's name and image in a direct message, and `groupChannelDisplayInfo` lets `ChannelAvatar` show the members of a group without an image.
3. `unread` is the user's unread count, or `0` while the channel is muted. `active` is `true` while the channel is open in the layout.
4. `SummarizedMessagePreview` renders the last-message line. `ChannelListItemTimestamp` renders its time.
5. `ChannelListItemActionButtons` renders 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:

```tsx title="src/ChannelChip.tsx"
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.aggregateState` holds the channel's latest message. Read it with `useStateStore` and a selector declared outside the component; [Reading state in components](https://getstream.io/chat/docs/sdk/react/v15/ui/reading-state-in-components/) explains the rules.
- `useLatestMessagePreview` turns the message into the line's text. `type` says what the message holds: `text`, `image`, `giphy`, `video`, `voice`, `file`, `link`, `location`, `poll`, `deleted`, `error`, `empty` or `unsupported`. A Giphy has its own type, so give it its own branch rather than treating it as an image. `senderName` is "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:

1. **Only the last-message line.** Override `SummarizedMessagePreview` and keep the default row around it.
2. **The whole row,** wrapper included. Override `ListItem`. It gets the channel as `item`, and the default renders `ChannelListItem`.
3. **The row actions.** Override `ChannelListItemActionButtons`; [Channel-list row actions](https://getstream.io/chat/docs/sdk/react/v15/ui/customizing/actions/#channel-list-row-actions) shows how.
4. **The empty and loading states.** Override `EmptyListIndicator`, `FirstPageLoadingIndicator` and `LoadingNextPageIndicator`.
5. **When the next page loads.** Set `loadMoreThresholdPx` and `loadMoreDebounceMs` on `ChannelList`.

Every key is listed in [Channel list UI → Replacing parts](https://getstream.io/chat/docs/sdk/react/v15/ui/components/channel-list-ui/#replacing-parts), and the props in the [Channel Preview reference](https://getstream.io/chat/docs/sdk/react/v15/component-reference/channel-list/channel-preview/).

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/v15/recipes/channel-list/preview-ui/](https://getstream.io/chat/docs/sdk/react/v15/recipes/channel-list/preview-ui/).