Efficient Search Autocomplete with React-Redux & Apollo

The application I’m working on right now has a search box that makes suggestions as the user types and does quick, inline searches to provide extra-fast results. Yesterday, I talked about how we improve our timing with debouncing. Today I’ll dive into the technical details of how we built the autocomplete behavior using ReactRedux and Apollo.

Implementation

We’re working with a React-Redux front end connected to a GraphQL server via Apollo, in TypeScript. If you’re not familiar with TypeScript, the below should still be fairly readable; if you’re not familiar with the other technologies mentioned, it probably won’t be. If all of the above sounded like a nerdy word-salad, check out my friend Drew’s post on the TypeScript/GraphQL/ReactJS stack.

The application is made up of React components, which take advantage of react-apollo to drive their props from the result of a GQL call on render. React components re-render when their props change, so react-apollo connected components automatically rerun their queries every time they get new props.

Our search box component needs to make a GQL query, but an input component gets new props with every key entered. As mentioned above, we don’t want to run the autosuggestion query tens of times in a second, and we certainly don’t want to render the autosuggestion text once for every new result that comes back.

To debounce the query calls effectively, we need more fine-grained control of when the query is made. An early attempt at this involved tinkering with the shouldComponentUpdate function of the component, but because it needs to update some things on every keystroke, this got hairy quickly. So, we departed from our usual react-apollo pattern that automatically makes queries on render, and built a function that we could debounce.

The Query

To start, we have an AutoSuggestingSearchBox component that takes in its props, among other things, a handle to our ApolloClient. It creates a lambda, getAutoSuggestion, that closes over that client and makes the autosuggestion query for a term in the search box. It looks something like this:

function AutoSuggestingSearchBox(
  props: {
    client: ApolloClient;
  } & OtherProps
) {
  const getAutoSuggestion = async (
    term: string
  ): Promise<string | undefined> => {
    const query = require("./autosuggest.graphql");
    const results = await props.client.query<AutoSuggestQuery>({
      query,
      variables: {
        term
      }
    });

    return results.data.searchSuggestion;
  };

  return (
    <SearchBox
      getAutoSuggestion={getAutoSuggestion}
      {...props}
    />
  );
}

Okay, so we have a function that gets an autosuggestion for a given term. Now, when do we call it? You’ll see above that the SearchBox component is taking that getAutoSuggestion function that we just made. Let’s see what it does with that.

Actions and State

We keep the current autoSuggestion string in part of our Redux store:

export interface SearchboxState {
  enteredText: string;
  suggestion?: string;
    [...]
}

So, in Redux style, we want to dispatch an action that makes the query and updates that state when we get a keypress. The autoSuggestion query is an async function, so this action needs to be asynchronous.

We use thunk for this, but there’s more than one way to skin that cat. The async action we’re going to dispatch looks like this:

export function queryUpdated(
  text: string,
  getAutoSuggestion: (text: string) => Promise<string | undefined>
) {
  return async (dispatch: Dispatch<any>) => {
    let suggestion = undefined;
    try {
      if (text) {
        suggestion = await getAutoSuggestion(text);
      }
    } catch (e) {
      suggestion = undefined;
    }
    dispatch(updateSuggestion(suggestion)); //* See below
  };
} 

* We’re using the action builder pattern described here. Dispatch an action however you like to do so. Our suggestionUpdated action updates the state of the SearchBox with a new suggestion string.

Debouncin’ It

You’ll note that we still haven’t gotten to the debouncing part! We’ll do this inside our mapDispatchToProps function on the SearchBox component. We’ll create a function that closes over our dispatch and dispatches our new thunk, and debounce /that/.
Here’s what it looks like:

interface ExternalProps {
  [...]
  getAutoSuggestion: (searchText: string) => Promise<string | undefined>;
}

[...]

function mapDispatchToProps(
  dispatch: Dispatch<any>,
  ownProps: ExternalProps
): DispatchProps {
  const updateAutoSuggestion = debounce((text: string) => {
    dispatch(
      Actions.queryUpdated(
        text,
        ownProps.getAutoSuggestion
      )
    );
  }, 100);

  return {
    onSearchTextChanged: updateAutoSuggestion,
    [...]
  };
}

The operative lesson here is that we’re debouncing the dispatching of the action that makes the query, not the query itself. This protects us from accidentally tinkering with the state when we end up throwing out a query, and it means that the presentational component for the SearchBox now has a handle to a single function to call every time the text is updated—nice and tidy.