# Empty and loading states

A channel list has three states before it shows any channels: its first page is loading, loading failed, or the user has no channels. This UI cookbook gives each one its own look: placeholder rows shaped like the real rows, an error with a Retry button, and an empty list with a New conversation button. Which states each list has, and what triggers them, is on [List states UI](https://getstream.io/chat/docs/sdk/react-native/ui/components/list-states-ui/#the-channel-list). The button opens the screen built in [Starting a conversation](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/channel-list/starting-a-conversation/).

>
> **Note:** **What you'll build**
>
> - Placeholder rows while the first page loads
> - An error with a Retry button
> - An empty list with a New conversation button
>

---

**Approach:** Replace one part. [Choosing where to start](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/overview/#choosing-where-to-start) explains the approaches.

## 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", with `useAnnounceOnShow()`.
- Stop the animation when the user asks for reduced motion.
- Give the empty list one clear action. Here it opens the same screen 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.
- Register the states around the channel list only. `EmptyStateIndicator` and `LoadingErrorIndicator` are read by `Channel`, the message list and `Thread` too.

## Show placeholder rows while the first page loads

`ChannelList` renders its `ChannelListLoadingIndicator` until the paginator's first page arrives, and again while a retry reloads it. The default renders the `Skeleton` key `numberOfSkeletons` times, 8 by default. Registering a replacement for `Skeleton` changes the row, but the rows are identical and the list announces nothing. `ChannelListLoadingIndicator` changes the whole placeholder, so these rows differ in width, as the text of real rows does, and the list is announced once.

Each row has an avatar circle, a title and a line for the last message. The shapes pulse, unless the user asked for reduced motion. The shapes are hidden from screen readers, and the placeholder reads as one element: a busy progress bar named "Loading conversations". `useAnnounceOnShow()` announces it too, after half a second, so a list that loads quickly announces nothing. Accessibility is opt-in, and `useAnnounceOnShow()` and `useReducedMotionPreference()` do nothing until it's turned on. See [Accessibility](https://getstream.io/chat/docs/sdk/react-native/features/app-experience/accessibility/#announcing-a-surface-when-it-shows).

```tsx title="src/ChannelListSkeleton.tsx"
import { useEffect, useRef } from "react";
import { Animated, StyleSheet, View } from "react-native";
import {
  useAnnounceOnShow,
  useChannelsContext,
  useReducedMotionPreference,
  useTheme,
} from "stream-chat-react-native";

// 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%" },
] as const;

/**
 * Placeholder rows shaped like the channel list's rows: an avatar, a title and a line for the last
 * message. Screen readers skip the shapes and hear one message instead.
 */
export const ChannelListSkeleton = () => {
  const { numberOfSkeletons } = useChannelsContext();
  const {
    theme: { semantics },
  } = useTheme();
  const reduceMotion = useReducedMotionPreference();
  const opacity = useRef(new Animated.Value(1)).current;

  useAnnounceOnShow(true, "Loading conversations");

  useEffect(() => {
    if (reduceMotion) {
      opacity.setValue(1);
      return;
    }
    const pulse = Animated.loop(
      Animated.sequence([
        Animated.timing(opacity, {
          duration: 800,
          toValue: 0.4,
          useNativeDriver: true,
        }),
        Animated.timing(opacity, {
          duration: 800,
          toValue: 1,
          useNativeDriver: true,
        }),
      ]),
    );
    pulse.start();
    return () => pulse.stop();
  }, [opacity, reduceMotion]);

  const shape = { backgroundColor: semantics.backgroundCoreSurfaceDefault };

  return (
    <View
      accessibilityLabel="Loading conversations"
      accessibilityRole="progressbar"
      accessibilityState={{ busy: true }}
      accessible
      style={styles.list}
    >
      <Animated.View
        importantForAccessibility="no-hide-descendants"
        style={{ opacity }}
      >
        {Array.from({ length: numberOfSkeletons }, (_, index) => {
          const row = rows[index % rows.length];
          return (
            <View
              key={index}
              style={[
                styles.row,
                { borderBottomColor: semantics.borderCoreSubtle },
              ]}
            >
              <View style={[styles.avatar, shape]} />
              <View style={styles.text}>
                <View style={[styles.line, shape, { width: row.title }]} />
                <View style={[styles.line, shape, { width: row.preview }]} />
              </View>
            </View>
          );
        })}
      </Animated.View>
    </View>
  );
};

const styles = StyleSheet.create({
  avatar: { borderRadius: 24, height: 48, width: 48 },
  line: { borderRadius: 8, height: 12 },
  list: { flex: 1 },
  row: {
    alignItems: "center",
    borderBottomWidth: 1,
    flexDirection: "row",
    gap: 16,
    padding: 16,
  },
  text: { flex: 1, gap: 8 },
});
```

<table>
  <tr>
  <td align='center' width='50%'>

![Placeholder rows with an avatar circle and two bars of different widths while the channel list loads on iOS](https://getstream.io/docs-assets/images/7f451ddadd03.png)

  </td>
  <td align='center' width='50%'>

![Placeholder rows with an avatar circle and two bars of different widths while the channel list loads on Android](https://getstream.io/docs-assets/images/24dc11b9cdb5.png)

  </td>
  </tr>
</table>

The row is 81 points high, the height of the SDK's own placeholder row: a 48-point avatar, 16 points of padding above and below, and the border. The placeholder doesn't take its colors from the default row's `channelListSkeleton` theme, so setting that theme changes only the SDK's own placeholder.

## Show an error with a Retry

When the first page fails, the paginator retries it, three times by default, and the list keeps showing the placeholder meanwhile. Once the last attempt fails, the paginator records the error as `lastQueryError`, and the list renders `LoadingErrorIndicator` with `listType="channel"` in place of its rows. Its `retry` prop calls `ChannelPaginator.reload()`, which empties the list and loads the first page again, so the placeholder comes back while the query runs and the error returns only if it fails again. The button needs no busy state of its own.

The component announces its message assertively when it appears, as a failure should be. The error has a `message` that is for developers, so it stays out of the view:

```tsx title="src/ChannelListLoadError.tsx"
import { Pressable, StyleSheet, Text, View } from "react-native";
import {
  useAnnounceOnShow,
  useComponentsContext,
  useTheme,
} from "stream-chat-react-native";
import type { LoadingErrorProps } from "stream-chat-react-native";

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

/**
 * Shown in place of the channel list when its first page failed to load. `retry` loads the first
 * page again.
 */
export const ChannelListLoadError = ({ retry }: LoadingErrorProps) => {
  const { icons } = useComponentsContext();
  const {
    theme: { semantics },
  } = useTheme();

  useAnnounceOnShow(true, title, { priority: "assertive" });

  return (
    <View accessibilityLiveRegion="assertive" style={styles.container}>
      <icons.ExclamationCircle
        height={32}
        stroke={semantics.accentError}
        width={32}
      />
      <Text style={[styles.title, { color: semantics.textPrimary }]}>
        {title}
      </Text>
      <Text style={{ color: semantics.textSecondary }}>
        Something went wrong. Try again.
      </Text>
      <Pressable
        accessibilityRole="button"
        onPress={retry}
        style={[styles.button, { backgroundColor: semantics.accentPrimary }]}
      >
        <Text style={{ color: semantics.textOnAccent }}>Retry</Text>
      </Pressable>
    </View>
  );
};

const styles = StyleSheet.create({
  button: {
    borderRadius: 24,
    marginTop: 8,
    paddingHorizontal: 24,
    paddingVertical: 12,
  },
  container: {
    alignItems: "center",
    flex: 1,
    gap: 8,
    justifyContent: "center",
    padding: 24,
  },
  title: { fontSize: 16, fontWeight: "600" },
});
```

<table>
  <tr>
  <td align='center' width='50%'>

![An error message reading Couldn't load your conversations with a Retry button on iOS](https://getstream.io/docs-assets/images/22400ca22158.png)

  </td>
  <td align='center' width='50%'>

![An error message reading Couldn't load your conversations with a Retry button on Android](https://getstream.io/docs-assets/images/eb029ce339a5.png)

  </td>
  </tr>
</table>

## Show an empty list with a call to action

`ChannelList` renders `EmptyStateIndicator` with `listType="channel"` when the first page came back with no channels. This one invites the user to start a conversation, and its button opens the `NewConversation` screen from [Starting a conversation](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/channel-list/starting-a-conversation/#add-the-button-to-the-channel-list):

```tsx title="src/EmptyChannelList.tsx"
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { ParamListBase } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useComponentsContext, useTheme } from "stream-chat-react-native";

export const EmptyChannelList = () => {
  const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>();
  const { icons } = useComponentsContext();
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <View style={styles.container}>
      <icons.MessageBubbleEmpty
        height={32}
        stroke={semantics.textTertiary}
        width={32}
      />
      <Text style={[styles.title, { color: semantics.textPrimary }]}>
        No conversations yet
      </Text>
      <Text style={{ color: semantics.textSecondary }}>
        Start one with a teammate or a friend.
      </Text>
      <Pressable
        accessibilityRole="button"
        onPress={() => navigation.navigate("NewConversation")}
        style={[styles.button, { backgroundColor: semantics.accentPrimary }]}
      >
        <Text style={{ color: semantics.textOnAccent }}>New conversation</Text>
      </Pressable>
    </View>
  );
};

const styles = StyleSheet.create({
  button: {
    borderRadius: 24,
    marginTop: 8,
    paddingHorizontal: 24,
    paddingVertical: 12,
  },
  container: {
    alignItems: "center",
    flex: 1,
    gap: 8,
    justifyContent: "center",
    padding: 24,
  },
  title: { fontSize: 16, fontWeight: "600" },
});
```

<table>
  <tr>
  <td align='center' width='50%'>

![An empty channel list reading No conversations yet with a New conversation button on iOS](https://getstream.io/docs-assets/images/0c3245834734.png)

  </td>
  <td align='center' width='50%'>

![An empty channel list reading No conversations yet with a New conversation button on Android](https://getstream.io/docs-assets/images/a128db9c4866.png)

  </td>
  </tr>
</table>

## Register the states

`ChannelListLoadingIndicator`, `LoadingErrorIndicator` and `EmptyStateIndicator` are keys of `WithComponents`. `LoadingErrorIndicator` and `EmptyStateIndicator` are not the channel list's alone: `Channel`, `MessageList` and `Thread` read them with their own `listType`. So the wrapper goes around the channel list only, and the replacements don't check `listType`:

```tsx title="src/ChannelListWithStates.tsx"
import { ChannelList, WithComponents } from "stream-chat-react-native";
import type {
  ChannelListProps,
  ComponentOverrides,
} from "stream-chat-react-native";
import { ChannelListLoadError } from "./ChannelListLoadError";
import { ChannelListSkeleton } from "./ChannelListSkeleton";
import { EmptyChannelList } from "./EmptyChannelList";

// Declared once: WithComponents reads its overrides when it mounts.
const overrides: ComponentOverrides = {
  ChannelListLoadingIndicator: ChannelListSkeleton,
  EmptyStateIndicator: EmptyChannelList,
  LoadingErrorIndicator: ChannelListLoadError,
};

/** A `ChannelList` with the loading, error and empty states of this UI cookbook. */
export const ChannelListWithStates = (props: ChannelListProps) => (
  <WithComponents overrides={overrides}>
    <ChannelList {...props} />
  </WithComponents>
);
```

Render `ChannelListWithStates` in place of `ChannelList` in the channel list screen. The message lists and the thread list, which sit outside it, keep the SDK's states.

>
> **Info:** **Example**
>
> The user opens the app while the server is having trouble, so the query for the channels fails although the client is connected:
>
> 1. `ChannelList` loads the first page. Until it arrives, the paginator's `items` is `undefined`, so the list renders `ChannelListSkeleton`. If the first page takes a moment, a screen reader hears "Loading conversations".
> 2. The request fails. The paginator retries it, three times by default, and the placeholder stays on screen.
> 3. The last retry fails. The paginator records the error as `lastQueryError`, `items` is still `undefined`, and the list renders `ChannelListLoadError` in place of the rows. A screen reader hears "Couldn't load your conversations".
> 4. A moment later the user presses Retry. `retry` calls `ChannelPaginator.reload()`, which empties the list, so the placeholder shows again while the query runs.
> 5. This time the first page arrives with no channels: the user isn't a member of any channel yet. `items` is an empty array, so the list renders `EmptyChannelList`.
> 6. The user presses New conversation, picks Dan, and sends a first message. The new channel matches the list's filters, so it shows up in the list, which renders its rows instead of the empty state.
>

A failed query that happens while channels are on screen doesn't use these states: the list keeps its rows and shows a banner over the top, which you replace with `ChannelListHeaderErrorIndicator`. A lost connection shows `ChannelListHeaderNetworkDownIndicator` the same way. [The channel list](https://getstream.io/chat/docs/sdk/react-native/ui/components/list-states-ui/#the-channel-list) lists all of the states.

## Read more

- [List states UI](https://getstream.io/chat/docs/sdk/react-native/ui/components/list-states-ui/) lists the loading, empty and error states of every list and the keys that replace them.
- [Overriding components](https://getstream.io/chat/docs/sdk/react-native/ui/components/overriding-components/) explains `WithComponents`.
- [Accessibility](https://getstream.io/chat/docs/sdk/react-native/features/app-experience/accessibility/#announcing-a-surface-when-it-shows) explains `useAnnounceOnShow()` and `useReducedMotionPreference()`.
- [Channel list paginators](https://getstream.io/chat/docs/sdk/react-native/state/loading-data/channel-list-paginators/) explains how the paginator loads its first page and retries.
- [Starting a conversation](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/channel-list/starting-a-conversation/) builds the screen the empty list opens.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/channel-list/empty-and-loading-states/](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/channel-list/empty-and-loading-states/).