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.

Conversation
  • Ionwyn Sean says:

    I don’t usually write comments after reading a guide/tutorial post. In fact, this is my first time, and I’m doing so because this is, by far, the most technically sophisticated tutorial I could find out there. Combined with good writing and code clarity, this is great stuff (kudos Rachel!).

    Too many guides elsewhere don’t suit my needs, but this one has perfectly captured mine. I am currently developing a React app with Redux, Apollo, and TypeScript, and Lodash (and other stacks, obviously).

    Thank you! *on to reading other articles*

    • Rachael McQuater Rachael McQuater says:

      Thanks, Ionwyn! I’m so thrilled to hear it was helpful. One of my biggest concerns writing this post was whether there was even anyone on earth who might happen to need to do something exactly like this, in this exact stack. It’s good to know that there is! It’d be interesting to trade notes on what working with React/Apollo/TypeScript has been like for you- we’ve been using this stack extensively at Atomic and it’s been working out really well for us.

  • Ionwyn Sean says:

    Hey, Rachael.

    Haha, I think I should be the one taking notes from you, as I’m still relatively new to the stack!

    Adopting TypeScript to React was not much of a problem having studied C++ (though TypeScript looks more like C# to me), and now I believe it has improved my development process overall. Combined with this awesome cheatsheet (https://github.com/sw-yx/react-typescript-cheatsheet), no problem.

    I still use Redux with Apollo because I’m working on what I think is a medium-sized web app where local data management and debugging can get complicated. I’m still unsure why Apollo decided to remove Redux in 2.0, while I started with Apollo 2.0, it seemed promising to be able to access Apollo data through Redux. That said, I’m putting my money on apollo-link-state in the future. At the moment, there’s absolutely no way I’m using apollo-link-state until it matures.

    I think the most painful part was getting AWS Lambda to work with Apollo. But then again, most documentations for AWS Services are counterintuitive :)

    I hope to see more interesting articles like this from you and the team at Atomic!

  • Comments are closed.