This is beta documentation for Stream Chat React SDK v14. For the latest stable version, see the latest version (v13) .

Advanced Search

Search covers the common sidebar-search use case, but the underlying SearchController is also customizable when you need to control sources, filters, ranking, or result rendering.

Best Practices

  • Keep advanced search logic in a memoized SearchController.
  • Use custom sources for data changes and WithComponents for UI changes.
  • Reuse DefaultSearchResultItems when you only want to replace one source renderer.
  • Prefer FilterBuilder or source filters over ad hoc query logic in React components.
  • Keep advanced search close to Chat so all search consumers share the same controller.

Basic Usage

To customize the default search surface used by ChannelList, override Search from the main package:

import { Chat, ChannelList, Search, WithComponents } from "stream-chat-react";

const SidebarSearch = () => (
  <Search
    exitSearchOnInputBlur
    placeholder="Search channels, messages, and users..."
  />
);

const App = () => (
  <Chat client={client}>
    <WithComponents overrides={{ Search: SidebarSearch }}>
      <ChannelList
        filters={filters}
        options={options}
        showChannelSearch
        sort={sort}
      />
    </WithComponents>
  </Chat>
);

Search Data Management

Search state is managed by the reactive SearchController class from stream-chat. It uses search sources for data retrieval and pagination. The default sources are:

  • ChannelSearchSource for channels
  • UserSearchSource for users
  • MessageSearchSource for messages

Customizing Search Data Retrieval

You can customize the built-in sources or add your own source for a custom entity. The same pattern applies whether you are introducing a brand-new source type or replacing one of the defaults.

Each custom source needs:

  1. a unique type
  2. a query() implementation
  3. an optional filterQueryResults() implementation

The general pattern is:

  1. create a source class that extends BaseSearchSource<T>
  2. return { items } from query()
  3. register the source in SearchController
  4. provide a matching result-item component in SearchSourceResultList

Example: Build A Custom Search Source

The example below demonstrates how to add a new custom search source. This is the most common pattern when your search needs to include an app-specific entity that Stream Chat does not query by default.

The important pieces are:

  1. the source class extends BaseSearchSource<T>
  2. the class declares a stable type
  3. query() returns the page of items for the current search string
  4. filterQueryResults() can optionally reshape that page before the UI sees it
  5. SearchSourceResultList receives a SearchResultItems map so the UI knows how to render the new source type
import { useMemo } from "react";
import {
  BaseSearchSource,
  ChannelSearchSource,
  MessageSearchSource,
  SearchController,
  SearchSourceOptions,
  UserSearchSource,
} from "stream-chat";
import {
  Chat,
  DefaultSearchResultItems,
  Search,
  SearchSourceResultList,
  WithComponents,
} from "stream-chat-react";

type Project = { id: string; name: string };

class ProjectSearchSource extends BaseSearchSource<Project> {
  // the search source type is used as the source key in SearchController
  readonly type = "projects";

  constructor(options?: SearchSourceOptions) {
    super(options);
  }

  // query() always receives the current search string
  protected async query(searchQuery: string) {
    return searchQuery.length > 1
      ? { items: [{ id: "alpha", name: `Project ${searchQuery}` }] }
      : { items: [] };
  }

  // filterQueryResults() is optional; use it when you want to adjust the retrieved page
  protected filterQueryResults(items: Project[]) {
    return items;
  }
}

// this component renders one item for the custom source
const ProjectResult = ({ item }: { item: Project }) => (
  <button type="button">{item.name}</button>
);

// SearchSourceResultList needs to know which component renders each source type
const SearchResultList = () => (
  <SearchSourceResultList
    SearchResultItems={{
      ...DefaultSearchResultItems,
      projects: ProjectResult,
    }}
  />
);

const App = () => {
  const searchController = useMemo(() => {
    if (!client) return;
    return new SearchController({
      sources: [
        new ProjectSearchSource(),
        new ChannelSearchSource(client),
        new UserSearchSource(client),
        new MessageSearchSource(client),
      ],
    });
  }, [client]);

  if (!searchController) return null;

  return (
    <Chat client={client} searchController={searchController}>
      <WithComponents
        overrides={{ Search, SearchSourceResultList: SearchResultList }}
      >
        <ChannelList
          filters={filters}
          options={options}
          showChannelSearch
          sort={sort}
        />
      </WithComponents>
    </Chat>
  );
};

That same pattern can be used to override a default source as well. In that case, you would still create the source instance yourself and register it in SearchController, but you would use ChannelSearchSource, UserSearchSource, or MessageSearchSource instead of extending BaseSearchSource.

Example: Replace A Built-In Source

Sometimes you do not need a brand-new source type. You just need the existing channel or user source to query with different defaults. In that case, instantiate the built-in source yourself and pass it into SearchController.

import { useMemo } from "react";
import {
  ChannelSearchSource,
  SearchController,
  UserSearchSource,
} from "stream-chat";

const useCustomSearchController = (client: StreamChat | null, userId: string) =>
  useMemo(() => {
    if (!client) return undefined;

    const channelSearchSource = new ChannelSearchSource(client, {
      filters: { members: { $in: [userId] }, type: "messaging" },
    });

    const userSearchSource = new UserSearchSource(client, {
      filters: { id: { $ne: userId } },
    });

    return new SearchController({
      sources: [channelSearchSource, userSearchSource],
    });
  }, [client, userId]);

This approach keeps the SDK's built-in search-source behavior, pagination, and result types, but swaps in your own query configuration.

You can also override query parameters such as filters, sort, or searchOptions by assignment:

import { useChatContext } from "stream-chat-react";

const SearchSourceFilters = () => {
  const { searchController } = useChatContext();

  const usersSearchSource = searchController.getSource("users");
  usersSearchSource.filters = {
    ...usersSearchSource.filters,
    myCustomField: "some-value",
  };

  return null;
};

Search Request Dynamic Filter Building

Default search sources include FilterBuilder instances for dynamic filter building.

The built-in sources expose these filter builders:

  1. ChannelSearchSource has channelSearchSource.filterBuilder
  2. UserSearchSource has userSearchSource.filterBuilder
  3. MessageSearchSource has:
    • messageSearchSource.messageSearchChannelFilterBuilder
    • messageSearchSource.messageSearchFilterBuilder
    • messageSearchSource.channelQueryFilterBuilder

You can define initialFilterConfig and initialContext when creating a source. The generator functions should return either:

  • a partial filter object that gets merged into the final filter
  • null to skip that generator
import { ChannelSearchSource } from "stream-chat";

const channelSearchSource = new ChannelSearchSource<{ archived: boolean }>(
  client,
  undefined,
  {
    initialContext: { archived: false },
    initialFilterConfig: {
      archived: {
        enabled: true,
        generator: ({ archived }) => ({ archived }),
      },
      memberUserName: {
        enabled: true,
        generator: ({ searchQuery }) =>
          searchQuery
            ? { "member.user.name": { $autocomplete: searchQuery } }
            : null,
      },
    },
  },
);

When you need more than one source with custom filter builders, create the SearchController explicitly:

import { useMemo } from "react";
import {
  ChannelSearchSource,
  MessageSearchSource,
  SearchController,
  UserSearchSource,
} from "stream-chat";

const useCustomSearchController = (client: StreamChat | null) =>
  useMemo(() => {
    if (!client) return undefined;

    const channelSearchSource = new ChannelSearchSource<{ archived: boolean }>(
      client,
      undefined,
      {
        initialContext: { archived: false },
        initialFilterConfig: {
          archived: {
            enabled: true,
            generator: ({ archived }) => ({ archived }),
          },
        },
      },
    );

    const userSearchSource = new UserSearchSource(client);

    const messageSearchSource = new MessageSearchSource<{
      messageSearchChannelContext: { region: string };
      messageSearchContext: { onlyPinned: boolean };
      channelQueryContext: { includeArchived: boolean };
    }>(client);

    return new SearchController({
      sources: [channelSearchSource, userSearchSource, messageSearchSource],
    });
  }, [client]);

It is also possible to enable or disable individual generators at runtime:

const searchSource = new ChannelSearchSource(client);

searchSource.filterBuilder.disableFilter("name");
searchSource.filterBuilder.enableFilter("name");

And you can update the filter-builder context reactively:

const searchSource = new ChannelSearchSource<{ archived: boolean }>(client);

searchSource.filterBuilder.setContext({ archived: true });

That reactive state can be consumed from your custom search UI:

import { useSearchContext, useStateStore } from "stream-chat-react";
import type {
  ChannelSearchSource,
  ChannelSearchSourceFilterBuilderContext,
  FilterBuilderGenerators,
} from "stream-chat";

const contextSelector = ({
  archived,
}: ChannelSearchSourceFilterBuilderContext<{ archived: boolean }>) => ({
  archived,
});

const filterGeneratorEnablementSelector = (
  generators: FilterBuilderGenerators<
    ChannelFilters,
    ChannelSearchSourceFilterBuilderContext<{ archived: boolean }>
  >,
) => ({
  archivedFilterEnabled: generators.archived.enabled,
});

export const SearchResultsHeader = () => {
  const { searchController } = useSearchContext();
  const channelSearchSource = searchController.getSource(
    "channels",
  ) as ChannelSearchSource<{ archived: boolean }>;
  const { archived } = useStateStore(
    channelSearchSource.filterBuilder.context,
    contextSelector,
  );
  const { archivedFilterEnabled } = useStateStore(
    channelSearchSource.filterBuilder.filterConfig,
    filterGeneratorEnablementSelector,
  );

  return (
    <div className="str-chat__search-results-header">
      <label>
        Archived
        <input
          checked={archived}
          type="checkbox"
          onChange={() =>
            channelSearchSource.filterBuilder.setContext({
              archived: !archived,
            })
          }
        />
      </label>
      <label>
        Enable archived filter
        <input
          checked={archivedFilterEnabled}
          type="checkbox"
          onChange={() =>
            archivedFilterEnabled
              ? channelSearchSource.filterBuilder.disableFilter("archived")
              : channelSearchSource.filterBuilder.enableFilter("archived")
          }
        />
      </label>
    </div>
  );
};

Search UI Components And Their Customization

The default search UI components can be overridden through ComponentContext.

The top-level component that renders SearchBar and SearchResults.

The input layer, clear button, and cancel button.

SearchResults

The top-level results container for one or more active search sources.

SearchResultsPresearch

Rendered when search is active but the query is still empty.

SearchResultsHeader

Rendered above the source-specific results pane.

SearchSourceResults

Rendered once per active search source. It coordinates:

  • SearchSourceResultsHeader
  • SearchSourceResultsEmpty
  • SearchSourceResultList
SearchSourceResultList

Renders the source items inside an InfiniteScrollPaginator. It can also receive a SearchResultItems map that decides how each source type should render.

  • SearchSourceResultListFooter renders the footer area
  • SearchSourceResultsLoadingIndicator renders the loading state within that footer

Contexts

SearchContext

Search provides SearchContext to its child components.

Values

ValueDescriptionType
directMessagingChannelTypeThe channel type to create on user-result selection. Defaults to messaging.string
disabledWhether the input is disabled.boolean
exitSearchOnInputBlurWhether the empty search UI should exit on input blur.boolean
placeholderPlaceholder text forwarded from Search.string
searchControllerThe SearchController instance from Chat.SearchController

You can consume the searchController reactively:

import {
  SearchResultsHeader,
  SearchResultsPresearch,
  SearchSourceResults,
  useSearchContext,
  useStateStore,
} from "stream-chat-react";
import type { SearchControllerState } from "stream-chat";

const searchControllerStateSelector = (nextValue: SearchControllerState) => ({
  activeSources: nextValue.sources.filter((source) => source.isActive),
  isActive: nextValue.isActive,
  searchQuery: nextValue.searchQuery,
});

export const SearchResults = () => {
  const { searchController } = useSearchContext();
  const { activeSources, isActive, searchQuery } = useStateStore(
    searchController.state,
    searchControllerStateSelector,
  );

  return isActive ? (
    <div>
      <SearchResultsHeader />
      {!searchQuery ? (
        <SearchResultsPresearch activeSources={activeSources} />
      ) : (
        activeSources.map((source) => (
          <SearchSourceResults key={source.type} searchSource={source} />
        ))
      )}
    </div>
  ) : null;
};

SearchSourceResultsContext

SearchSourceResults provides useSearchSourceResultsContext() for the active source pane. That lets child components react to source-level state such as pagination or loading:

import {
  useSearchSourceResultsContext,
  useStateStore,
} from "stream-chat-react";
import type { SearchSourceState } from "stream-chat";

const searchSourceStateSelector = (value: SearchSourceState) => ({
  hasMore: value.hasMore,
  isLoading: value.isLoading,
});

const SearchSourceFooter = () => {
  const { searchSource } = useSearchSourceResultsContext();
  const { hasMore, isLoading } = useStateStore(
    searchSource.state,
    searchSourceStateSelector,
  );

  return (
    <div>
      {isLoading ? (
        <div>Loading…</div>
      ) : !hasMore ? (
        <div>All results loaded</div>
      ) : null}
    </div>
  );
};