Search Sources

Several SDK components query and paginate a list behind a search input. That work is done by a search source from stream-chat, and each of those components accepts a searchSource prop so you can supply your own instance instead of the pre-configured default.

This guide covers what the sources do, how to tune their request behavior, and how to replace one.

Best practices

  • Create the source once and memoize it, so a re-render does not throw away its state and pagination cursor.
  • Pass query parameters (filters, sort, searchOptions) to the constructor, and assign them as properties only when changing them later.
  • Prefer tuning the built-in source over writing your own; a custom source has to reimplement pagination and loading state.
  • Forward the abort signal in a custom query() so a superseded request stops in flight.

Which components take a search source

ComponentSource typeDefault configuration
ChannelMemberListChannelMemberSearchSourceautocompletes members by name
ChannelAddMembersFormUserSearchSourceautocompletes users by name
PinnedMessageListMessageSearchSourcefetches pinned messages
FileAttachmentListMessageSearchSourcefetches file and audio attachments
MediaListMessageSearchSourcefetches image and video attachments

Each source exposes reactive state (items, isLoading, hasNext, searchQuery) through a StateStore, which is what drives the list and its loading indicator.

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. 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

The interval is resolved on every 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
import { useMemo } from "react";
import { ChannelMemberSearchSource } from "stream-chat";
import { ChannelMemberList } from "stream-chat-react-native";

const MemberScreen = ({ channel }) => {
  const searchSource = useMemo(
    () =>
      new ChannelMemberSearchSource(channel, {
        longQueryDebounceMs: 250,
        pageSize: 20,
        shortQueryDebounceMs: 700,
        shortQueryMaxLength: 3,
      }),
    [channel],
  );

  return <ChannelMemberList searchSource={searchSource} />;
};

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:

new ChannelMemberSearchSource(channel, { debounceMs: 400 });

The intervals can also be changed on a live source:

searchSource.setDebounceOptions({ shortQueryDebounceMs: 600 });

Mobile networks make this worth tuning. A longer short-query interval trades first-keystroke latency for far fewer wasted requests on a slow connection.

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.

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

resetState() and cancelScheduledQuery() abort too. Call one of them when a screen holding a source unmounts, so a pending request does not outlive it:

useEffect(() => () => searchSource.cancelScheduledQuery(), [searchSource]);

The built-in sources handle all of this already. A custom source participates by forwarding the abort signal into whatever it calls, as shown under Writing a custom source below. A source that resolves from local data can ignore the signal; nothing else is required of it.

Changing what a source queries

The constructor's second argument takes the query parameters alongside the source options, so both can be set in one place:

const searchSource = useMemo(
  () =>
    new UserSearchSource(client, {
      filters: { id: { $ne: client.userID }, role: { $eq: "admin" } },
      pageSize: 30,
      sort: [{ name: 1 }],
    }),
  [client],
);

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

Each one is also a property on the instance, which is how you change it after construction, for example from a filter control:

searchSource.filters = { ...searchSource.filters, role: { $eq: "moderator" } };

Writing a custom source

Extend BaseSearchSource<T> when you need to query an entity the SDK does not cover. A custom source needs a stable type, a query() implementation returning { items }, and optionally filterQueryResults() to reshape a page before the list sees it.

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

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

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

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

  // forward the abort signal so a superseded query stops in flight
  protected async query(
    searchQuery: string,
    queryOptions?: SearchQueryOptions,
  ) {
    const response = await fetch(
      `https://example.com/api/projects?q=${encodeURIComponent(searchQuery)}`,
      { signal: queryOptions?.signal },
    );
    return { items: (await response.json()) as Project[] };
  }

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

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.