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.
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 whenkeepSingleActiveSourceisfalse.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)andSearchController.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)andSearchController.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'sidmust 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:
messageSearchChannelFilterBuilderandmessageSearchFilterBuilderfor the search, andchannelQueryFilterBuilderfor 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:
ChannelSearchSourceandMessageSearchSourcekeep their built-in generators and add yours, so only a generator with the same name replaces one.UserSearchSourceandChannelMemberSearchSourceuse only your generators, so include one for the search text.MessageSearchSourcetakes the third argument per query, undermessageSearchChannel,messageSearchandchannelQuery. ThechannelQuerygenerators receivecids, 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 valuesgeneratereceives.FilterBuilder.enableFilter(name)andFilterBuilder.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 movesoffsetforward by the number of items, andhasNextstaystruewhile a page comes back full. - By cursor. Return
{ items, next }. The source keepsnextfor the following query, andhasNextistruewhilenextis 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.