Powering Client Side Search with React Contexts

Ben Follis
Uclusion
Published in
4 min readMay 26, 2020

In this article, I’m going to talk about Client Side Search, and how to integrate a client search library into your React Contexts.

For those unaware, Client Side Search is a search system where the search index is created, and used entirely in the user’s Web Browser. It’s essentially zero latency, and scales infinitely with your users.

Uclusion, has a search box on it’s main screen that allows a user to search through any data that pertains to a Workspace, Dialog, or Initiative that they are currently participating in. The results are displayed as a series of items that they can click on:

To make this all work we have to get data into the index, and present the contents of their search no matter what page they happen to be accessing the search from. Enter React Contexts. We make use of two contexts, the first holds the index, and the second holds the contents of the search, and state of the users search query. These are called the SearchIndexContext and SearchResultsContext respectively.

The SearchIndexContext uses the Amplify Hub message bus and a reducer to listen for items to index. Here’s the code for the context itself:

import React, { useEffect, useState } from 'react';
import * as JsSearch from 'js-search';
import { beginListening } from './searchIndexContextMessages';
const EMPTY_STATE = null;

const SearchIndexContext = React.createContext(EMPTY_STATE);

function SearchIndexProvider(props) {
const [state, setState] = useState(EMPTY_STATE);
const [isInitialization, setIsInitialization] = useState(true);
useEffect(() => {
if (isInitialization) {
const index = new JsSearch.Search('id');
index.indexStrategy = new JsSearch.AllSubstringsIndexStrategy();
index.addIndex('body');
setState(index);
beginListening(index);
setIsInitialization(false);
}
return () => {};
}, [isInitialization, state]);

return (
<SearchIndexContext.Provider value={[state]} >
{props.children}
</SearchIndexContext.Provider>
);
}

export { SearchIndexProvider, SearchIndexContext };

All it really does is import the js-search library, make sure we’re not rebuilding the index every page load, and sets the index to use an all substring matching strategy so that prefix searches return sufficient results. The real meat of the indexer is in the message bus listener, here:

import { registerListener } from '../../utils/MessageBusUtils';

export const SEARCH_INDEX_CHANNEL = 'SEARCH_INDEX_CHANNEL';
export const INDEX_UPDATE = 'INDEX_UPDATE';
export const INDEX_COMMENT_TYPE = 'COMMENT';
export const INDEX_INVESTIBLE_TYPE = 'INVESTIBLE';
export const INDEX_MARKET_TYPE = 'MARKET';


function getBody(itemType, item) {
switch (itemType) {
case INDEX_COMMENT_TYPE:
return item.body;
case INDEX_INVESTIBLE_TYPE:
case INDEX_MARKET_TYPE:
// add the name and description into the tokenization
return item.description + " " + item.name;
default:
return ""
}
}

function transformItemsToIndexable(itemType, items){
return items.map((item) => {
const { id, market_id: marketId } = item;
return {
type: itemType,
body: getBody(itemType, item),
id,
marketId,
}
});
}

export function beginListening(index) {
registerListener(SEARCH_INDEX_CHANNEL, 'indexUpdater', (data) => {
const { payload: { event, itemType, items }} = data;
switch (event){
case INDEX_UPDATE:
const indexable = transformItemsToIndexable(itemType, items);
index.addDocuments(indexable);
break;
default:
//do nothing
}
});
}

That code sets up a bus listener, and whenever it gets something coming in transforms it to what the index can understand, then incrementally indexes it.

Now that we’ve got something that can index data as it arrives, how do we actually search against it? Enter the SearchBox UI element, which imports the SearchIndexContext, and manipulates the SearchResultsContext. The code for the SearchBox is here:

function SearchBox (props) {
const MAX_RESULTS = 15;
const intl = useIntl();
const [index] = useContext(SearchIndexContext);
const [searchResults, setSearchResults] = useContext(SearchResultsContext);
function onSearchChange (event) {
const { value } = event.target;
const results = index.search(value), MAX_RESULTS);
setSearchResults({
search: value,
results
});
}

return (
<div id='search-box'>
<TextField
onChange={onSearchChange}
value={searchResults.search}
placeholder={intl.formatMessage({ id: 'searchBoxPlaceholder' })}
variant="outlined"
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</div>
);
}

Now we have the SearchResultsContext being properly manipulated to the users’ search query, and the result of the query. To render the results, we need the SearchResults UI component, which overlays itself on top of the current UI whenever the SearchResultsContext contains an non-empty searc results. It’s code is here:

const useStyles = makeStyles((theme) => {
return {
popper: {
zIndex: 1500,
}
};
});

function SearchResults () {
const [searchResults, setSearchResults] = useContext(SearchResultsContext);
const [open, setOpen] = useState(false);
const { results } = searchResults;
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState(null);

useEffect(() => {
if (_.isEmpty(anchorEl)) {
setAnchorEl(document.getElementById('search-box'));
}
const shouldBeOpen = !_.isEmpty(results);
setOpen(shouldBeOpen);

}, [setAnchorEl, anchorEl, results]);

function zeroResults () {
setSearchResults({
search: '',
results: []
});
}

function getSearchResult (item) {
const { id, type, marketId } = item;
if (type === INDEX_COMMENT_TYPE) {
return (<CommentSearchResult marketId={marketId} commentId={id}/>);
}
if (type === INDEX_INVESTIBLE_TYPE) {
return (<InvestibleSearchResult investibleId={id}/>);
}
if (type === INDEX_MARKET_TYPE) {
return (<MarketSearchResult marketId={id}/>);
}
}

function getResults () {
return results.map((item) => {
const { id } = item;
return (
<ListItem
key={id}
button
onClick={zeroResults}
>
{getSearchResult(item)}
</ListItem>
);
});
}

const placement = 'bottom';

return (
<Popper
open={open}
id="search-results"
anchorEl={anchorEl}
placement={placement}
className={classes.popper}
>
<Paper>
<List
dense
>
{getResults()}
</List>
</Paper>
</Popper>
);
}

For completeness, here’s the code of the SearchResultsContext, though it is pretty much a boring data holder context.

import React, { useState } from 'react';
const EMPTY_STATE = {
search: '',
results: [],
};

const SearchResultsContext = React.createContext(EMPTY_STATE);

function SearchResultsProvider(props) {
const [state, setState] = useState(EMPTY_STATE);
return (
<SearchResultsContext.Provider value={[state, setState]} >
{props.children}
</SearchResultsContext.Provider>
);
}

export { SearchResultsProvider, SearchResultsContext };

That’s all the code there is. With just those 4 components, Uclusion is able to offer a search experience that is immediately responsive, always in sync with the user’s view, and updated live as new data comes in.

--

--