Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Search sources

Search in the state layer is a SearchController with a set of sources. Each source searches one kind of item: channels, users, messages, or anything your app adds. The controller holds the search text and runs it against the sources that are active.

Info:

Example

The user types "des" in the sidebar search. The controller passes "des" to its active source, the channel source, which queries the channels whose name matches "des". The results land in that source's state, page by page, like a paginator's. Unlike a paginator's, they don't change as events arrive.

Showing search in the UI, and replacing the result components, is on the Search feature page.

The search controller

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

const searchController = new SearchController({
  client,
  sources: [
    new ChannelSearchSource(client),
    new UserSearchSource(client),
    new MessageSearchSource(client),
  ],
});

searchController.activate(); // activates the first source, channels
void searchController.search("des");

// later: search messages instead
searchController.activateSource("messages");
void searchController.search("des");

The controller's state store holds:

  • isActive: whether search is open;
  • searchQuery: the current search text;
  • sources: every source, active or not.

Its methods:

  • SearchController.activate() opens search. If no source is active, it activates the first one, or every source when keepSingleActiveSource is false.
  • SearchController.search(text) sets the search text and passes it to every active source. Each source waits for the user to stop typing before it queries.
  • SearchController.activateSource(type) and SearchController.deactivateSource(type) turn a source on or off. The last active source can't be turned off.
  • SearchController.clear() empties the search text and every source's results, and keeps search open. SearchController.exit() does the same and closes search.
  • SearchController.addSource(source), SearchController.removeSource(type) and SearchController.getSource(type) manage the sources.
  • SearchController.cancelSearchQueries() cancels the queries that are waiting or in flight.

By default only one source is active at a time: activating one turns the others off. To search several at once, set keepSingleActiveSource to false:

client.config.set({ searchController: { keepSingleActiveSource: false } });

client.config reaches a controller only when it was created with client, as above.

When you no longer need a controller you created, call SearchController.dispose(). Its channel results then no longer keep their channels in memory, and it stops following client.config. SearchController.registerSubscriptions() undoes it. The React SDK creates a controller with the channel, user and message sources when the app doesn't pass one.

Built-in sources

Source type Searches In the UI's default controller
ChannelSearchSource "channels" the user's channels whose name matches the text yes
UserSearchSource "users" users whose id or name matches, without the current user yes
MessageSearchSource "messages" messages that contain the text, in the user's channels, newest first yes
ChannelMemberSearchSource "members" members of one channel whose name matches or whose id is the text no

The UI layer creates a SearchController with the channel, user and message sources. A SearchController you create yourself has only the sources you pass.

The built-in sources come in two kinds: three that run one query, and MessageSearchSource, which runs two.

Channel, user and member sources

Each runs one query: queryChannels, queryUsers or queryMembers. Create one with the client and an options object, as in new ChannelSearchSource(client, options). ChannelMemberSearchSource takes the channel instead. These options narrow what the query finds:

Option What it does
filters narrows the results
sort orders them
searchOptions the query's other options, except limit and offset, which the source sets
  • "Matches" in the table means $autocomplete: a word in the field starts with the text. A member's id must equal the text.
  • Channels found aren't watched. Opening one loads and watches it.
  • Its filter builder is source.filterBuilder.

Message source

MessageSearchSource runs two queries: it searches messages, then loads the results' channels that the client doesn't have, without watching them. Create it with the client and an options object, new MessageSearchSource(client, options). These options narrow what its queries find:

Option Applies to
messageSearchChannelFilters the search: which channels to search
messageSearchFilters the search: which messages
messageSearchSort the search: the order. Messages always sort by created_at first, newest first by default; a created_at entry sets the direction, and other fields sort after it
channelQueryFilters, channelQueryOptions loading the results' channels
  • Each of the two queries has its own filter builders: messageSearchChannelFilterBuilder and messageSearchFilterBuilder for the search, and channelQueryFilterBuilder for loading the channels.

Changing the options later

The options in the tables above, such as filters and sort, are also properties of the source, so they can change after it's created. The new value applies from the next query:

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

const channels = new ChannelSearchSource(client, {
  filters: { type: "messaging" },
  sort: [{ direction: -1, field: "last_message_at" }],
});
const users = new UserSearchSource(client, { filters: { role: "user" } });

// later
users.filters = { ...users.filters, banned: false };

Building the filters from the search text at runtime

Setting a source's filters gives it a fixed filter. To build the filter each time the source queries instead, give the source filter generators: functions that receive the search text, as searchQuery, and any values you add as context, and return part of the filter, or null to leave it out. The source sends those parts together with filters.

The generators go in the source's constructor, in its third argument, as initialFilterConfig. It's an object with one entry per generator, and each entry has two fields:

  • generate: the generator function;
  • enabled: whether its part is used.

The key names the generator. Pick any name for a generator of your own. The key doesn't have to match the field the generator filters on. A built-in generator's name replaces it:

Source Built-in generator Its condition
ChannelSearchSource name name matches the text
UserSearchSource $or id or name matches the text
ChannelMemberSearchSource default name matches, or id equals the text
MessageSearchSource text in the message search, cid when loading the results' channels the messages contain the text; the channels are the results'

The values for the context go in the same argument, as initialContext.

Adding a generator

This generator leaves out archived channels while hideArchived is true:

import { ChannelSearchSource } from "stream-chat";

const channels = new ChannelSearchSource<{ hideArchived: boolean }>(
  client,
  undefined,
  {
    initialContext: { hideArchived: true },
    initialFilterConfig: {
      archived: {
        enabled: true,
        generate: ({ hideArchived }) =>
          hideArchived ? { archived: false } : null,
      },
    },
  },
);

The built-in name generator stays, so the text "des" now finds the user's channels named "des…" that aren't archived. To change hideArchived while the user searches, see Switching generators at runtime.

Replacing a built-in generator

A generator with a built-in generator's name replaces it. This name generator matches the text against the channel's name or its members' names. Returning an $or makes either match enough:

import { ChannelSearchSource } from "stream-chat";

const channels = new ChannelSearchSource(client, undefined, {
  initialFilterConfig: {
    name: {
      enabled: true,
      generate: ({ searchQuery }) =>
        searchQuery
          ? {
              $or: [
                { name: { $autocomplete: searchQuery } },
                { "member.user.name": { $autocomplete: searchQuery } },
              ],
            }
          : null,
    },
  },
});

Whether the built-in generators stay depends on the source:

  • ChannelSearchSource and MessageSearchSource keep their built-in generators and add yours, so only a generator with the same name replaces one.
  • UserSearchSource and ChannelMemberSearchSource use only your generators, so include one for the search text.
  • MessageSearchSource takes the third argument per query, under messageSearchChannel, messageSearch and channelQuery. The channelQuery generators receive cids, the results' channel ids, instead of the search text.

Switching generators at runtime

The source's filter builder, source.filterBuilder, changes the generators while the user searches. MessageSearchSource has one per query: messageSearchChannelFilterBuilder, messageSearchFilterBuilder and channelQueryFilterBuilder. The change applies from the next query.

  • FilterBuilder.updateContext(values) changes the values generate receives.
  • FilterBuilder.enableFilter(name) and FilterBuilder.disableFilter(name) turn a generator on or off.

With the archived generator above, a "Show archived" switch changes the context:

import type { ChannelSearchSource } from "stream-chat";

const setShowArchived = (
  channels: ChannelSearchSource<{ hideArchived: boolean }>,
  showArchived: boolean,
) => channels.filterBuilder.updateContext({ hideArchived: !showArchived });

Source state

Each source keeps its results in its state store:

Field What it holds
items The results loaded so far. undefined before the first search.
isLoading Whether a query is running.
hasNext Whether there are more results to load.
lastQueryError The error of the last failed query.
searchQuery The text the results are for.
isActive Whether the source is active.

source.search() with no text loads the next page. It does nothing while a query runs or when hasNext is false. A new search text replaces the results and restarts the search from the beginning.

import { MessageSearchSource } from "stream-chat";

const messages = new MessageSearchSource(client);
messages.activate();

const unsubscribe = messages.state.subscribeWithSelector(
  ({ hasNext, isLoading, items }) => ({ hasNext, isLoading, items }),
  ({ hasNext, isLoading, items }) => {
    console.log(`${items?.length ?? 0} messages found`, { hasNext, isLoading });
  },
);

void messages.search("release plan");

When a source queries

Every source's constructor options also take settings that tune when it queries: the second argument for the built-in sources, as in new ChannelSearchSource(client, options), and the only argument for a source of your own.

Option Default What it does
pageSize 10 How many results a page holds.
shortQueryDebounceMs 500 How long to wait after the last keystroke, for a text up to shortQueryMaxLength.
longQueryDebounceMs 300 How long to wait for a longer text.
shortQueryMaxLength 2 The longest text that counts as short.
debounceMs none One wait for every text, in place of the two above.
allowEmptySearchString false Whether a source queries with an empty text. Always true for ChannelMemberSearchSource, so it lists members before the user types.
resetOnNewSearchQuery true Whether a new text clears the results before its own arrive.

A short text matches much of the data, which makes it the slowest query to run, so it waits longer. Typing a, ab, then abc waits 500 ms, 500 ms, then 300 ms, and only the last query runs. source.setDebounceOptions() changes the waits later.

When a new query starts while the previous one is in flight, the previous one is cancelled, and its late response is ignored. A cancelled query never sets lastQueryError. source.cancelScheduledQuery() and source.resetState() cancel too.

Adding your own source

A source for your own data extends BaseSearchSource. It needs a type, a BaseSearchSource.query() that returns { items } for one page, and a BaseSearchSource.filterQueryResults() that returns the items to show.

import { BaseSearchSource, SearchController } 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);
  }

  protected async query(
    searchQuery: string,
    { signal }: SearchQueryOptions = {},
  ) {
    const params = new URLSearchParams({
      limit: String(this.pageSize),
      offset: String(this.offset ?? 0),
      q: searchQuery,
    });
    // Passing the signal lets a newer query cancel this request.
    const response = await fetch(`/api/projects?${params}`, { signal });
    return { items: (await response.json()) as Project[] };
  }

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

const searchController = new SearchController({
  client,
  sources: [new ProjectSearchSource({ pageSize: 20 })],
});

How BaseSearchSource.query() pages:

  • By offset. Return { items }. The source moves offset forward by the number of items, and hasNext stays true while a page comes back full.
  • By cursor. Return { items, next }. The source keeps next for the following query, and hasNext is true while next is set.

A source whose data is local can extend BaseSearchSourceSync instead, with a BaseSearchSourceSync.query() that returns the page directly.

Search vs paginators

Search results are a snapshot. A new message that contains the text doesn't join the search results, and a renamed channel doesn't leave them. To refresh the results, search again.

Channel results are the same Channel instances the rest of the state layer uses, and opening one loads and watches it. For data that has to stay current, use a paginator: Channel list paginators or Message paginators.

Used in