# Search screen

The SDK has no search screen, so this UI cookbook builds one. It searches channels, people and messages from a single input, with a filter and a sort for each kind of result, results grouped by kind or mixed in one list, and a tap that opens the channel, the message or the conversation the result points to. Search is a state-layer feature, which [Search sources](https://getstream.io/chat/docs/sdk/react-native/state/loading-data/search-sources/) explains, and [Search](https://getstream.io/chat/docs/sdk/react-native/features/channels/search/) lists where the SDK itself searches.

>
> **Note:** **What you'll build**
>
> - One input that searches channels, people and messages
> - A filter and a sort for each kind of result
> - Results grouped by kind or mixed in one list
> - Opening the picked channel, message or person
>

---

**Approach:** Compose and Change behavior in the state layer. [Choosing where to start](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/overview/#choosing-where-to-start) explains the approaches.

## Best practices

- Give the sources their filters and sorts when you create them, as filter generators that are on or off. Changing one at runtime is then a call on the source's filter builder.
- After changing a source's filters or sort, search again. A source applies the change from its next query, and doesn't query on its own.
- Create the search controller once for the screen. A controller created on every render starts every search from scratch.
- Empty the sources when the screen closes with `SearchController.exit()`, and release what they hold with `SearchController.dispose()`, so a closed screen holds no results and none of the channels they came from.
- Label the input, give every control a label, and render the results as buttons, so a screen reader announces each result by its text.
- Don't watch the channels you list. A channel result is a preview, and `Channel` watches the channel when the user opens it.

## Create the search

`createSearch()` creates the three sources and a `SearchController` that holds them. Each source starts with the filter generators and sort the screen offers, and loads at most five results per page:

```ts title="src/search.ts"
import {
  ChannelSearchSource,
  MessageSearchSource,
  SearchController,
  UserSearchSource,
} from "stream-chat";
import type { SortParamRequest, StreamChat } from "stream-chat";

/** The values the message filters read. */
export type MessageFilterContext = {
  /** The id of the author whose messages to find. */
  authorId?: string;
  /** The oldest creation time to find, as an ISO date. */
  sentSince?: string;
};

export const channelSorts = {
  activity: [{ direction: -1, field: "last_message_at" }],
  members: [{ direction: -1, field: "member_count" }],
  newest: [{ direction: -1, field: "created_at" }],
} satisfies Record<string, SortParamRequest[]>;

export const userSorts = {
  active: [{ direction: -1, field: "last_active" }],
  name: [{ direction: 1, field: "name" }],
} satisfies Record<string, SortParamRequest[]>;

export const messageSorts = {
  newest: [{ direction: -1, field: "created_at" }],
  oldest: [{ direction: 1, field: "created_at" }],
} satisfies Record<string, SortParamRequest[]>;

// A few results per source: Load more fetches the next few.
const pageSize = 5;

/** The three sources and the controller that passes the search text to the ones that are active. */
export const createSearch = (client: StreamChat) => {
  const channels = new ChannelSearchSource(
    client,
    { pageSize, sort: channelSorts.activity },
    {
      initialFilterConfig: {
        // Replaces the built-in `name` generator: a channel matches by its name or a member's.
        name: {
          enabled: true,
          generate: ({ searchQuery }) =>
            searchQuery
              ? {
                  $or: [
                    { name: { $autocomplete: searchQuery } },
                    { "member.user.name": { $autocomplete: searchQuery } },
                  ],
                }
              : null,
        },
        hideArchived: {
          enabled: true,
          generate: () => ({ archived: false }),
        },
        groupsOnly: {
          enabled: false,
          generate: () => ({ member_count: { $gt: 2 } }),
        },
      },
    },
  );

  const users = new UserSearchSource(client, {
    pageSize,
    sort: userSorts.name,
  });

  const messages = new MessageSearchSource<{
    messageSearchContext: MessageFilterContext;
  }>(
    client,
    { messageSearchSort: messageSorts.newest, pageSize },
    {
      messageSearch: {
        initialFilterConfig: {
          author: {
            enabled: true,
            generate: ({ authorId }) =>
              authorId ? { "user.id": authorId } : null,
          },
          sentSince: {
            enabled: true,
            generate: ({ sentSince }) =>
              sentSince ? { created_at: { $gte: sentSince } } : null,
          },
          withAttachments: {
            enabled: false,
            generate: () => ({ attachments: { $exists: true } }),
          },
        },
      },
    },
  );

  const controller = new SearchController({
    client,
    // Every source the user turns on searches at once.
    config: { keepSingleActiveSource: false },
    sources: [channels, users, messages],
  });

  return { channels, controller, messages, users };
};

export type Search = ReturnType<typeof createSearch>;

/**
 * Runs the current search text again in one source, from the first page. A new `sort` or filter
 * applies from the next query, and the source doesn't query on its own when they change.
 */
export const searchAgain = (
  { controller }: Pick<Search, "controller">,
  source: { search: (text?: string) => Promise<void> | undefined },
) => {
  if (controller.searchQuery) void source.search(controller.searchQuery);
};
```

- `config: { keepSingleActiveSource: false }` lets several sources be active at once. By default, `SearchController.activateSource()` turns the other sources off.
- Each entry of `initialFilterConfig` is a filter generator. `enabled` sets whether its part of the filter is sent, and `generate` returns that part from the search text and the filter builder's context. A generator named like a built-in one replaces it, so the channel source's `name` generator also finds channels by a member's name.
- The message source keeps its built-in `text` generator, which matches words that start with the search text, and adds the others. `author` and `sentSince` read `authorId` and `sentSince` from the context, which the filters change with `FilterBuilder.updateContext()`.
- The sort maps hold the orders the screen offers. The filters set one of them as the source's `sort`, or as `messageSearchSort` on the message source. Messages always sort by `created_at` first, so for messages only its direction changes.
- The user source has a sort and no filter. A `UserSearchSource` with generators of its own has only those, so a filter for it needs a generator for the search text too.
- `searchAgain()` runs the current text in one source with `BaseSearchSource.search()`. A text counts as a new search, so the source drops its results and loads the first page again with the new settings. With no text it would load the next page with them instead, after results the old settings found.

## Build the screen

The screen holds the input, a button for each source, the choice between grouped and mixed results, and the button that shows the filters. Each keystroke goes to `SearchController.search()`, which passes the text to the sources that are active. An empty text doesn't search, so the screen clears the results instead.

`open()` turns a result into where to go. A channel result opens its channel, a message result opens the channel at the message, and a person opens the direct message channel with them. The screen doesn't navigate itself. `onOpenChannel` does. `Button` fills the width of its parent, so the controls that share a row set `width: "auto"` in their `style`, and the source buttons also set `flex: 1` to share the row evenly.

```tsx title="src/SearchScreen.tsx"
import { useCallback, useEffect, useState } from "react";
import { Alert, StyleSheet, View } from "react-native";
import type { SearchControllerState, SearchSourceState } from "stream-chat";
import {
  Button,
  SearchInput,
  useChatContext,
  useStateStore,
} from "stream-chat-react-native";
import { SearchFilters } from "./SearchFilters";
import { SearchResults } from "./SearchResults";
import type { Result, ResultsLayout } from "./SearchResults";
import { createSearch, searchAgain } from "./search";
import type { Search } from "./search";

const isActiveSelector = ({ isActive }: SearchSourceState) => ({ isActive });

const activeCountSelector = ({ sources }: SearchControllerState) => ({
  activeCount: sources.filter((source) => source.isActive).length,
});

const sourceLabels = {
  channels: "Channels",
  messages: "Messages",
  users: "People",
} as const;

const SourceToggle = ({
  search,
  type,
}: {
  search: Search;
  type: keyof typeof sourceLabels;
}) => {
  const { controller } = search;
  const source = search[type];
  const { isActive } = useStateStore(source.state, isActiveSelector);
  // The controller publishes each time a source is turned on or off, for every button to read.
  const { activeCount } = useStateStore(controller.state, activeCountSelector);
  // The last active source can't be turned off.
  const isLastActive = isActive && activeCount === 1;

  return (
    <Button
      disabled={isLastActive}
      label={sourceLabels[type]}
      onPress={() => {
        if (isActive) {
          controller.deactivateSource(type);
          return;
        }
        controller.activateSource(type);
        // Activating a source doesn't search it, so run the current text in it.
        searchAgain(search, source);
      }}
      selected={isActive}
      size="sm"
      style={styles.toggle}
      type="outline"
      variant="secondary"
    />
  );
};

type SearchScreenProps = {
  /** Opens the channel screen, at the message when there is one. */
  onOpenChannel: (cid: string, messageId?: string) => void;
};

export const SearchScreen = ({ onOpenChannel }: SearchScreenProps) => {
  const { client } = useChatContext();
  const [search] = useState(() => createSearch(client));
  const [layout, setLayout] = useState<ResultsLayout>("grouped");
  const [filtersOpen, setFiltersOpen] = useState(false);
  const { controller } = search;

  useEffect(() => {
    // Takes back what an earlier dispose() released, as after a remount.
    controller.registerSubscriptions();
    // No source is active the first time, so every one is turned on.
    controller.sources.forEach((source) =>
      controller.activateSource(source.type),
    );
    return () => {
      // Empties the text and every source's results, and cancels the queries in flight.
      controller.exit();
      controller.dispose();
    };
  }, [controller]);

  const open = useCallback(
    async ({ item, type }: Result) => {
      if (type === "channels") return onOpenChannel(item.cid);
      if (type === "messages") return onOpenChannel(item.cid, item.id);
      try {
        // The direct message channel with the person. Watching it creates it when it doesn't
        // exist yet, and returns the channel the client keeps when it does.
        const channel = await client
          .channel("messaging", {
            members: [{ user_id: client.userId ?? "" }, { user_id: item.id }],
          })
          .watch();
        onOpenChannel(channel.cid);
      } catch {
        Alert.alert("Couldn't open the conversation");
      }
    },
    [client, onOpenChannel],
  );

  return (
    <View style={styles.screen}>
      <SearchInput
        accessibilityLabel="Search channels, people and messages"
        onChangeText={(text) => {
          // An empty text doesn't search, so clear the results instead.
          if (text) void controller.search(text);
          else controller.clear();
        }}
      />
      <View style={styles.controls}>
        {(["channels", "users", "messages"] as const).map((type) => (
          <SourceToggle key={type} search={search} type={type} />
        ))}
      </View>
      <View style={styles.controls}>
        <Button
          label={layout === "grouped" ? "Show mixed" : "Show grouped"}
          onPress={() => setLayout(layout === "grouped" ? "mixed" : "grouped")}
          size="sm"
          style={styles.fit}
          type="ghost"
          variant="primary"
        />
        <Button
          label={filtersOpen ? "Hide filters" : "Filters and sort"}
          onPress={() => setFiltersOpen(!filtersOpen)}
          size="sm"
          style={styles.fit}
          type="ghost"
          variant="primary"
        />
      </View>
      {filtersOpen ? <SearchFilters search={search} /> : null}
      <SearchResults layout={layout} onOpen={open} search={search} />
    </View>
  );
};

const styles = StyleSheet.create({
  controls: { flexDirection: "row", gap: 8, paddingHorizontal: 12 },
  fit: { width: "auto" },
  toggle: { flex: 1, width: "auto" },
  screen: { flex: 1 },
});
```

- No source is active when the screen mounts, so it turns them all on. They stay as the user left them while the screen is mounted.
- `SearchInput` is the SDK's search field, with the clear button the channel details lists use.
- The last active source can't be turned off, so its button is disabled.
- Activating a source doesn't search it, so `searchAgain()` runs the current text in it.
- `client.channel("messaging", { members })` with the two users is the direct message channel between them. `Channel.watch()` creates it when it doesn't exist yet, and loads it when it does. The cid it returns is the real one, so the screen navigates with it, as [Ids in the route params](https://getstream.io/chat/docs/sdk/react-native/ui/layout/navigation/#ids-in-the-route-params) describes.

## Show the results

Each source keeps its results in its `state` store. `SearchResults` subscribes to the three stores and shows the active sources' results in one of two layouts:

```tsx title="src/SearchResults.tsx"
import { useMemo } from "react";
import { Pressable, SectionList, StyleSheet, Text, View } from "react-native";
import type { SectionListData } from "react-native";
import { convertTimestampToDate } from "stream-chat";
import type {
  Channel,
  ChannelStateData,
  MessageResponse,
  SearchControllerState,
  SearchSource,
  SearchSourceState,
  UserResponse,
} from "stream-chat";
import {
  Button,
  ChannelAvatar,
  getDateString,
  useChannelPreviewDisplayName,
  useChatContext,
  useStateStore,
  useTheme,
  useTranslationContext,
  useUserActivityStatus,
  UserAvatar,
} from "stream-chat-react-native";
import type { Search } from "./search";

export type ResultsLayout = "grouped" | "mixed";

export type Result =
  | { item: Channel; type: "channels" }
  | { item: MessageResponse; type: "messages" }
  | { item: UserResponse; type: "users" };

const tagLabels = {
  channels: "Channel",
  messages: "Message",
  users: "Person",
} as const;

const sectionTitles = {
  channels: "Channels",
  messages: "Messages",
  users: "People",
} as const;

const resultKey = ({ item, type }: Result) =>
  `${type}:${type === "channels" ? item.cid : item.id}`;

// The time a mixed list sorts by: a channel's last message, a person's last activity, a message's
// creation. The SDK keeps timestamps as nanosecond numbers.
const resultTime = (result: Result) => {
  const timestamp =
    result.type === "channels"
      ? result.item.data?.last_message_at
      : result.type === "users"
        ? result.item.last_active
        : result.item.created_at;
  return convertTimestampToDate(timestamp)?.getTime() ?? 0;
};

const sourceSelector = ({
  hasNext,
  isActive,
  isLoading,
  items,
  searchQuery,
}: SearchSourceState) => ({ hasNext, isActive, isLoading, items, searchQuery });

/**
 * What one source has loaded, as results. A source waits for a pause in typing before it queries,
 * so until its own `searchQuery` is the controller's, it's still searching.
 */
const useSourceResults = (
  type: Result["type"],
  source: SearchSource,
  searchQuery: string,
) => {
  const state = useStateStore(source.state, sourceSelector);

  return {
    hasMore:
      !!state.items?.length &&
      state.hasNext &&
      !state.isLoading &&
      state.searchQuery === searchQuery,
    isActive: state.isActive,
    isSearching: state.isLoading || state.searchQuery !== searchQuery,
    loadMore: () => void source.search(),
    results: (state.items ?? []).map((item) => ({ item, type }) as Result),
    type,
  };
};

type SourceResults = ReturnType<typeof useSourceResults>;

// A grouped list has a section for each source. A mixed list has one section and no group.
type ResultsSection = { data: Result[]; group?: SourceResults };
type SectionInfo = { section: SectionListData<Result, ResultsSection> };

const controllerSelector = ({ searchQuery }: SearchControllerState) => ({
  searchQuery,
});

const memberCountSelector = ({ memberCount }: ChannelStateData) => ({
  memberCount,
});

const ChannelResult = ({ channel }: { channel: Channel }) => {
  const { memberCount } = useStateStore(channel.state, memberCountSelector);
  const name = useChannelPreviewDisplayName(channel);

  return (
    <>
      <ChannelAvatar channel={channel} size="lg" />
      <ResultText detail={`${memberCount} members`} title={name} />
    </>
  );
};

const UserResult = ({ user }: { user: UserResponse }) => {
  const status = useUserActivityStatus(user);

  return (
    <>
      <UserAvatar showOnlineIndicator={user.online} size="lg" user={user} />
      <ResultText detail={status} title={user.name ?? user.id} />
    </>
  );
};

const MessageResult = ({ message }: { message: MessageResponse }) => {
  const { client } = useChatContext();
  const { t, tDateTimeParser } = useTranslationContext();
  const channelName = useChannelPreviewDisplayName(
    client.channelManager.get(message.cid),
  );
  const date = useMemo(
    () =>
      getDateString({
        messageCreatedAt: convertTimestampToDate(message.created_at),
        t,
        tDateTimeParser,
        timestampTranslationKey: "timestamp.ChannelPreviewStatus",
      }),
    [message.created_at, t, tDateTimeParser],
  );
  const author = message.user?.name ?? message.user?.id ?? "";

  return (
    <>
      {message.user ? <UserAvatar size="lg" user={message.user} /> : null}
      <ResultText
        detail={`${author} in ${channelName} - ${date ?? ""}`}
        title={message.text ?? ""}
      />
    </>
  );
};

const ResultText = ({ detail, title }: { detail: string; title: string }) => {
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <View style={styles.resultText}>
      <Text
        numberOfLines={1}
        style={[styles.resultTitle, { color: semantics.textPrimary }]}
      >
        {title}
      </Text>
      <Text numberOfLines={1} style={{ color: semantics.textSecondary }}>
        {detail}
      </Text>
    </View>
  );
};

const ResultRow = ({
  onOpen,
  result,
  showTag,
}: {
  onOpen: (result: Result) => void;
  result: Result;
  showTag?: boolean;
}) => {
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <Pressable
      accessibilityRole="button"
      onPress={() => onOpen(result)}
      style={styles.result}
    >
      {result.type === "channels" ? (
        <ChannelResult channel={result.item} />
      ) : result.type === "users" ? (
        <UserResult user={result.item} />
      ) : (
        <MessageResult message={result.item} />
      )}
      {showTag ? (
        <Text style={{ color: semantics.textTertiary }}>
          {tagLabels[result.type]}
        </Text>
      ) : null}
    </Pressable>
  );
};

export const SearchResults = ({
  layout,
  onOpen,
  search,
}: {
  layout: ResultsLayout;
  onOpen: (result: Result) => void;
  search: Search;
}) => {
  const {
    theme: { semantics },
  } = useTheme();
  const { searchQuery } = useStateStore(
    search.controller.state,
    controllerSelector,
  );
  const channels = useSourceResults("channels", search.channels, searchQuery);
  const users = useSourceResults("users", search.users, searchQuery);
  const messages = useSourceResults("messages", search.messages, searchQuery);
  const groups = [channels, users, messages].filter(({ isActive }) => isActive);

  const isSearching = groups.some(({ isSearching }) => isSearching);
  const count = groups.reduce((sum, { results }) => sum + results.length, 0);
  const status = !searchQuery
    ? ""
    : isSearching
      ? "Searching..."
      : `${count} ${count === 1 ? "result" : "results"}`;

  // Nothing is listed until the user types.
  const sections: ResultsSection[] = !searchQuery
    ? []
    : layout === "grouped"
      ? groups.map((group) => ({ data: group.results, group }))
      : [
          {
            data: groups
              .flatMap(({ results }) => results)
              .sort((a, b) => resultTime(b) - resultTime(a)),
          },
        ];
  const withMore = groups.filter(({ hasMore }) => hasMore);

  return (
    <SectionList<Result, ResultsSection>
      ListFooterComponent={
        layout === "mixed" && withMore.length ? (
          <LoadMore
            onPress={() => withMore.forEach(({ loadMore }) => loadMore())}
          />
        ) : null
      }
      ListHeaderComponent={
        <Text
          accessibilityLiveRegion="polite"
          style={[styles.status, { color: semantics.textSecondary }]}
        >
          {status}
        </Text>
      }
      keyboardShouldPersistTaps="handled"
      keyExtractor={resultKey}
      renderItem={({ item }) => (
        <ResultRow onOpen={onOpen} result={item} showTag={layout === "mixed"} />
      )}
      renderSectionFooter={({ section }: SectionInfo) =>
        section.group?.hasMore ? (
          <LoadMore onPress={section.group.loadMore} />
        ) : null
      }
      renderSectionHeader={({ section }: SectionInfo) =>
        section.group ? <SectionHeader group={section.group} /> : null
      }
      sections={sections}
    />
  );
};

const SectionHeader = ({ group }: { group: SourceResults }) => {
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <View
      style={[
        styles.sectionHeader,
        { backgroundColor: semantics.backgroundCoreApp },
      ]}
    >
      <Text style={[styles.sectionTitle, { color: semantics.textPrimary }]}>
        {sectionTitles[group.type]}
      </Text>
      <Text style={{ color: semantics.textSecondary }}>
        {group.isSearching
          ? "Searching..."
          : group.results.length
            ? ""
            : "No results"}
      </Text>
    </View>
  );
};

const LoadMore = ({ onPress }: { onPress: () => void }) => (
  <View style={styles.loadMore}>
    <Button
      label="Load more"
      onPress={onPress}
      type="ghost"
      variant="primary"
    />
  </View>
);

const styles = StyleSheet.create({
  loadMore: { alignItems: "flex-start", paddingHorizontal: 8 },
  result: { alignItems: "center", flexDirection: "row", gap: 12, padding: 12 },
  resultText: { flex: 1 },
  resultTitle: { fontWeight: "600" },
  sectionHeader: {
    alignItems: "baseline",
    flexDirection: "row",
    gap: 8,
    paddingHorizontal: 12,
    paddingVertical: 8,
  },
  sectionTitle: { fontWeight: "700" },
  status: { paddingHorizontal: 12, paddingVertical: 8 },
});
```

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

![Search results for Cara grouped into Channels, People and Messages on iOS](https://getstream.io/docs-assets/images/e8c7bdb9c6b4.png)

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

![Search results for Cara grouped into Channels, People and Messages on Android](https://getstream.io/docs-assets/images/1d577ca0204d.png)

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

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

![Search results for Cara mixed in one list, each labeled Channel, Message or Person, on iOS](https://getstream.io/docs-assets/images/db1d8342a28c.png)

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

![Search results for Cara mixed in one list, each labeled Channel, Message or Person, on Android](https://getstream.io/docs-assets/images/4c657c6f5b7c.png)

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

**Grouped** shows a section for each active source, with a heading and the source's own Load more. `BaseSearchSource.search()` with no text loads that source's next page, while `hasNext` in the source's state says there is one.

A source waits for a pause in typing before it queries, so for a moment its results are still for the previous text. Until `searchQuery` in the source's state is the controller's `searchQuery`, the source counts as searching: its heading and the status line say "Searching..." instead of "No results" or a count, and Load more is hidden, so it never loads the next page of the previous text.

**Mixed** merges the results into one list, newest first, and tags each row with its kind. A channel sorts by its last message, a person by their last activity, and a message by when it was sent. Load more loads the next page of every source that has one, and the new results take their place in the list.

The status line is a live region, so TalkBack announces "Searching..." and then the number of results when the text changes. `accessibilityLiveRegion` has no effect on iOS. To have VoiceOver announce the same text, call `AccessibilityInfo.announceForAccessibility()` when it changes.

## Filter and sort each source

The filters change the sources' filter generators and sorts while the user searches:

```tsx title="src/SearchFilters.tsx"
import { useState } from "react";
import type { ReactNode } from "react";
import { StyleSheet, Text, View } from "react-native";
import {
  Button,
  useChatContext,
  useStateStore,
  useTheme,
} from "stream-chat-react-native";
import { channelSorts, messageSorts, searchAgain, userSorts } from "./search";
import type { MessageFilterContext, Search } from "./search";

type Generators = Record<string, { enabled: boolean }>;

const channelFiltersSelector = (config: Generators) => ({
  groupsOnly: !!config.groupsOnly?.enabled,
  hideArchived: !!config.hideArchived?.enabled,
});

const messageFiltersSelector = (config: Generators) => ({
  withAttachments: !!config.withAttachments?.enabled,
});

const messageContextSelector = ({ authorId }: MessageFilterContext) => ({
  authorId,
});

const DAY_MS = 24 * 60 * 60 * 1000;

// How far back the messages go. The cutoff is computed when the chip is pressed.
const sentOptions = [
  { days: undefined, label: "Any time" },
  { days: 7, label: "Past week" },
  { days: 30, label: "Past month" },
];

const Chip = ({
  label,
  onPress,
  selected,
}: {
  label: string;
  onPress: () => void;
  selected: boolean;
}) => (
  <Button
    label={label}
    onPress={onPress}
    selected={selected}
    size="sm"
    style={{ width: "auto" }}
    type="outline"
    variant="secondary"
  />
);

const Section = ({
  children,
  title,
}: {
  children: ReactNode;
  title: string;
}) => {
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <View style={styles.section}>
      <Text style={[styles.title, { color: semantics.textPrimary }]}>
        {title}
      </Text>
      <View style={styles.chips}>{children}</View>
    </View>
  );
};

export const SearchFilters = ({ search }: { search: Search }) => {
  const { client } = useChatContext();
  const { channels, messages, users } = search;
  const { groupsOnly, hideArchived } = useStateStore(
    channels.filterBuilder.filterConfig,
    channelFiltersSelector,
  );
  const { withAttachments } = useStateStore(
    messages.messageSearchFilterBuilder.filterConfig,
    messageFiltersSelector,
  );
  const { authorId } = useStateStore(
    messages.messageSearchFilterBuilder.context,
    messageContextSelector,
  );
  // A sort isn't in a store, so the chips keep their own copy.
  const [channelSort, setChannelSort] =
    useState<keyof typeof channelSorts>("activity");
  const [userSort, setUserSort] = useState<keyof typeof userSorts>("name");
  const [messageSort, setMessageSort] =
    useState<keyof typeof messageSorts>("newest");
  const [sentDays, setSentDays] = useState<number>();

  const channelFilters = channels.filterBuilder;
  const messageFilters = messages.messageSearchFilterBuilder;

  return (
    <View style={styles.container}>
      <Section title="Channels">
        <Chip
          label="Include archived"
          onPress={() => {
            if (hideArchived) channelFilters.disableFilter("hideArchived");
            else channelFilters.enableFilter("hideArchived");
            searchAgain(search, channels);
          }}
          selected={!hideArchived}
        />
        <Chip
          label="Groups only"
          onPress={() => {
            if (groupsOnly) channelFilters.disableFilter("groupsOnly");
            else channelFilters.enableFilter("groupsOnly");
            searchAgain(search, channels);
          }}
          selected={groupsOnly}
        />
        {(["activity", "members", "newest"] as const).map((key) => (
          <Chip
            key={key}
            label={`By ${key}`}
            onPress={() => {
              channels.sort = channelSorts[key];
              setChannelSort(key);
              searchAgain(search, channels);
            }}
            selected={channelSort === key}
          />
        ))}
      </Section>
      <Section title="People">
        {(["name", "active"] as const).map((key) => (
          <Chip
            key={key}
            label={`By ${key}`}
            onPress={() => {
              users.sort = userSorts[key];
              setUserSort(key);
              searchAgain(search, users);
            }}
            selected={userSort === key}
          />
        ))}
      </Section>
      <Section title="Messages">
        <Chip
          label="With attachments"
          onPress={() => {
            if (withAttachments)
              messageFilters.disableFilter("withAttachments");
            else messageFilters.enableFilter("withAttachments");
            searchAgain(search, messages);
          }}
          selected={withAttachments}
        />
        <Chip
          label="From me"
          onPress={() => {
            messageFilters.updateContext({
              authorId: authorId ? undefined : (client.userId ?? undefined),
            });
            searchAgain(search, messages);
          }}
          selected={!!authorId}
        />
        {sentOptions.map(({ days, label }) => (
          <Chip
            key={label}
            label={label}
            onPress={() => {
              messageFilters.updateContext({
                sentSince: days
                  ? new Date(Date.now() - days * DAY_MS).toISOString()
                  : undefined,
              });
              setSentDays(days);
              searchAgain(search, messages);
            }}
            selected={sentDays === days}
          />
        ))}
        {(["newest", "oldest"] as const).map((key) => (
          <Chip
            key={key}
            label={`${key[0].toUpperCase()}${key.slice(1)} first`}
            onPress={() => {
              messages.messageSearchSort = messageSorts[key];
              setMessageSort(key);
              searchAgain(search, messages);
            }}
            selected={messageSort === key}
          />
        ))}
      </Section>
    </View>
  );
};

const styles = StyleSheet.create({
  chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
  container: { gap: 12, padding: 12 },
  section: { gap: 6 },
  title: { fontWeight: "700" },
});
```

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

![The filters and sort chips for channels, people and messages above the results on iOS](https://getstream.io/docs-assets/images/a3f6b54fbfbc.png)

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

![The filters and sort chips for channels, people and messages above the results on Android](https://getstream.io/docs-assets/images/6b24522cf8c6.png)

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

- **Include archived** and **Groups only** turn the channel source's `hideArchived` and `groupsOnly` generators off and on with `FilterBuilder.disableFilter()` and `FilterBuilder.enableFilter()`, on `channels.filterBuilder`. **With attachments** does the same with the message source's `withAttachments` generator, on `messages.messageSearchFilterBuilder`.
- **From me** and the **Any time**, **Past week** and **Past month** chips set values in the message filter builder's context with `FilterBuilder.updateContext()`. The `author` and `sentSince` generators read them on the next query. The cutoff is computed when the chip is pressed.
- **By activity**, **By members**, **By newest**, **By name** and **By active** set `channels.sort` or `users.sort`, and **Newest first** and **Oldest first** set `messages.messageSearchSort`, to one of the sort maps.
- The chips read their state with `useStateStore()`, from `filterBuilder.filterConfig` and `filterBuilder.context`, so they show the source's current settings. A sort isn't in a store, so the chips keep their own copy.

## Add the screen to the stack

The screen is a screen of the stack, and `onOpenChannel` navigates to the channel screen with the `cid` and the message's id. The channel screen jumps to the message when it opens, as on [Jumping when a screen opens](https://getstream.io/chat/docs/sdk/react-native/ui/layout/moving-between-channels-threads-and-messages/#jumping-when-a-screen-opens), so the same screen serves a result that is a channel and one that is a message:

```tsx title="src/SearchRoute.tsx"
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import { SearchScreen } from "./SearchScreen";

type RootStackParamList = {
  Channel: { cid: string; messageId?: string };
  Search: undefined;
};

export const SearchRoute = ({
  navigation,
}: NativeStackScreenProps<RootStackParamList, "Search">) => (
  <SearchScreen
    onOpenChannel={(cid, messageId) =>
      navigation.navigate("Channel", { cid, messageId })
    }
  />
);
```

Register `SearchRoute` as the `Search` screen, and open it from a button in the channel list's header, as the [Mentions inbox](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/search/mentions-inbox/#add-the-screen-to-the-stack) does for its screen.

## How it works

>
> **Info:** **Example**
>
> The user types "Cara". The channel source finds Garden crew, Cara's direct conversation, Plant swap and Weekend plans, whose names or members match Cara, and loads the first five. The user source finds Cara, and the message source finds "Thanks Cara, looking forward to it!":
>
> 1. `createSearch()` creates the sources, and the controller lets them be active together. `SearchScreen` turns them all on when it mounts.
> 2. Each keystroke passes the text to the active sources through `SearchController.search()`. Each source waits for a pause in typing, queries, and publishes its results in its `state` store, which `SearchResults` renders grouped or mixed.
> 3. A source button, a filter or a sort changes one source and runs the current text in it again, from the first page.
> 4. The user taps the message result. `onOpenChannel` pushes the channel screen with the message's channel and its id, and the screen jumps to the message. The message source stored the channel with the result, and `Channel` watches it when it mounts.
> 5. Leaving the screen calls `SearchController.exit()`, which empties the text and every source's results, and `SearchController.dispose()`, which releases the channels the results held.
>

- A channel found by search isn't watched. `Channel` watches it when it mounts, so the open channel receives events.
- The results are a snapshot. A new message that contains the text doesn't join them, and a renamed channel doesn't leave them. Search again to refresh.
- The server decides what each query returns. Message search covers only the channel types whose `search` setting is on; see [Search](https://getstream.io/chat/docs/sdk/react-native/features/channels/search/#turning-it-on-or-off).

## Read more

- [Search sources](https://getstream.io/chat/docs/sdk/react-native/state/loading-data/search-sources/): the controller, the built-in sources, filter generators and the source's state.
- [Search](https://getstream.io/chat/docs/sdk/react-native/features/channels/search/): how search works across the layers, and what the SDK searches itself.
- [Moving between channels, threads and messages](https://getstream.io/chat/docs/sdk/react-native/ui/layout/moving-between-channels-threads-and-messages/): jumping to a message when a screen opens.
- [Navigation](https://getstream.io/chat/docs/sdk/react-native/ui/layout/navigation/): the stack the screen sits in.
- [Mentions inbox](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/search/mentions-inbox/): a screen that lists the messages that mention the user, from a `MessageSearchSource`.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/search/search-dialog/](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/search/search-dialog/).