<ChannelList
filters={filters}
sort={sort}
options={options}
showChannelSearch
additionalChannelSearchProps={{ searchForChannels: true }}
/>
Channel Search
Channel search is used to find channels based on a query that includes user input. Depending on your needs, it can be used to either find existing conversations, start new ones, or do both.
There are two ways to use the built-in search functionality:
- by enabling search in the
ChannelList
component with theshowChannelSearch
prop, - or by using the
ChannelSearch
component directly.
Whichever works better for you, the ChannelSearch
component is the one that handles the UI logic.
In the first case, if you’re using the search functionality of the
ChannelList
component, the ChannelSearch
is rendered by the ChannelList
.
You can still pass props to the underlying ChannelSearch
through the
additionalChannelSearchProps
.
For example, you could enable search in the ChannelList
(with the
showChannelSearch
prop), and configure the search results to include both channels
and users by passing settings in the additionalChannelSearchProps
:
In the second case, if you’re using the ChannelSearch
component directly,
you can pass settings directly as props of the ChannelSearch
component:
<ChannelSearch searchForChannels />
Component Anatomy
The ChannelSearch
component consists of the search bar (including the search
input), the results header, and the results list (consisting of individual search
results items).
Each of these components can be overridden by passing custom components in the
ChannelSearch
props:
<ChannelSearch
SearchBar={CustomSearchBar}
SearchInput={CustomInput}
SearchResultsHeader={CustomHeader}
SearchResultsList={CustomList}
SearchResultItem={CustomItem}
/>
If you’re using the search functionality of the ChannelList
components, you
can pass the same custom components to the additionalChannelSearchProps
:
<ChannelList
filters={filters}
sort={sort}
options={options}
showChannelSearch
additionalChannelSearchProps={{
SearchBar: CustomSearchBar,
SearchInput: CustomInput,
SearchResultsHeader: CustomHeader,
SearchResultsList: CustomList,
SearchResultItem: CustomItem,
}}
/>
Next, we’ll take a closer look at some of these customization options. But first, we encourage you to explore the many out-of-the-box customization options that do not require you to provide custom components.
Overriding the Search Result Item
You can override the way each search result item is presented by providing a
custom SearchResultItem
.
<ChannelSearch SearchResultItem={CustomSearchResultItem} />
Or:
<ChannelList
additionalChannelSearchProps={{
SearchResultItem: CustomSearchResultItem,
}}
/>
// Don't forget to provide filter and sort options as well!
This component receives a search result item as a prop, which can be either a
UserResponse
or a Channel
(if the searchForChannels
option is enabled).
Your custom implementation should be able to:
- Display both channel and user search result items.
- Provide visual feedback for an item focused with the arrow keys. We can do this by
looking at the
focusUser
prop which contains the index of the currently selected item. - When clicked, it should invoke the
selectResult
callback.
const CustomSearchResultItem = ({ result, index, focusedUser, selectResult }) => {
const isChannel = result.cid;
return (
<button
className={`search-result-item ${index === focusedUser ? 'search-result-item_focused' : ''}`}
onClick={() => selectResult(result)}
>
{isChannel ? (
<>
<span className='search-result-item__icon'>#️⃣</span>
{result.data?.name}
</>
) : (
<>
<span className='search-result-item__icon'>👤</span>
{result.name ?? result.id}
</>
)}
</button>
);
};
.search-result-item {
font: inherit;
border: 0;
background: none;
padding: 10px 20px 10px 50px;
text-align: left;
}
.search-result-item:not(:last-child) {
border-bottom: 1px solid #dbdde1;
}
.search-result-item_focused {
background: #dbdde1;
}
.search-result-item__icon {
display: inline-block;
width: 30px;
margin-left: -30px;
}
Implementing Search from Scratch
You don’t have to rely on the components provided in the SDK to implement search. For ultimate customization, it’s not too difficult to implement search from scratch. You’ll have to manage the state yourself, and use our low-level client methods to query for results, but the upside is that you can manipulate the results however you like.
See our client documentation to learn how to query for channels, users, or messages. As a quick reference, here are the queries we will be using:
// Query at most 5 messaging channels where current user is a member,
// by channel name:
const channels = await client.queryChannels(
{
type: 'messaging',
name: { $autocomplete: query },
members: { $in: [userId] },
},
{ last_message_at: -1, updated_at: -1 },
{ limit: 5 },
);
// Query at most 5 users (except the current one), by user name or id:
const { users } = await client.queryUsers(
{
$or: [{ id: { $autocomplete: query } }, { name: { $autocomplete: query } }],
id: { $ne: userId },
},
{ id: 1, name: 1 },
{ limit: 5 },
);
// Query at most 5 messages from the messaging channels where current user
// is a member, by message text:
const { results } = await client.search({ type: 'messaging', members: { $in: [userId] } }, query, {
limit: 5,
});
const messages = results.map((item) => item.message);
Next, let’s add some simple text input and some buttons to search for channels, users, or messages:
const CustomSearch = () => {
const [query, setQuery] = useState('');
return (
<div className='search'>
<input
type='search'
className='search-input'
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{query && (
<div className='search-actions'>
<button type='button' className='search-button'>
#️⃣ Find "{query}" channels
</button>
<button type='button' className='search-button'>
👤 Find "{query}" users
</button>
<button type='button' className='search-button'>
💬 Look up "{query}" in messages
</button>
</div>
)}
</div>
);
};
.search-input {
width: 100%;
border: 0;
border-radius: 10px;
background: #00000014;
font: inherit;
padding: 10px 15px;
}
.search-input::-webkit-search-cancel-button {
appearance: none;
}
.search-actions {
display: flex;
flex-direction: column;
margin: 10px 0 20px;
}
.search-button {
background: #00000014;
border: 0;
border-bottom: 1px solid #dbdde1;
padding: 10px 15px;
cursor: pointer;
}
.search-button:first-child {
border-radius: 10px 10px 0 0;
}
.search-button:last-child {
border-radius: 0 0 10px 10px;
border-bottom: 0;
}
.search-button:hover {
background: #dbdde1;
}
So far, our CustomSearch
component doesn’t do anything. Let’s wire things up
by adding click event listeners to the search buttons.
A note about race conditions
One thing we should be aware of is race conditions: we should either abort or discard the results of the previous request when making a new one, or prevent a user from making multiple requests at once. Better yet, use a query library like TanStack Query or SWR to make requests.
In this example, we will use a helper function that will protect us from race conditions:
function useSearchQuery() {
const [results, setResults] = useState(null);
const [pending, setPending] = useState(false);
const pendingRequestAbortController = useRef(null);
const startNextRequestWithSignal = () => {
pendingRequestAbortController.current?.abort();
pendingRequestAbortController.current = new AbortController();
return pendingRequestAbortController.current.signal;
};
const querySearchResults = async (fether) => {
setPending(true);
const signal = startNextRequestWithSignal();
const results = await fether();
if (!signal.aborted) {
setResults(results);
setPending(false);
}
};
return { results, pending, querySearchResults };
}
If you’re implementing the “search as you type” user experience, don’t forget to debounce or throttle your search requests. Otherwise, you can quickly hit rate limits.
import { useChatContext } from 'stream-chat-react';
const CustomSearch = () => {
const { client } = useChatContext();
const [query, setQuery] = useState('');
const { results, pending, querySearchResults } = useSearchQuery();
const handleChannelSearchClick = () => {
querySearchResults(async () => {
const channels = await client.queryChannels(
{
type: 'messaging',
name: { $autocomplete: query },
members: { $in: [userId] },
},
{ last_message_at: -1, updated_at: -1 },
{ limit: 5 },
);
return {
entity: 'channel',
items: channels,
};
});
};
const handleUserSearchClick = () => {
querySearchResults(async () => {
const { users } = await client.queryUsers(
{
$or: [{ id: { $autocomplete: query } }, { name: { $autocomplete: query } }],
id: { $ne: userId },
},
{ id: 1, name: 1 },
{ limit: 5 },
);
return {
entity: 'user',
items: users,
};
});
};
const handleMessageSearchClick = () => {
querySearchResults(async () => {
const { results } = await client.search(
{ type: 'messaging', members: { $in: [userId] } },
query,
{ limit: 5 },
);
return {
entity: 'message',
items: results.map((item) => item.message),
};
});
};
return (
<div className='search'>
<input
type='search'
className='search-input'
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{query && (
<div className='search-actions'>
<button type='button' className='search-button' onClick={handleChannelSearchClick}>
#️⃣ Find <strong>"{query}"</strong> channels
</button>
<button type='button' className='search-button' onClick={handleUserSearchClick}>
👤 Find <strong>"{query}"</strong> users
</button>
<button type='button' className='search-button' onClick={handleMessageSearchClick}>
💬 Look up <strong>"{query}"</strong> in messages
</button>
</div>
)}
</div>
);
};
Finally, we need to display the search results to the user. You can use components
like ChannelPreview
that come with the SDK, or you can create your own. Let’s
create very simple preview components for channels, users, and messages:
const ChannelSearchResultPreview = ({ channel }) => (
<li className='search-results__item'>
<div className='search-results__icon'>#️⃣</div>
{channel.data?.name}
</li>
);
const UserSearchResultPreview = ({ user }) => (
<li className='search-results__item'>
<div className='search-results__icon'>👤</div>
{user.name ?? user.id}
</li>
);
const MessageSearchResultPreview = ({ message }) => (
<li className='search-results__item'>
<div className='search-results__icon'>💬</div>
{message.text}
</li>
);
const SearchResultsPreview = ({ results }) => {
if (results.items.length === 0) {
return <div class='search-results'>🤷♂️ No results</div>;
}
return (
<ul className='search-results'>
{results.entity === 'channel' &&
results.items.map((item) => <ChannelSearchResultPreview key={item.cid} channel={item} />)}
{results.entity === 'user' &&
results.items.map((item) => <UserSearchResultPreview key={item.id} user={item} />)}
{results.entity === 'message' &&
results.items.map((item) => <MessageSearchResultPreview key={item.id} message={item} />)}
</ul>
);
};
.search-results {
list-style: none;
padding: 0;
margin: 0;
}
.search-results__item {
padding-left: 30px;
}
.search-results__item:not(:last-child) {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #dbdde1;
}
.search-results__icon {
display: inline-block;
width: 30px;
margin-left: -30px;
}
What happens when you click on a search result depends on your desired user experience. If you click on a channel, it makes sense to set the channel as active. When clicking on a user, you may want to create or open a channel with a one-on-one conversation with the user. When clicking on a message, it’s probably expected that a relevant channel will be set as active and scrolled to the message.
import { useChatContext } from 'stream-chat-react';
const ChannelSearchResultPreview = ({ channel }) => {
const { setActiveChannel } = useChatContext();
return (
<li className='search-results__item' onClick={() => setActiveChannel(channel)}>
<div className='search-results__icon'>#️⃣</div>
{channel.data?.name}
</li>
);
};
import { useChatContext } from 'stream-chat-react';
const UserSearchResultPreview = ({ user }) => {
const { client, setActiveChannel } = useChatContext();
const handleClick = async () => {
const channel = client.channel('messaging', { members: [userId, user.id] });
await channel.watch();
setActiveChannel(channel);
};
return (
<li className='search-results__item' onClick={handleClick}>
<div className='search-results__icon'>👤</div>
{user.name ?? user.id}
</li>
);
};
import { useChatContext } from 'stream-chat-react';
const MessageSearchResultPreview = ({ message }) => {
const history = useHistory(); // bring your own router of choice
const { client, setActiveChannel } = useChatContext();
const handleClick = async () => {
if (message.channel) {
const channel = client.channel(message.channel.type, message.channel.id);
setActiveChannel(channel);
await channel.state.loadMessageIntoState(message.id);
history.replace(`${window.location.pathname}#${message.id}`);
}
};
return (
<li className='search-results__item' onClick={handleClick}>
<div className='search-results__icon'>💬</div>
{message.text}
</li>
);
};
// Somewhere in your application code:
const location = useLocation();
const messageId = useMemo(() => new URL(location).hash.slice(1), [location]);
<MessageList highlightedMessageId={messageId} />;
And that’s it! Here’s the complete code:
import { useChatContext } from 'stream-chat-react';
const CustomSearch = () => {
const { client } = useChatContext();
const [query, setQuery] = useState('');
const { results, pending, querySearchResults } = useSearchQuery();
// Use your favorite query library here 👆
const handleChannelSearchClick = async () => {
querySearchResults(async () => {
const channels = await client.queryChannels(
{
type: 'messaging',
name: { $autocomplete: query },
members: { $in: [userId] },
},
{ last_message_at: -1, updated_at: -1 },
{ limit: 5 },
);
return {
entity: 'channel',
items: channels,
};
});
};
const handleUserSearchClick = async () => {
querySearchResults(async () => {
const { users } = await client.queryUsers(
{
$or: [{ id: { $autocomplete: query } }, { name: { $autocomplete: query } }],
id: { $ne: userId },
},
{ id: 1, name: 1 },
{ limit: 5 },
);
return {
entity: 'user',
items: users,
};
});
};
const handleMessageSearchClick = async () => {
querySearchResults(async () => {
const { results } = await client.search(
{ type: 'messaging', members: { $in: [userId] } },
query,
{ limit: 5 },
);
return {
entity: 'message',
items: results.map((item) => item.message),
};
});
};
return (
<div className='search'>
<input
type='search'
className='search-input'
value={query}
placeholder='Search'
onChange={(event) => setQuery(event.target.value)}
/>
{query && (
<div className='search-actions'>
<button type='button' className='search-button' onClick={handleChannelSearchClick}>
#️⃣ Find <strong>"{query}"</strong> channels
</button>
<button type='button' className='search-button' onClick={handleUserSearchClick}>
👤 Find <strong>"{query}"</strong> users
</button>
<button type='button' className='search-button' onClick={handleMessageSearchClick}>
💬 Look up <strong>"{query}"</strong> in messages
</button>
</div>
)}
{pending && <>Searching...</>}
{results && <SearchResultsPreview results={results} />}
</div>
);
};
const ChannelSearchResultPreview = ({ channel }) => {
const { setActiveChannel } = useChatContext();
return (
<li className='search-results__item' onClick={() => setActiveChannel(channel)}>
<div className='search-results__icon'>#️⃣</div>
{channel.data?.name}
</li>
);
};
const UserSearchResultPreview = ({ user }) => {
const { client, setActiveChannel } = useChatContext();
const handleClick = async () => {
const channel = client.channel('messaging', { members: [userId, user.id] });
await channel.watch();
setActiveChannel(channel);
};
return (
<li className='search-results__item' onClick={handleClick}>
<div className='search-results__icon'>👤</div>
{user.name ?? user.id}
</li>
);
};
const MessageSearchResultPreview = ({ message }) => {
const history = useHistory(); // bring your own router of choice
const { client, setActiveChannel } = useChatContext();
const handleClick = async () => {
if (message.channel) {
const channel = client.channel(message.channel.type, message.channel.id);
setActiveChannel(channel);
await channel.state.loadMessageIntoState(message.id);
history.replace(`${window.location.pathname}#${message.id}`);
}
};
return (
<li className='search-results__item' onClick={handleClick}>
<div className='search-results__icon'>💬</div>
{message.text}
</li>
);
};
const SearchResultsPreview = ({ results }) => {
if (results.items.length === 0) {
return <>No results</>;
}
return (
<ul className='search-results'>
{results.entity === 'channel' &&
results.items.map((item) => <ChannelSearchResultPreview key={item.cid} channel={item} />)}
{results.entity === 'user' &&
results.items.map((item) => <UserSearchResultPreview key={item.id} user={item} />)}
{results.entity === 'message' &&
results.items.map((item) => <MessageSearchResultPreview key={item.id} message={item} />)}
</ul>
);
};
.search-input {
width: 100%;
border: 0;
border-radius: 10px;
background: #00000014;
font: inherit;
padding: 10px 15px;
}
.search-input::-webkit-search-cancel-button {
appearance: none;
}
.search-actions {
display: flex;
flex-direction: column;
margin: 10px 0 20px;
}
.search-button {
background: #00000014;
border: 0;
border-bottom: 1px solid #dbdde1;
padding: 10px 15px;
cursor: pointer;
}
.search-button:first-child {
border-radius: 10px 10px 0 0;
}
.search-button:last-child {
border-radius: 0 0 10px 10px;
border-bottom: 0;
}
.search-button:hover {
background: #dbdde1;
}
.search-results {
list-style: none;
padding: 0;
margin: 0;
}
.search-results__item {
padding-left: 30px;
}
.search-results__item:not(:last-child) {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #dbdde1;
}
.search-results__icon {
display: inline-block;
width: 30px;
margin-left: -30px;
}