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>
);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
WithComponentsfor UI changes. - Reuse
DefaultSearchResultItemswhen you only want to replace one source renderer. - Prefer
FilterBuilderor source filters over ad hoc query logic in React components. - Keep advanced search close to
Chatso all search consumers share the same controller.
Basic Usage
To customize the default search surface used by ChannelList, override Search from the main package:
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:
ChannelSearchSourcefor channelsUserSearchSourcefor usersMessageSearchSourcefor 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:
- a unique
type - a
query()implementation - an optional
filterQueryResults()implementation
The general pattern is:
- create a source class that extends
BaseSearchSource<T> - return
{ items }fromquery() - register the source in
SearchController - 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:
- the source class extends
BaseSearchSource<T> - the class declares a stable
type query()returns the page of items for the current search stringfilterQueryResults()can optionally reshape that page before the UI sees itSearchSourceResultListreceives aSearchResultItemsmap 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.
| Option | Default | Applies to |
|---|---|---|
shortQueryDebounceMs | 500 | queries of at most shortQueryMaxLength characters |
longQueryDebounceMs | 300 | queries longer than shortQueryMaxLength |
shortQueryMaxLength | 2 | the 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:
ChannelSearchSourcehaschannelSearchSource.filterBuilderUserSearchSourcehasuserSearchSource.filterBuilderMessageSearchSourcehas:messageSearchSource.messageSearchChannelFilterBuildermessageSearchSource.messageSearchFilterBuildermessageSearchSource.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
nullto 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.
Search
The top-level component that renders SearchBar and SearchResults.
SearchBar
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:
SearchSourceResultsHeaderSearchSourceResultsEmptySearchSourceResultList
SearchSourceResultList
Renders the source items inside an InfiniteScrollPaginator. It can also receive a SearchResultItems map that decides how each source type should render.
SearchSourceResultListFooterrenders the footer areaSearchSourceResultsLoadingIndicatorrenders the loading state within that footer
Contexts
SearchContext
Search provides SearchContext to its child components.
Values
| Value | Description | Type |
|---|---|---|
directMessagingChannelType | The channel type to create on user-result selection. Defaults to messaging. | string |
disabled | Whether the input is disabled. | boolean |
exitSearchOnInputBlur | Whether the empty search UI should exit on input blur. | boolean |
placeholder | Placeholder text forwarded from Search. | string |
searchController | The 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>
);
};