Empty and loading states

A channel list has three states before it shows any channels: its first page is loading, the user has no channels, or loading failed. This recipe gives each one its own look.

Note:

What you'll build

  • Placeholder rows while the first page loads
  • An error message with a Retry button
  • A "No conversations yet" message with a New conversation button

Preview

The channel list loading its first page, with five grey placeholder rows of an avatar circle and text bars

Approach: Replace one part. Choosing where to start explains the approaches.

Starts from: the base app, with the Starting a conversation recipe, whose New conversation dialog the empty list opens. The page shows only what the recipe adds.

Best practices

  • Shape the placeholder rows like the real rows, with the same height and spacing, so nothing moves when the channels arrive.
  • Hide the placeholder's shapes from screen readers, and tell them the list is loading with one message, such as "Loading conversations…", through the SDK's announcer.
  • Stop the shimmer when the user asks for reduced motion.
  • Give the empty list one clear action. Here it opens the same dialog as the header's New conversation button, so both start a conversation the same way.
  • Show the error in the list's place, with a way to try again. A list whose first page failed has no channels to show, so the error replaces the placeholder, not the rows.
  • Mark the Retry button busy while the retry runs, with aria-busy and aria-disabled rather than disabled, so the focus stays on it.
  • Register the states around the channel list only, since <ThreadList/> reads the same loading and error keys.

Show placeholder rows while the first page loads

<ChannelList/> renders its FirstPageLoadingIndicator until paginator.state.items holds the first page. These rows have the avatar, the title and the time, and the last message of a real row, with a different width on each row. A live region that appears already filled is often not read, so the placeholder announces "Loading conversations…" with useAriaLiveAnnouncer() instead, through the live region <Chat/> keeps on the page. Announcing to assistive technology explains the announcer:

src/ChannelListSkeleton.tsx
import { useEffect } from "react";
import { useAriaLiveAnnouncer } from "stream-chat-react";
import "./ChannelListSkeleton.css";

// Different widths per row, so the placeholder reads as a list of conversations.
const rows = [
  { preview: "88%", title: "52%" },
  { preview: "70%", title: "38%" },
  { preview: "80%", title: "60%" },
  { preview: "64%", title: "44%" },
  { preview: "76%", title: "34%" },
];

/**
 * Placeholder rows shaped like the channel list's rows: an avatar, the title and the time on the
 * first line, the last message on the second. Screen readers skip the shapes and hear one message
 * through the SDK's announcer instead.
 */
export const ChannelListSkeleton = () => {
  const announce = useAriaLiveAnnouncer();

  // Delayed, so a first page that arrives quickly is not announced at all; unmounting cancels it.
  useEffect(
    () => announce("Loading conversations…", { delayMs: 300 }),
    [announce],
  );

  return (
    <div aria-hidden="true" className="cookbook-channel-list-skeleton">
      {rows.map(({ preview, title }, index) => (
        <div className="cookbook-channel-list-skeleton__row" key={index}>
          <div className="cookbook-channel-list-skeleton__avatar" />
          <div className="cookbook-channel-list-skeleton__lines">
            <div className="cookbook-channel-list-skeleton__first-line">
              <div
                className="cookbook-channel-list-skeleton__bar"
                style={{ width: title }}
              />
              <div className="cookbook-channel-list-skeleton__bar cookbook-channel-list-skeleton__bar--time" />
            </div>
            <div
              className="cookbook-channel-list-skeleton__bar"
              style={{ width: preview }}
            />
          </div>
        </div>
      ))}
    </div>
  );
};

Show an error with a Retry

When the first page fails, the paginator retries it, three times by default. Meanwhile the list keeps showing its FirstPageLoadingIndicator. Once the last attempt fails, the paginator records the error as paginator.state.lastQueryError, and paginator.state.items stays undefined. <ChannelList/> then renders its ListLoadErrorIndicator in place of the list. Its retry calls ChannelPaginator.reload(). ListLoadErrorIndicatorProps lists its props.

The list keeps the error view on screen while a retry runs, as FirstPageLoadingIndicator and ListLoadErrorIndicator explains. <ChannelListLoadError/> reads "Retrying…" meanwhile. When the retry fails too, the button reads Retry again and the error is announced once more. The recipe's error announcement is polite, so it does not compete with the SDK's error notification, which is announced assertively at the same moment:

src/ChannelListLoadError.tsx
import { useEffect } from "react";
import type { ListLoadErrorIndicatorProps } from "stream-chat-react";
import {
  Button,
  IconExclamationCircleFill,
  IconRetry,
  useAriaLiveAnnouncer,
} from "stream-chat-react";
import "./ChannelListState.css";

const title = "Couldn’t load your conversations";

/**
 * Shown in place of the channel list when its first page failed to load. `retry` queries the first
 * page again. The list keeps this view on screen while the retry runs, so the Retry button keeps
 * focus: it turns busy, and reads Retry again if the retry fails.
 */
export const ChannelListLoadError = ({
  isRetrying,
  retry,
}: ListLoadErrorIndicatorProps) => {
  const announce = useAriaLiveAnnouncer();

  // Announced when the error first shows and again whenever a retry fails. Polite, so it follows
  // the SDK's own error notification, which is announced assertively at the same moment. The dedupe
  // window swallows the second run of the effect in development.
  useEffect(() => {
    if (isRetrying) return;
    announce(title, { dedupeMs: 1000, priority: "polite" });
  }, [announce, isRetrying]);

  return (
    <div className="cookbook-channel-list-state">
      <div className="cookbook-channel-list-state__icon cookbook-channel-list-state__icon--error">
        <IconExclamationCircleFill />
      </div>
      <p className="cookbook-channel-list-state__title">{title}</p>
      <p className="cookbook-channel-list-state__text">
        Something went wrong. Try again.
      </p>
      {/* aria-disabled, not disabled: a disabled button would lose focus. */}
      <Button
        appearance="outline"
        aria-busy={isRetrying}
        aria-disabled={isRetrying}
        onClick={retry}
        size="md"
        variant="secondary"
      >
        <IconRetry />
        {isRetrying ? "Retrying…" : "Retry"}
      </Button>
    </div>
  );
};

The load error with Retry

The channel list after its first page failed to load, with "Couldn’t load your conversations", "Something went wrong. Try again.", and a Retry button

Show an empty list with a call to action

<ChannelList/> renders its EmptyListIndicator when the first page came back with no channels. This one invites the user to start a conversation, and its button opens the NewConversationDialog from Starting a conversation. It uses the stylesheet of Show an error with a Retry:

src/EmptyChannelList.tsx
import { useState } from "react";
import {
  Button,
  GlobalModal,
  IconMessageBubbles,
  IconPlus,
} from "stream-chat-react";
import { NewConversationDialog } from "./NewConversationDialog";
import "./ChannelListState.css";

/**
 * Shown in place of the channel list when the user has no conversations. The button opens the same
 * New conversation dialog as the header's button.
 */
export const EmptyChannelList = () => {
  const [open, setOpen] = useState(false);
  const close = () => setOpen(false);

  return (
    <div className="cookbook-channel-list-state">
      <div className="cookbook-channel-list-state__icon">
        <IconMessageBubbles />
      </div>
      <p className="cookbook-channel-list-state__title">No conversations yet</p>
      <p className="cookbook-channel-list-state__text">
        Start one with a person or a group, and it shows up here.
      </p>
      <Button
        appearance="solid"
        aria-haspopup="dialog"
        onClick={() => setOpen(true)}
        size="md"
        variant="primary"
      >
        <IconPlus />
        New conversation
      </Button>
      <GlobalModal onClose={close} open={open}>
        {/* Mounted only while open, so every opening starts with an empty dialog. */}
        {open && <NewConversationDialog onClose={close} />}
      </GlobalModal>
    </div>
  );
};

The empty channel list

The empty channel list with "No conversations yet", a line inviting the user to start one, and a New conversation button

Register the states

FirstPageLoadingIndicator, ListLoadErrorIndicator and EmptyListIndicator are ComponentContext keys, so <WithComponents/> registers them for every channel list inside it. The wrapper goes around the channel list only, because the thread list reads the same loading and error keys:

src/WithChannelListStates.tsx
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react";
import { ChannelListLoadError } from "./ChannelListLoadError";
import { ChannelListSkeleton } from "./ChannelListSkeleton";
import { EmptyChannelList } from "./EmptyChannelList";

// Declared once, so WithComponents gets the same object on every render.
const overrides = {
  EmptyListIndicator: EmptyChannelList,
  FirstPageLoadingIndicator: ChannelListSkeleton,
  ListLoadErrorIndicator: ChannelListLoadError,
};

/**
 * Replaces the loading, error and empty states of every channel list inside it. `ThreadList` reads
 * the loading and error slots too, so wrap only the channel list: the `ChannelNavigation` your view
 * renders, or `slotRenderers.channelList` when `ChatView` renders the list from its slot registry.
 */
export const WithChannelListStates = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

Wrap the <ChannelNavigation/> of the channels view in it. Wrap the app in <WithNewConversation/> from Starting a conversation, which registers the handler that creates a new conversation on its first message:

src/App.tsx
import {
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  ThreadHeader,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { WithNewConversation } from "./NewConversationButton";
import { WithChannelListStates } from "./WithChannelListStates";

const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
];

const ChannelsView = () => (
  <>
    <WithChannelListStates>
      <ChannelNavigation />
    </WithChannelListStates>
    <ChannelSlot slot="channel">
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadSlot slot="thread">
      <ThreadHeader />
      <MessageList />
      <MessageComposer />
    </ThreadSlot>
  </>
);

export const App = () => (
  <Chat client={client}>
    <WithNewConversation>
      <ChatView layouts={layouts} views={{ channels: <ChannelsView /> }} />
    </WithNewConversation>
  </Chat>
);

When <ChatView/> renders the channel list itself, through its built-in layout, wrap the list in slotRenderers.channelList instead. slotRenderers explains the renderers.

How it works

Info:

Example

The user opens the app while the server is having trouble, so the query for the channels fails with a server error although the client is connected:

  1. <ChannelList/> asks the paginator for the first page with ChannelPaginator.toTailDebounced(). Until the first page arrives, paginator.state.items is undefined, so the list renders <ChannelListSkeleton/>. If the first page takes a moment, screen readers hear "Loading conversations…".
  2. The request fails. The paginator retries it, three times by default, and paginator.state.isLoading stays true, so <ChannelListSkeleton/> stays on screen.
  3. The last retry fails. The paginator records the error as paginator.state.lastQueryError and sets paginator.state.isLoading to false. paginator.state.items is still undefined, so the list renders <ChannelListLoadError/> in place of the rows. Screen readers hear the SDK's error notification, "Error notification: Couldn't load conversations", then the recipe's "Couldn’t load your conversations".
  4. A moment later, the user presses Retry. retry calls ChannelPaginator.reload(). The list keeps <ChannelListLoadError/> on screen with isRetrying set, so the Retry button, which keeps the focus, reads "Retrying…" while the query runs.
  5. This time the first page arrives, with no channels: the user isn't a member of any channel yet. The paginator sets paginator.state.items to an empty array, so the list renders <EmptyChannelList/>, and screen readers hear "Conversations loaded". Had the retry failed, the button would read Retry again, still focused, and screen readers would hear the error once more.
  6. The user presses New conversation, picks Bob, and sends a first message. The new channel matches the list's filters, so it shows up in the list, which then renders its rows instead of the empty state.

Each failed query also publishes an error notification, "Couldn't load conversations", of type api:channel:query:failed. It shows in the channel-list panel, below the lists of <ChannelNavigation/>. When the first page fails explains it.

The states apply to every channel list inside <WithChannelListStates/>, so a <ChannelNavigation/> with several lists shows them in each list whose first page is loading, empty or failed.