DEV Community

David Israel for Uclusion

Posted on • Edited on

Powering Client Side Search with React Contexts

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 and you can also see the folder for search index context in Github:

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 };
Enter fullscreen mode Exit fullscreen mode

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 { 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}
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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):

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

For completeness, here’s the code of the SearchResultsContext (in GitHub here), 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 };
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)