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 {
  SearchQueryOptions,
  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() receives the current search string plus the abort signal for this
  // query; forward the signal so a superseded query stops in flight
  protected async query(
    searchQuery: string,
    queryOptions?: SearchQueryOptions,
  ) {
    const response = await fetch(
      `/api/projects?q=${encodeURIComponent(searchQuery)}`,
      { signal: queryOptions?.signal },
    );
    return { items: (await response.json()) as Project[] };
  }

  // 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" },
      sort: { last_message_at: -1 },
    });

    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.

The second constructor argument takes both the query parameters (filters, sort, searchOptions) and the source options covered under Tuning search requests (pageSize, the debounce intervals), so they can be set together:

const channelSearchSource = new ChannelSearchSource(client, {
  filters: { type: "messaging" },
  pageSize: 20,
  shortQueryDebounceMs: 700,
});

MessageSearchSource names its query parameters after the request each one shapes: messageSearchChannelFilters, messageSearchFilters, messageSearchSort, channelQueryFilters, channelQuerySort and channelQueryOptions.

Every query parameter is also a property on the instance, which is how you change one after construction:

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;
};

Tuning search requests

Short queries match a large share of the dataset, which makes them the slowest and most expensive ones to run server-side. Search sources handle that in two ways: the debounce interval depends on how long the query is, and a request that a newer query has replaced is cancelled rather than left to finish.

Adaptive debounce

Every source resolves its debounce interval on each keystroke, so a query moves between the two buckets as it grows or shrinks.

OptionDefaultApplies to
shortQueryDebounceMs500queries of at most shortQueryMaxLength characters
longQueryDebounceMs300queries longer than shortQueryMaxLength
shortQueryMaxLength2the query length that still counts as short, inclusive
const channelSearchSource = new ChannelSearchSource(client, {
  longQueryDebounceMs: 250,
  shortQueryDebounceMs: 700,
  shortQueryMaxLength: 3,
});

Typing a, ab, then abc schedules at 500ms, 500ms, and finally 300ms, and only the last one runs. Deleting back down to ab returns to the 500ms interval.

Setting the legacy debounceMs applies a single interval to both buckets, which opts out of the adaptive behavior:

const source = new ChannelSearchSource(client, { debounceMs: 400 });

The intervals can also be changed on a live source:

searchController.getSource("channels")?.setDebounceOptions({
  shortQueryDebounceMs: 600,
});

Request cancellation

When the debounce fires while a previous request is still in flight, the source aborts that request before dispatching the new one. A late response from the superseded query is discarded, so it can never overwrite newer results. cancelScheduledQuery() and resetState() abort as well, which is what makes closing or clearing the search surface stop pending work.

A new search query also preempts an in-flight one rather than being dropped. Pagination still waits for the current page to arrive, so search() with no argument is a no-op while a query is loading.

Custom sources participate by forwarding the abort signal into whatever they call. This is the same source built above, reduced to the parts that matter for cancellation:

import { BaseSearchSource } from "stream-chat";
import type { SearchQueryOptions } from "stream-chat";

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

class ProjectSearchSource extends BaseSearchSource<Project> {
  readonly type = "projects";

  protected async query(
    searchQuery: string,
    queryOptions?: SearchQueryOptions,
  ) {
    const response = await fetch(
      `/api/projects?q=${encodeURIComponent(searchQuery)}`,
      { signal: queryOptions?.signal },
    );
    return { items: (await response.json()) as Project[] };
  }

  protected filterQueryResults(items: Project[]) {
    return items;
  }
}

A source that resolves from local data can ignore the signal; nothing else is required of it.

The source skips its state update for an aborted query, so an abort rejection is never surfaced through lastQueryError. Avoid translating it into an empty result set or an error message of your own.

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,
        generate: ({ archived }) => ({ archived }),
      },
      memberUserName: {
        enabled: true,
        generate: ({ 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,
            generate: ({ 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.updateContext({ 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.updateContext({
              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) => ({
  hasNext: value.hasNext,
  isLoading: value.isLoading,
});

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

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