Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

Custom navigation

By default, ChannelDetails opens its sub-screens on top of itself as React Native modals: the edit form, the add-members form, the full member list, and the pinned messages, photos and videos, and files lists. This UI cookbook moves each of them into a screen of your own navigator, so they get your navigator's headers, transitions and back gestures. The examples use React Navigation's native stack, and the same overrides work with Expo Router. What channel details holds and what each part does is explained in Channel details and members.

Best practices

  • Wrap every screen in ChannelDetailsContextProvider with the channel. The header, sections, forms and lists all read the channel from it.
  • Pass the channel's cid in the route params, and look the channel up with client.channelManager.get(cid), as the other screens of the app do. See Passing channels and threads between screens.
  • Route each modal with the callback the SDK gives for it: onPress on ChannelDetailsEditButton and ChannelAddMembersButton, onViewAllMembersPress on ChannelDetailsMemberSection, and onPress on the rows that getNavigationItems returns.
  • A callback you leave unset keeps its modal, so you can move one screen at a time.
  • Declare the override components at module scope, and read the navigation and the channel inside them through hooks. WithComponents reads its overrides once.
  • Reuse the exported forms and lists in your screens: ChannelEditDetailsForm, ChannelAddMembersForm, ChannelMemberList, PinnedMessageList, MediaList and FileAttachmentList.

The screens

Each sub-screen gets a route with the channel's cid:

src/navigation.ts
import { useNavigation } from "@react-navigation/native";
import {
  createNativeStackNavigator,
  type NativeStackNavigationProp,
} from "@react-navigation/native-stack";

export type RootStackParamList = {
  ChannelDetails: { cid: string };
  ChannelEdit: { cid: string };
  ChannelAddMembers: { cid: string };
  ChannelMembers: { cid: string };
  ChannelPinnedMessages: { cid: string };
  ChannelMedia: { cid: string };
  ChannelFiles: { cid: string };
  // ...the app's channel list, channel and thread screens
};

export const Stack = createNativeStackNavigator<RootStackParamList>();

export const useAppNavigation = () =>
  useNavigation<NativeStackNavigationProp<RootStackParamList>>();

Route the modals to the screens

ChannelDetails renders its parts from override keys, and renders each part without the callbacks. So each override renders the SDK's part with the callback added:

src/ChannelDetailsScreen.tsx
import { useCallback } from "react";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
  ChannelAddMembersButton,
  ChannelDetails,
  ChannelDetailsContextProvider,
  ChannelDetailsEditButton,
  ChannelDetailsMemberSection,
  ChannelDetailsNavigationSection,
  useChannelDetailsContext,
  useChatContext,
  WithComponents,
} from "stream-chat-react-native";
import type {
  ChannelAddMembersButtonProps,
  ChannelDetailsEditButtonProps,
  ChannelDetailsMemberSectionProps,
  ChannelDetailsNavigationSectionProps,
  ComponentOverrides,
  GetChannelDetailsNavigationItems,
} from "stream-chat-react-native";
import { type RootStackParamList, useAppNavigation } from "./navigation";

const EditButton = (props: ChannelDetailsEditButtonProps = {}) => {
  const navigation = useAppNavigation();
  const { channel } = useChannelDetailsContext();

  return (
    <ChannelDetailsEditButton
      {...props}
      onPress={() => navigation.navigate("ChannelEdit", { cid: channel.cid })}
    />
  );
};

const AddMembersButton = (props: ChannelAddMembersButtonProps) => {
  const navigation = useAppNavigation();
  const { channel } = useChannelDetailsContext();

  return (
    <ChannelAddMembersButton
      {...props}
      onPress={() =>
        navigation.navigate("ChannelAddMembers", { cid: channel.cid })
      }
    />
  );
};

const MemberSection = (props: ChannelDetailsMemberSectionProps) => {
  const navigation = useAppNavigation();
  const { channel } = useChannelDetailsContext();

  return (
    <ChannelDetailsMemberSection
      {...props}
      onViewAllMembersPress={() =>
        navigation.navigate("ChannelMembers", { cid: channel.cid })
      }
    />
  );
};

const NavigationSection = (props: ChannelDetailsNavigationSectionProps) => {
  const navigation = useAppNavigation();
  const { channel } = useChannelDetailsContext();

  const getNavigationItems = useCallback<GetChannelDetailsNavigationItems>(
    ({ defaultItems }) =>
      defaultItems.map((item) => {
        const params = { cid: channel.cid };
        switch (item.section) {
          case "pinned-messages":
            return {
              ...item,
              onPress: () =>
                navigation.navigate("ChannelPinnedMessages", params),
            };
          case "photos-and-videos":
            return {
              ...item,
              onPress: () => navigation.navigate("ChannelMedia", params),
            };
          case "files":
            return {
              ...item,
              onPress: () => navigation.navigate("ChannelFiles", params),
            };
          default:
            return item;
        }
      }),
    [channel.cid, navigation],
  );

  return (
    <ChannelDetailsNavigationSection
      {...props}
      getNavigationItems={getNavigationItems}
    />
  );
};

const overrides: ComponentOverrides = {
  ChannelAddMembersButton: AddMembersButton,
  ChannelDetailsEditButton: EditButton,
  ChannelDetailsMemberSection: MemberSection,
  ChannelDetailsNavigationSection: NavigationSection,
};

export const ChannelDetailsScreen = ({
  navigation,
  route,
}: NativeStackScreenProps<RootStackParamList, "ChannelDetails">) => {
  const { client } = useChatContext();
  const channel = client.channelManager.get(route.params.cid);

  if (!channel) return null;

  return (
    <ChannelDetailsContextProvider channel={channel}>
      <WithComponents overrides={overrides}>
        <ChannelDetails onBack={() => navigation.goBack()} />
      </WithComponents>
    </ChannelDetailsContextProvider>
  );
};

Here is what each override changes:

  • ChannelDetailsEditButton opens the edit form. With onPress, it calls your function and doesn't render its modal. It still shows only in group channels, and only to users with the update-channel capability.
  • ChannelAddMembersButton opens the add-members form. With onPress, it calls your function and doesn't render its modal. It shows only to users with the update-channel-members capability.
  • ChannelDetailsMemberSection shows the first five members, and "View all" when the channel has more. With onViewAllMembersPress, "View all" calls your function, and the section doesn't render the member list modal.
  • ChannelDetailsNavigationSection shows the Pinned Messages, Photos & Videos and Files rows. A row with an onPress calls it instead of opening its modal. The section of each default row is pinned-messages, photos-and-videos or files.

ChannelDetails renders the edit button without props, so its override takes them as optional, with = {}. Each override reads the navigation and the channel through hooks, so the overrides object is declared once and never changes. Tapping a member still opens the member sheet. Custom member actions routes a member to a screen.

The destination screens

Each screen wraps one of the exported forms or lists in ChannelDetailsContextProvider:

src/ChannelDetailsSubScreens.tsx
import type { PropsWithChildren } from "react";
import { StyleSheet } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
  ChannelAddMembersForm,
  ChannelDetailsContextProvider,
  ChannelEditDetailsForm,
  ChannelMemberList,
  FileAttachmentList,
  MediaList,
  PinnedMessageList,
  useChatContext,
} from "stream-chat-react-native";
import type { RootStackParamList } from "./navigation";

type ScreenProps<Name extends keyof RootStackParamList> =
  NativeStackScreenProps<RootStackParamList, Name>;

const WithChannel = ({ children, cid }: PropsWithChildren<{ cid: string }>) => {
  const { client } = useChatContext();
  const channel = client.channelManager.get(cid);

  if (!channel) return null;

  return (
    <ChannelDetailsContextProvider channel={channel}>
      {children}
    </ChannelDetailsContextProvider>
  );
};

export const ChannelEditScreen = ({
  navigation,
  route,
}: ScreenProps<"ChannelEdit">) => (
  <SafeAreaView edges={["top", "bottom"]} style={styles.screen}>
    <WithChannel cid={route.params.cid}>
      <ChannelEditDetailsForm onClose={() => navigation.goBack()} />
    </WithChannel>
  </SafeAreaView>
);

export const ChannelAddMembersScreen = ({
  navigation,
  route,
}: ScreenProps<"ChannelAddMembers">) => (
  <SafeAreaView edges={["top", "bottom"]} style={styles.screen}>
    <WithChannel cid={route.params.cid}>
      <ChannelAddMembersForm onClose={() => navigation.goBack()} />
    </WithChannel>
  </SafeAreaView>
);

export const ChannelMembersScreen = ({
  route,
}: ScreenProps<"ChannelMembers">) => (
  <WithChannel cid={route.params.cid}>
    <ChannelMemberList />
  </WithChannel>
);

export const ChannelPinnedMessagesScreen = ({
  route,
}: ScreenProps<"ChannelPinnedMessages">) => (
  <WithChannel cid={route.params.cid}>
    <PinnedMessageList />
  </WithChannel>
);

export const ChannelMediaScreen = ({ route }: ScreenProps<"ChannelMedia">) => (
  <WithChannel cid={route.params.cid}>
    <MediaList />
  </WithChannel>
);

export const ChannelFilesScreen = ({ route }: ScreenProps<"ChannelFiles">) => (
  <WithChannel cid={route.params.cid}>
    <FileAttachmentList />
  </WithChannel>
);

const styles = StyleSheet.create({
  screen: { flex: 1 },
});

Register them in the stack. ChannelDetails and the two forms draw their own headers, so their screens hide the stack's header, and the lists use the stack's header for their title:

src/ChannelDetailsStack.tsx
import { ChannelDetailsScreen } from "./ChannelDetailsScreen";
import {
  ChannelAddMembersScreen,
  ChannelEditScreen,
  ChannelFilesScreen,
  ChannelMediaScreen,
  ChannelMembersScreen,
  ChannelPinnedMessagesScreen,
} from "./ChannelDetailsSubScreens";
import { Stack } from "./navigation";

export const RootStack = () => (
  <Stack.Navigator>
    {/* ...the app's channel list, channel and thread screens */}
    <Stack.Group screenOptions={{ headerShown: false }}>
      <Stack.Screen component={ChannelDetailsScreen} name="ChannelDetails" />
      <Stack.Screen component={ChannelEditScreen} name="ChannelEdit" />
      <Stack.Screen
        component={ChannelAddMembersScreen}
        name="ChannelAddMembers"
      />
    </Stack.Group>
    <Stack.Screen
      component={ChannelMembersScreen}
      name="ChannelMembers"
      options={{ title: "Members" }}
    />
    <Stack.Screen
      component={ChannelPinnedMessagesScreen}
      name="ChannelPinnedMessages"
      options={{ title: "Pinned Messages" }}
    />
    <Stack.Screen
      component={ChannelMediaScreen}
      name="ChannelMedia"
      options={{ title: "Photos & Videos" }}
    />
    <Stack.Screen
      component={ChannelFilesScreen}
      name="ChannelFiles"
      options={{ title: "Files" }}
    />
  </Stack.Navigator>
);

What the screens do:

  • The forms. ChannelEditDetailsForm and ChannelAddMembersForm render their own header, with a close button and a confirm button. Both call onClose when the user closes the form and after a successful save, so navigation.goBack() covers both. A failed save keeps the form open and shows an error notification in the form. The header has no top inset of its own, so the screens add the safe area with SafeAreaView.
  • The member list. ChannelMemberList searches the channel's members by name and opens the member sheet when a member is tapped.
  • The media list. MediaList opens a photo or video in the image gallery of the OverlayProvider at the root of the app, so it needs no provider of its own.
  • Pinned messages and files. PinnedMessageList and FileAttachmentList load their items when they mount, and read the channel from the provider.

The pinned messages, media and files lists load their first page when they open and don't refresh while they're open, so a message pinned meanwhile shows the next time the screen opens. The member list searches again when the channel's member count changes.

A header of your own

ChannelDetailsNavHeader is the header of ChannelDetails. It receives onBack, the function ChannelDetails got, and action, the edit button, which is undefined in a direct message channel. A header of your own renders them where it wants. The default header pads its top by the safe area inset, so this one does too:

src/DetailsHeader.tsx
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTheme } from "stream-chat-react-native";
import type {
  ChannelDetailsNavHeaderProps,
  ComponentOverrides,
} from "stream-chat-react-native";

const DetailsHeader = ({ action, onBack }: ChannelDetailsNavHeaderProps) => {
  const { top } = useSafeAreaInsets();
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <View
      style={[
        styles.header,
        {
          backgroundColor: semantics.backgroundCoreElevation1,
          paddingTop: top + 8,
        },
      ]}
    >
      {onBack ? (
        <Pressable accessibilityRole="button" hitSlop={8} onPress={onBack}>
          <Text style={{ color: semantics.accentPrimary }}>Back</Text>
        </Pressable>
      ) : null}
      <Text
        accessibilityRole="header"
        style={[styles.title, { color: semantics.textPrimary }]}
      >
        Details
      </Text>
      {action}
    </View>
  );
};

export const headerOverrides: ComponentOverrides = {
  ChannelDetailsNavHeader: DetailsHeader,
};

const styles = StyleSheet.create({
  header: {
    alignItems: "center",
    flexDirection: "row",
    gap: 12,
    paddingBottom: 8,
    paddingHorizontal: 16,
  },
  title: { flex: 1, fontSize: 17, fontWeight: "600" },
});

Add ChannelDetailsNavHeader: DetailsHeader to the overrides of ChannelDetailsScreen. The action it receives renders the ChannelDetailsEditButton override, so the edit button still navigates to ChannelEdit.

The edit form's header

ChannelEditDetailsForm renders the ChannelEditDetailsFormHeader key above its fields, with the form's onClose. A header of your own saves with submit() from useChannelEditDetailsContext(), and reads whether there's anything to save with useAreChannelDetailsEdited(store). This one has text buttons on both sides, and keeps Save disabled until the name or the image changes:

src/EditFormHeader.tsx
import { useState } from "react";
import {
  ActivityIndicator,
  Keyboard,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import {
  useAreChannelDetailsEdited,
  useChannelEditDetailsContext,
  useTheme,
} from "stream-chat-react-native";
import type {
  ChannelEditDetailsFormHeaderProps,
  ComponentOverrides,
} from "stream-chat-react-native";

const EditFormHeader = ({ onClose }: ChannelEditDetailsFormHeaderProps) => {
  const { store, submit } = useChannelEditDetailsContext();
  const edited = useAreChannelDetailsEdited(store);
  const [saving, setSaving] = useState(false);
  const {
    theme: { semantics },
  } = useTheme();
  const canSave = edited && !saving;

  const save = async () => {
    if (!canSave) return;
    Keyboard.dismiss();
    setSaving(true);
    try {
      await submit();
      onClose();
    } catch {
      // The form shows an error notification, and stays open.
    } finally {
      setSaving(false);
    }
  };

  return (
    <View style={styles.header}>
      <Pressable accessibilityRole="button" hitSlop={8} onPress={onClose}>
        <Text style={{ color: semantics.accentPrimary }}>Cancel</Text>
      </Pressable>
      <Text
        accessibilityRole="header"
        style={[styles.title, { color: semantics.textPrimary }]}
      >
        Edit channel
      </Text>
      {saving ? (
        <ActivityIndicator />
      ) : (
        <Pressable
          accessibilityRole="button"
          accessibilityState={{ disabled: !canSave }}
          disabled={!canSave}
          hitSlop={8}
          onPress={save}
        >
          <Text
            style={{
              color: canSave ? semantics.accentPrimary : semantics.textDisabled,
            }}
          >
            Save
          </Text>
        </Pressable>
      )}
    </View>
  );
};

export const editHeaderOverrides: ComponentOverrides = {
  ChannelEditDetailsFormHeader: EditFormHeader,
};

const styles = StyleSheet.create({
  header: {
    alignItems: "center",
    flexDirection: "row",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 12,
  },
  title: { flex: 1, fontSize: 17, fontWeight: "600", textAlign: "center" },
});
  • submit() saves the name and the image, whichever changed, and rejects when either request fails. The channel actions behind it publish a notification for each result, so on a failure the header only keeps the form open, and the form's NotificationList shows the error.
  • useAreChannelDetailsEdited re-renders the header only when the answer changes, not on every letter typed.
  • onClose is navigation.goBack() on ChannelEditScreen, so Cancel and a successful save both leave the screen.

Wrap ChannelEditDetailsForm in ChannelEditScreen with <WithComponents overrides={editHeaderOverrides}>, or add the key to your app's overrides.