# Custom navigation

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

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](https://reactnavigation.org/)'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](https://getstream.io/chat/docs/sdk/react-native/v10/features/channels/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](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/navigation/#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`:

```tsx title="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:

```tsx title="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](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/channel-details/custom-member-actions/) routes a member to a screen.

## The destination screens

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

```tsx title="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:

```tsx 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:

```tsx title="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:

```tsx title="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.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/channel-details/custom-navigation/](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/channel-details/custom-navigation/).