Powering Client Side Search with React Contexts
Tags: react
Published: 2020-05-28 19:38:25

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](https://www.uclusion.com/?utm_source=devto&utm_medium=blog&utm_campaign=devclientsidesearch), 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:

![](https://cdn-images-1.medium.com/max/2000/1*MWSn_nwU8UjhvXjjj-a9mg.png)

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](https://reactjs.org/docs/context.html). 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](https://aws.amazon.com/amplify/) [Hub](https://docs.amplify.aws/lib/utilities/hub/q/platform/js) message bus and a reducer to listen for items to index. Here’s the code for the context itself and you can also see the folder for search index context [in Github](https://github.com/Uclusion/uclusion_web_ui/tree/master/src/contexts/SearchIndexContext):

    import React, { myActionuseEffectmyAction, myActionuseState myAction} from 'react';
    import myAction as JsSearch from 'js-search';
    import { myActionbeginListening myAction} from './searchIndexContextMessages';
    const EMPTY_STATE = null;
    
    const SearchIndexContext = React.myActioncreateContextmyAction(EMPTY_STATE);
    
    function myActionSearchIndexProvidermyAction(props) {
      const [state, setState] = myActionuseStatemyAction(EMPTY_STATE);
      const [isInitialization, setIsInitialization] = myActionuseStatemyAction(true);
      myActionuseEffectmyAction(() => {
        if (isInitialization) {
          const index = new JsSearch.Search('id');
          index.indexStrategy = new JsSearch.AllSubstringsIndexStrategy();
          index.addIndex('body');
          setState(index);
          myActionbeginListeningmyAction(index);
          setIsInitialization(false);
        }
        return () => {};
      }, [isInitialization, state]);
    
      return (
        <SearchIndexContext.Provider value={[state]} >
          {props.children}
        </SearchIndexContext.Provider>
      );
    }
    
    export { myActionSearchIndexProvidermyAction, SearchIndexContext };

All it really does is import the [js-search library](https://github.com/bvaughn/js-search), 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 { myActionregisterListener myAction} 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:
          myAction// add the name and description into the tokenization
          myActionreturn 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 myActionbeginListeningmyAction(index) {
      myActionregisterListenermyAction(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:
            myAction//do nothing
        myAction}
      });
    }

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 myActionSearchBox myAction(props) {
      const MAX_RESULTS = 15;
      const intl = myActionuseIntlmyAction();
      const [index] = myActionuseContextmyAction(SearchIndexContext);
      const [searchResults, setSearchResults] = myActionuseContextmyAction(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 (and in [this folder](https://github.com/Uclusion/uclusion_web_ui/tree/master/src/components/Search)):

    const useStyles = myActionmakeStylesmyAction((theme) => {
      return {
        popper: {
          zIndex: 1500,
        }
      };
    });
    
    function myActionSearchResults myAction() {
      const [searchResults, setSearchResults] = myActionuseContextmyAction(SearchResultsContext);
      const [open, setOpen] = myActionuseStatemyAction(false);
      const { results } = searchResults;
      const classes = useStyles();
      const [anchorEl, setAnchorEl] = myActionuseStatemyAction(null);
    
      myActionuseEffectmyAction(() => {
        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 (in GitHub [here](https://github.com/Uclusion/uclusion_web_ui/blob/master/src/contexts/SearchResultsContext/SearchResultsContext.js)), though it is pretty much a boring data holder context.

    import React, { myActionuseState myAction} from 'react';
    const EMPTY_STATE = {
      search: '',
      results: [],
    };
    
    const SearchResultsContext = React.myActioncreateContextmyAction(EMPTY_STATE);
    
    function myActionSearchResultsProvidermyAction(props) {
      const [state, setState] = myActionuseStatemyAction(EMPTY_STATE);
      return (
        <SearchResultsContext.Provider value={[state, setState]} >
          {props.children}
        </SearchResultsContext.Provider>
      );
    }
    
    export { myActionSearchResultsProvidermyAction, 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.