Search Autocomplete Suggestions with GraphQL Query and React Hook

How to implement autocomplete suggestions from server on search field without crashing the server with thousands of GraphQL queries

May Chen
NEXL Engineering
5 min readSep 3, 2020

--

Search suggestion on input change

The problem

I was implementing a search suggestion on input change feature — so the user can see the suggested results as they type in the search field. On the dev side, what we want to do is to make a GraphQL query/API call to get the suggestions from server when the search term changes. But we also don’t want to make the queries/calls too often, imagine someone types really quick, we don’t want to hit the server with a thousand calls in one second, and the person probably only needs to see the suggestions when they stop typing, not as they type each character. So we want to have at least a 500 ms interval between each call.

So…how?

Step 1: query on search term update

First of all, we want to implement the part that we make a query to the server whenever the search term updates, regardless how much and frequently queries we send. Don’t worry about the performance for now.

This is how the search feature folder structure looks like. It’s just our convention to have a wrapper component (index.tsx) that has all the query related stuff, or the packages we use that we have no control of and we don’t want to test, and a UI component (SearchUI.tsx) that contains our business logic and we want to make sure working all the time with unit tests (SearchUI.test.tsx). And SearchUsers.network.gql is the GraphQL query we use to get the suggestions from the server.

folder structure

So, in the wrapper component, we have the search query hooks and a search term state, so whenever the term changes the query will get refetched. And we pass the query result and search term state and set state function into our UI component.

index.tsx

import React, { useState } from "react";
import { useSearchUserByNameQuery } from "javascript/__generated__/network_gql";
import { SearchUI } from "./SearchUI";
interface ISearchProps {}
export const Search: React.FC<ISearchProps> = ({}) => {
//search term state
const [searchTerm, setSearchTerm] = useState<string>("");
//query to get suggestions
const { data, loading } = useSearchUserByNameQuery({
variables: {
input: searchTerm,
},
});
return (
<SearchUI
loading={loading}
data={data} // search suggestions returned
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
);
};

And in our UI component, we have the suggestions as options to the <Autocomplete /> component, and we call setSearchTerm function on the autocomplete field change.

SearchUI.tsx

import React from "react";
import { TextField } from "@material-ui/core";
import Autocomplete from "@material-ui/lab/Autocomplete";
import { SearchUserByNameQueryResult } from "javascript/__generated__/network_gql";
interface ISearchUIProps {
loading: boolean;
data: SearchUserByNameQueryResult["data"];
searchTerm: string;
setSearchTerm: (term: string) => void;
}
export const SearchUI: React.FC<ISearchUIProps> = ({
data,
loading,
searchTerm,
setSearchTerm,
}) => {
return (
<Autocomplete
// suggestions return from query
options={data?.searchByUser?.userProfiles || []}
loading={loading} // query loading state
renderInput={(params) => (
<TextField
{...params}
value={searchTerm} //search term value
//update search term state on field change
onChange={(e) => setSearchTerm(e.target.value)}
/>
)}
/>
);
};

It works. The only thing is, as you type in “g”, “r”, “s”, whoops…wrong, backspace, “a”, “n”, “t”, it’s making 7 graphql queries to the server even though you most likely only want to know the last one.

screenshot of the numbers of the graphql queries
too many graphql queries!

Step 2: debounce it

We probably don’t want to hit the server with thousands of queries that we don’t actually need, especially don’t want to bring the app down when there’re thousands of users online at the same time. Ideally, we only want to make one query after the user typed “grs, backspace, ant” and stopped, and return the result for keyword “grant”. So, we want to debounce the search term update function so as to make the query less frequently.

In the wrapper component, we introduce the debounce function from lodash and pass the debounced search term update function to the UI component. No changes to the UI component.

index.tsx

import React, { useState } from "react";
import { useSearchUserByNameQuery } from "javascript/__generated__/network_gql";
import { SearchUI } from "./SearchUI";
import debounce from "lodash/debounce";
interface ISearchProps {}
export const Search: React.FC<ISearchProps> = ({}) => {
const [searchTerm, setSearchTerm] = useState<string>("");
const { data, loading } = useSearchUserByNameQuery({
variables: {
input: searchTerm,
},
});
const setSearchTermDebounced = debounce(setSearchTerm, 500); return (
<SearchUI
loading={loading}
data={data}
searchTerm={searchTerm}
setSearchTerm={setSearchTermDebounced}
/>
);
};

It works… it does reduce the number of queries to the server to get the suggestions. But we will soon realise, it affects user experience on the search really badly as there’s a lag between you type on the keyboard and see the changes on the screen. That’s because by debouncing the search term update, it delays the search term value update and it reflects on the UI.

That’s not good. But we can fix it by adding another state to the UI component to store the term that reflects on the UI while keeping the debounced term for the query only.

So, in the wrapper component, we have a state searchTerm for the QraphQL query and the same as before we pass the debounced update function into the UI component.

index.tsx

import React, { useState } from "react";
import { useSearchUserByNameQuery } from "javascript/__generated__/network_gql";
import { SearchUI } from "./SearchUI";
import debounce from "lodash/debounce";
interface ISearchProps {}
export const Search: React.FC<ISearchProps> = ({}) => {
const [searchTerm, setSearchTerm] = useState<string>("");
const { data, loading } = useSearchUserByNameQuery({
variables: {
input: searchTerm,
},
});
const setSearchTermDebounced = debounce(setSearchTerm, 500);return (
<SearchUI
loading={loading}
data={data}
initialTerm={searchTerm}
updateSearchTerm={setSearchTermDebounced}
/>
);
};

And in the UI component, we keep another state so we can update term that’s visible to the users immediately when something changes. And on input change, we update both the state in UI component and the state in the wrapper component.

SearchUI.tsx

import React, { useState } from "react";
import { TextField } from "@material-ui/core";
import Autocomplete from "@material-ui/lab/Autocomplete";
import { SearchUserByNameQueryResult } from "javascript/__generated__/network_gql";
interface ISearchUIProps {
loading: boolean;
data: SearchUserByNameQueryResult["data"];
initialTerm: string;
updateSearchTerm: (term: string) => void;
}
export const SearchUI: React.FC<ISearchUIProps> = ({
data,
loading,
initialTerm,
updateSearchTerm,
}) => {
const [term, setTerm] = useState(initalTerm);
return (
<Autocomplete
options={data?.searchByUser?.userProfiles || []}
loading={loading}
renderInput={(params) => (
<TextField
{...params}
value={term}
onChange={(e) => {
updateSearchTerm(e.target.value);
setTerm(e.target.value);
}}
/>
)}
/>
);
};

That’s it. Let me know in the comment if you have any questions, I’ll try my best to help =)

--

--

May Chen
NEXL Engineering

A developer who occasionally has existential crisis and thinks if we are heading to the wrong direction, technology is just getting us there sooner.