Search as you type

Many websites implement search as you type for their search engine. Search as you type is a UX pattern that makes suggestions to the user while typing a search query and before she clicks the submit button.

The first example that comes to my mind is Google’s search bar:

Google search bar suggests results while typing 'search as you type' query

From Google’s examples, there are a few things to note:

  • While the user types text on the input, it computes suggestions based on the query and displays them below the input (often called a combobox).
  • When the user clicks outside the input, the suggestions disappear and reappear when the user focuses the input again.

Google’s search bar works well because results are computed and returned fast. It will make an HTTP request for each keystroke, even if the user corrects a typo in between. The response to these requests could take some time due to network latency.

Implementing a robust system against latencies shouldn’t be an afterthought; the example below takes it seriously. The consequences are the following:

  • We’ll debounce the request. The request will be started only 500ms after the user stops typing to reduce the number of requests being made.
  • If the user types in the input while a request is pending, the response will be ignored, and a new request will be made 500ms later.

Example

Try to type any character in the input. It will autocomplete based on its tiny database.

Type more characters while debouncing or fetching happens. Try to break the machine!

Code

View in GitHub
machine.ts
import { assign, setup, assertEvent, fromPromise } from "xstate";
/**
* Thanks ChatGPT 4 for this list of words!
*/
import itemsCollection from "./database.json";
const fetchAutocompleteItems = fromPromise<string[], { search: string }>(
async ({ input }) => {
await new Promise((resolve) => setTimeout(resolve, 500));
if (input.search === "") {
return [];
}
return itemsCollection.filter((item) =>
item.toLowerCase().startsWith(input.search.toLowerCase())
);
}
);
export const searchAsYouTypeMachine = setup({
types: {
events: {} as
| { type: "input.focus" }
| { type: "input.change"; searchInput: string }
| { type: "item.click"; itemId: number }
| { type: "item.mouseenter"; itemId: number }
| { type: "item.mouseleave" }
| { type: "combobox.click-outside" },
context: {} as {
searchInput: string;
activeItemIndex: number;
availableItems: string[];
lastFetchedSearch: string;
},
tags: "Display loader",
},
actions: {
"Assign search input to context": assign({
searchInput: ({ event }) => {
assertEvent(event, "input.change");
return event.searchInput;
},
}),
"Assign active item index to context": assign({
activeItemIndex: ({ event }) => {
assertEvent(event, "item.mouseenter");
return event.itemId;
},
}),
"Reset active item index into context": assign({
activeItemIndex: -1,
}),
"Assign selected item as current search input into context": assign({
searchInput: ({ context, event }) => {
assertEvent(event, "item.click");
return context.availableItems[event.itemId];
},
}),
"Assign last fetched search into context": assign({
lastFetchedSearch: ({ context }) => context.searchInput,
}),
"Reset available items in context": assign({
availableItems: [],
}),
},
guards: {
"Has search query been fetched": ({ context }) =>
context.searchInput === context.lastFetchedSearch,
},
actors: {
"Autocomplete search": fetchAutocompleteItems,
},
}).createMachine({
id: "Search as you type",
context: {
searchInput: "",
activeItemIndex: -1,
availableItems: [],
lastFetchedSearch: "",
},
initial: "Inactive",
states: {
Inactive: {
on: {
"input.focus": {
target: "Active",
},
},
},
Active: {
entry: "Reset active item index into context",
initial: "Checking if initial fetching is required",
states: {
"Checking if initial fetching is required": {
description: `After an item has been clicked and used as the new search query, we want to fetch its related results but only if they have not already been fetched.`,
always: [
{
guard: "Has search query been fetched",
target: "Idle",
},
{
target: "Fetching",
},
],
},
Idle: {},
Debouncing: {
after: {
500: {
target: "Fetching",
},
},
},
Fetching: {
tags: "Display loader",
invoke: {
src: "Autocomplete search",
input: ({ context }) => ({
search: context.searchInput,
}),
onDone: {
target: "Idle",
actions: [
assign({
availableItems: ({ event }) => event.output,
}),
"Assign last fetched search into context",
],
},
},
},
},
on: {
"input.change": {
target: ".Debouncing",
reenter: true,
actions: "Assign search input to context",
},
"combobox.click-outside": {
target: "Inactive",
},
"item.mouseenter": {
actions: "Assign active item index to context",
},
"item.mouseleave": {
actions: "Reset active item index into context",
},
"item.click": {
target: "Inactive",
actions: [
"Assign selected item as current search input into context",
/**
* We reset the available items when an item has been clicked because this is
* as if we were starting a new interaction session. An item has been selected
* and we don't want to see the previous and stale results when focus the input.
*/
"Reset available items in context",
],
},
},
},
},
});

Get news from XState by Example

Sign up for the newsletter to be notified when more machines or an interactive tutorial are released. I respect your privacy and will only send emails once in a while.