# Thread header

> **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/).

This UI cookbook builds a thread header of its own: a back button, the avatars of the people in the thread, the title, and a subtitle with the channel's name and the reply count, or who is typing a reply. The SDK has no thread header component; [Thread UI](https://getstream.io/chat/docs/sdk/react-native/v10/ui/components/thread-ui/#a-thread-header) explains the options. The header follows the same pattern as the [Channel header](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/headers/channel-header/) UI cookbook.

## Best practices

- Render the header as the first child of `Thread`, and hide the navigator's header for the thread screen. The header then reads the thread with `useThreadContext()`, and `Thread` starts at the top of the screen, so pass `keyboardVerticalOffset={0}`.
- Add the top safe area inset to the header's padding, from `useSafeAreaInsets()`, so the status bar and the notch don't cover it.
- Wrap the header in `PortalWhileClosingView`, so a reply animating back from the message menu passes under it, not over it.
- Read the participants and the reply count from `thread.state` with `useStateStore`, and declare the selector at module scope.
- Read typing with `useTypingString()`. Inside `Thread`, it counts only the people typing a reply in that thread.

## Implementation

```tsx title="src/ThreadHeader.tsx"
import { useMemo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { ThreadState, UserResponse } from "stream-chat";
import {
  ChevronLeft,
  PortalWhileClosingView,
  useChannel,
  useChannelPreviewDisplayName,
  UserAvatarStack,
  useStateStore,
  useTheme,
  useThreadContext,
  useTypingString,
} from "stream-chat-react-native";

const threadStateSelector = ({ participants, replyCount }: ThreadState) => ({
  participants,
  replyCount,
});

type ThreadHeaderProps = {
  onBack: () => void;
};

export const ThreadHeader = ({ onBack }: ThreadHeaderProps) => {
  const { thread } = useThreadContext();
  const channel = useChannel();
  const { top } = useSafeAreaInsets();
  const {
    theme: { semantics },
  } = useTheme();
  const { participants, replyCount = 0 } =
    useStateStore(thread?.state, threadStateSelector) ?? {};
  const channelName = useChannelPreviewDisplayName(channel);
  const typing = useTypingString();

  // A new array only when the participants change, so the avatars don't re-render on typing.
  const users = useMemo(
    () =>
      (participants ?? []).flatMap(({ user }): UserResponse[] =>
        user ? [user] : [],
      ),
    [participants],
  );

  const replies = replyCount === 1 ? "1 reply" : `${replyCount} replies`;
  const subtitle = typing || `${channelName}, ${replies}`;

  return (
    <PortalWhileClosingView
      portalHostName="thread-header"
      portalName="thread-header"
    >
      <View
        style={[
          styles.container,
          {
            backgroundColor: semantics.backgroundCoreElevation1,
            borderBottomColor: semantics.borderCoreSubtle,
            paddingTop: top + 8,
          },
        ]}
      >
        <Pressable
          accessibilityLabel="Back"
          accessibilityRole="button"
          hitSlop={8}
          onPress={onBack}
          style={styles.back}
        >
          <ChevronLeft height={24} stroke={semantics.textPrimary} width={24} />
        </Pressable>
        {users.length > 0 ? (
          <UserAvatarStack
            avatarSize="sm"
            maxVisible={3}
            overlap={0.3}
            users={users}
          />
        ) : null}
        <View style={styles.title}>
          <Text
            numberOfLines={1}
            style={[styles.name, { color: semantics.textPrimary }]}
          >
            Thread
          </Text>
          <Text
            numberOfLines={1}
            style={[styles.subtitle, { color: semantics.textSecondary }]}
          >
            {subtitle}
          </Text>
        </View>
      </View>
    </PortalWhileClosingView>
  );
};

const styles = StyleSheet.create({
  back: {
    alignItems: "center",
    height: 40,
    justifyContent: "center",
    width: 40,
  },
  container: {
    alignItems: "center",
    borderBottomWidth: 1,
    flexDirection: "row",
    gap: 8,
    paddingBottom: 8,
    paddingHorizontal: 8,
  },
  name: { fontSize: 16, fontWeight: "600" },
  subtitle: { fontSize: 13 },
  title: { flex: 1 },
});
```

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

![A thread header with participant avatars, the title Thread and the reply count on iOS](https://getstream.io/docs-assets/images/bd83eb218083.png)

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

![A thread header with participant avatars, the title Thread and the reply count on Android](https://getstream.io/docs-assets/images/fb5c871424f9.png)

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

Here is what each part reads:

- `useThreadContext()` returns the thread that `Thread` renders. `thread.state` holds the participants, each with its `user`, and `replyCount`, the number of replies, the deleted ones included. The thread takes both from its parent message each time the client updates that message, and the header re-renders with them. [What channels and threads hold](https://getstream.io/chat/docs/sdk/react-native/v10/state/channels-and-threads/what-channels-and-threads-hold/#parent-message-and-replies) lists the rest of `thread.state`.
- `useChannel()` returns the thread's channel inside `Thread`, and `useChannelPreviewDisplayName(channel)` gives the name the channel list shows: the channel's name, or its other members' names when it has none.
- `UserAvatarStack` overlaps the first three avatars and shows a count for the others.
- `useTypingString()` returns "Cleo is typing", or "Cleo and 2 more are typing", and an empty string while nobody is. It reads `channel.state.typing`, which holds the latest typing event of each user in the channel. An event sent from a thread carries the thread's id as `parent_id`, and inside `Thread` the hook keeps only the events of this thread and leaves out the current user. To list the typing users yourself, `useChannelTypingUsers(channel, thread.id)` returns them, and it works outside `Thread` too.

The title, the reply count and the fallback subtitle are plain English here. `useTypingString()` is translated already. To translate the rest, use `t` from `useTranslationContext()`, as [Localization](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/localization/#using-t-in-your-own-components) explains.

## Final code

The thread screen hides the navigator's header and renders `ThreadHeader` above the replies:

```tsx title="src/ThreadScreen.tsx"
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
  MessageComposer,
  MessageList,
  Thread,
  useChatContext,
} from "stream-chat-react-native";
import { ThreadHeader } from "./ThreadHeader";

type StackParamList = {
  Channel: { cid: string };
  Thread: { threadId: string };
};

export const ThreadScreen = ({
  navigation,
  route,
}: NativeStackScreenProps<StackParamList, "Thread">) => {
  const { client } = useChatContext();
  const thread = client.threads.get(route.params.threadId);

  if (!thread) return null;

  return (
    <Thread keyboardVerticalOffset={0} thread={thread}>
      <ThreadHeader onBack={() => navigation.goBack()} />
      <MessageList />
      <MessageComposer />
    </Thread>
  );
};
```

Register the screen with `options={{ headerShown: false }}`. The header is part of the screen's views, so `Thread` gets a `keyboardVerticalOffset` of `0` and no `topInset`; see [Keyboard and insets](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/keyboard-and-insets/). `client.threads.get()` returns the thread that `onThreadSelect` passed to the channel screen, as [Navigation](https://getstream.io/chat/docs/sdk/react-native/v10/ui/layout/navigation/#ids-in-the-route-params) explains.

When the first page of replies fails to load, `Thread` renders `LoadingErrorIndicator` in place of its children, so the header doesn't show. The user leaves the screen with the system back gesture or the Android back button, or taps the indicator to load the replies again.

## Read more

- [Thread UI](https://getstream.io/chat/docs/sdk/react-native/v10/ui/components/thread-ui/): what `Thread` renders and the parts you can replace.
- [Channel header](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/headers/channel-header/): the same pattern for a channel, with the connection status and an unread badge.
- [Overlays and bottom sheets](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/overlays-and-bottom-sheets/): why the header goes in a `PortalWhileClosingView`.

---

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