UI Libraries / Autocomplete / Using Autocomplete with InstantSearch

Using Autocomplete with InstantSearch

When you think of search experiences on sites like Amazon (ecommerce) or YouTube (media), you may notice that both sites use an autocomplete experience. It’s the autocomplete, and not just a search input, that powers the search page.

If you have an existing InstantSearch implementation, you can create a similar experience by adding Autocomplete to your InstantSearch application. Adding Autocomplete to an existing InstantSearch implementation lets you enhance the search experience and create a richer, more contextual search. You can use context from the current user and how they interacted with your site, save their recent searches, provide suggested queries, and more. This autocomplete can work as a rich search box in a search page, and a portable all-in-one search experience anywhere else on your site.

This guide shows you how to integrate Autocomplete with InstantSearch on your site.

Preview

This guide starts from a brand new InstantSearch application, but you can adapt it to integrate Autocomplete in your existing implementation.

Creating a search page with InstantSearch

First, begin with some boilerplate for the InstantSearch implementation. In a new project, create an index.html file and add the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link
      rel="stylesheet"
      href="https://unpkg.com/instantsearch.css@7/themes/satellite-min.css"
    />
    <title>InstantSearch | Autocomplete</title>
  </head>

  <body>
    <div class="container">
      <div>
        <div id="categories"></div>
      </div>
      <div>
        <div id="searchbox"></div>
        <div id="hits"></div>
        <div id="pagination"></div>
      </div>
    </div>

    <script src="app.js"></script>
  </body>
</html>

The search page uses a two-column layout with categories on the left, and a search box, hits, and a pagination widget on the right.

Next, create the app.js file to instantiate InstantSearch.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import algoliasearch from 'algoliasearch/lite'
import instantsearch from 'instantsearch.js'
import historyRouter from 'instantsearch.js/es/lib/routers/history'
import {
  searchBox,
  hierarchicalMenu,
  hits,
  pagination,
} from 'instantsearch.js/es/widgets'

import '@algolia/autocomplete-theme-classic'

const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76'
)
const INSTANT_SEARCH_INDEX_NAME = 'instant_search'
const instantSearchRouter = historyRouter()
const search = instantsearch({
  searchClient,
  indexName: INSTANT_SEARCH_INDEX_NAME,
  routing: instantSearchRouter,
})

search.addWidgets([
  searchBox({
    container: '#searchbox',
    placeholder: 'Search for products',
  }),
  hierarchicalMenu({
    container: '#categories',
    attributes: ['hierarchicalCategories.lvl0', 'hierarchicalCategories.lvl1'],
  }),
  hits({
    container: '#hits',
  }),
  pagination({
    container: '#pagination',
  }),
])

search.start()

This InstantSearch implementation imports the default historyRouter router which you want to reuse in your Autocomplete integration. This lets InstantSearch understand query parameters from the URL to derive its state.

If your Algolia index doesn’t have hierarchical attributes, you can use a regular attribute with a menu widget.

You should now have a working InstantSearch application.

InstantSearch ships with a searchBox component, but it doesn’t provide autocomplete features like you see on YouTube and Amazon. Instead, you can replace the searchBox with an autocomplete experience, using Autocomplete.

Start by removing the InstantSearch #searchbox container and adding the Autocomplete container, named #autocomplete here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link
      rel="stylesheet"
      href="https://unpkg.com/instantsearch.css@7/themes/satellite-min.css"
    />
    <title>InstantSearch | Autocomplete</title>
  </head>

  <body>
    <header class="header">
      <div id="autocomplete"></div>
    </header>

    <div class="container">
      <div>
        <div id="categories"></div>
      </div>
      <div>
        <div id="hits"></div>
        <div id="pagination"></div>
      </div>
    </div>

    <script src="app.js"></script>
  </body>
</html>

You want the Autocomplete experience to be available on all the pages of the site, not only the search page, so you can move its container to a header or navigation bar instead of the page content.

Next, you can virtualize the searchBox from your InstantSearch implementation and instantiate Autocomplete in your app.js file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import { autocomplete } from '@algolia/autocomplete-js'
import algoliasearch from 'algoliasearch/lite'
import instantsearch from 'instantsearch.js'
import historyRouter from 'instantsearch.js/es/lib/routers/history'
import { connectSearchBox } from 'instantsearch.js/es/connectors'
import { hierarchicalMenu, hits, pagination } from 'instantsearch.js/es/widgets'

import '@algolia/autocomplete-theme-classic'

const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76'
)

const INSTANT_SEARCH_INDEX_NAME = 'instant_search'
const instantSearchRouter = historyRouter()

const search = instantsearch({
  searchClient,
  indexName: INSTANT_SEARCH_INDEX_NAME,
  routing: instantSearchRouter,
})

// Mount a virtual search box to manipulate InstantSearch's `query` UI
// state parameter.
const virtualSearchBox = connectSearchBox(() => {})

search.addWidgets([
  virtualSearchBox({}),
  hierarchicalMenu({
    container: '#categories',
    attributes: ['hierarchicalCategories.lvl0', 'hierarchicalCategories.lvl1'],
  }),
  hits({
    container: '#hits',
    templates: {
      item:
        '<div>{{#helpers.highlight}}{ "attribute": "name" }{{/helpers.highlight}}</div>',
    },
  }),
  pagination({
    container: '#pagination',
  }),
])

search.start()

// Set the InstantSearch index UI state from external events.
function setInstantSearchUiState(indexUiState) {
  search.setUiState(uiState => ({
    ...uiState,
    [INSTANT_SEARCH_INDEX_NAME]: {
      ...uiState[INSTANT_SEARCH_INDEX_NAME],
      // We reset the page when the search state changes.
      page: 1,
      ...indexUiState,
    },
  }))
}

// Return the InstantSearch index UI state.
function getInstantSearchUiState() {
  const uiState = instantSearchRouter.read()

  return (uiState && uiState[INSTANT_SEARCH_INDEX_NAME]) || {}
}

const searchPageState = getInstantSearchUiState()

autocomplete({
  container: '#autocomplete',
  placeholder: 'Search for products',
  detachedMediaQuery: 'none',
  initialState: {
    query: searchPageState.query || '',
  },
  onSubmit({ state }) {
    setInstantSearchUiState({ query: state.query })
  },
  onReset() {
    setInstantSearchUiState({ query: '' })
  },
  onStateChange({ prevState, state }) {
    if (prevState.query !== state.query) {
      setInstantSearchUiState({ query: state.query })
    }
  },
})

This replaces the InstantSearch search box with Autocomplete, and acts exactly like before. But you can now add many more interesting features.

Adding recent searches

When you make a search on YouTube or Google and come back to the search box later on, the autocomplete displays your recent searches. This pattern lets users quickly access content by using the same path they took to find it in the first place.

Autocomplete lets you add recent searches via the @algolia/autocomplete-plugin-recent-searches package. It exposes a createLocalStorageRecentSearchesPlugin function to let you create a recent searches plugin.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/** @jsx h */
import { autocomplete } from '@algolia/autocomplete-js'
import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches'
import { h } from 'preact'

// ...

// Build URLs that InstantSearch understands.
function getInstantSearchUrl(indexUiState) {
  return search.createURL({ [INSTANT_SEARCH_INDEX_NAME]: indexUiState })
}

// Detect when an event is modified with a special key to let the browser
// trigger its default behavior.
function isModifierEvent(event) {
  const isMiddleClick = event.button === 1

  return (
    isMiddleClick ||
    event.altKey ||
    event.ctrlKey ||
    event.metaKey ||
    event.shiftKey
  )
}

function onSelect({ setIsOpen, setQuery, event, query }) {
  // You want to trigger the default browser behavior if the event is modified.
  if (isModifierEvent(event)) {
    return
  }

  setQuery(query)
  setIsOpen(false)
  setInstantSearchUiState({ query })
}

function getItemUrl({ query }) {
  return getInstantSearchUrl({ query })
}

function ItemWrapper({ children, query }) {
  const uiState = { query }

  return (
    <a
      className="aa-ItemLink"
      href={getInstantSearchUrl(uiState)}
      onClick={event => {
        if (!isModifierEvent(event)) {
          // Bypass the original link behavior if there's no event modifier
          // to set the InstantSearch UI state without reloading the page.
          event.preventDefault()
        }
      }}
    >
      {children}
    </a>
  )
}

const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({
  key: 'instantsearch',
  limit: 3,
  transformSource({ source }) {
    return {
      ...source,
      getItemUrl({ item }) {
        return getItemUrl({
          query: item.label,
        })
      },
      onSelect({ setIsOpen, setQuery, item, event }) {
        onSelect({
          setQuery,
          setIsOpen,
          event,
          query: item.label,
        })
      },
      // Update the default `item` template to wrap it with a link
      // and plug it to the InstantSearch router.
      templates: {
        ...source.templates,
        item(params) {
          const { children } = source.templates.item(params).props

          return <ItemWrapper query={params.item.label}>{children}</ItemWrapper>
        },
      },
    }
  },
})

autocomplete({
  // You want recent searches to appear with an empty query.
  openOnFocus: true,
  // Add the recent searches plugin.
  plugins: [recentSearchesPlugin],
  // ...
})

Note that some of this code is abstracted away in the following sections to simplify the examples.

Since the recentSearchesPlugin reads from localStorage, you can’t see any recent searches until you perform at least one query. To submit a search, make sure to press Enter on the query. Once you do, you’ll see it appear as a recent search.

Adding Query Suggestions

The most typical pattern you can see on every autocomplete is suggestions. They’re predictions of queries that match what the user is currently typing, and that guarantee to return results. For example, when typing “how to” in Google, the search engine suggests matching suggestions for the user to complete their query. It’s especially useful on mobile, where typing is harder than on a physical keyboard.

Autocomplete lets you add Query Suggestions with the @algolia/autocomplete-plugin-query-suggestions package. It exposes a createQuerySuggestionsPlugin function to let you create a Query Suggestions plugin.

This plugin requires a Query Suggestions index.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// ...
import { createQuerySuggestionsPlugin } from '@algolia/autocomplete-plugin-query-suggestions'
// ...

const querySuggestionsPlugin = createQuerySuggestionsPlugin({
  searchClient,
  indexName: 'instant_search_demo_query_suggestions',
  getSearchParams() {
    // This creates a shared `hitsPerPage` value once the duplicates
    // between recent searches and Query Suggestions are removed.
    return recentSearchesPlugin.data.getAlgoliaSearchParams({
      hitsPerPage: 6,
    })
  },
  transformSource({ source }) {
    return {
      ...source,
      sourceId: 'querySuggestionsPlugin',
      getItemUrl({ item }) {
        return getItemUrl({
          query: item.query,
        })
      },
      onSelect({ setIsOpen, setQuery, event, item }) {
        onSelect({
          setQuery,
          setIsOpen,
          event,
          query: item.query,
        })
      },
      getItems(params) {
        // We don't display Query Suggestions when there's no query.
        if (!params.state.query) {
          return []
        }

        return source.getItems(params)
      },
      templates: {
        ...source.templates,
        item(params) {
          const { children } = source.templates.item(params).props

          return <ItemWrapper query={params.item.query}>{children}</ItemWrapper>
        },
      },
    }
  },
})

autocomplete({
  // ...
  // Add the recent searches and Query Suggestions plugins.
  plugins: [recentSearchesPlugin, querySuggestionsPlugin],
})

Debouncing search results

Having two sets of results update as you type generates many UI flashes. This is distracting to the user, because two distinct sections of the page are competing for their attention.

You can mitigate this problem by debouncing search results.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function debounce(fn, time) {
  let timerId = undefined

  return function(...args) {
    if (timerId) {
      clearTimeout(timerId)
    }

    timerId = setTimeout(() => fn(...args), time)
  }
}

const debouncedSetInstantSearchUiState = debounce(setInstantSearchUiState, 500)

autocomplete({
  // ...
  onStateChange({ prevState, state }) {
    if (prevState.query !== state.query) {
      debouncedSetInstantSearchUiState({ query: state.query })
    }
  },
})

Supporting categories in Query Suggestions

A key feature to Autocomplete is to pre-configure your InstantSearch page. The Query Suggestions plugin supports categories that you can leverage to refine both the query and the category in a single interaction. This pattern brings users to the right category without interacting with the hierarchicalMenu widget, only with the autocomplete.

First, you need to support categories in the helpers you created at the beginning.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function onSelect({ setIsOpen, setQuery, event, query, category }) {
  // You want to trigger the default browser behavior if the event is modified.
  if (isModifierEvent(event)) {
    return
  }

  setQuery(query)
  setIsOpen(false)
  setInstantSearchUiState({
    query,
    hierarchicalMenu: {
      [INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [category],
    },
  })
}

function getItemUrl({ query, category }) {
  return getInstantSearchUrl({
    query,
    hierarchicalMenu: {
      [INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [category],
    },
  })
}

function ItemWrapper({ children, query, category }) {
  const uiState = {
    query,
    hierarchicalMenu: {
      [INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [category],
    },
  }

  return (
    <a
      className="aa-ItemLink"
      href={getInstantSearchUrl(uiState)}
      onClick={event => {
        if (!isModifierEvent(event)) {
          // Bypass the original link behavior if there's no event modifier
          // to set the InstantSearch UI state without reloading the page.
          event.preventDefault()
        }
      }}
    >
      {children}
    </a>
  )
}

Then, you can update the plugins to forward the category to these helpers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({
  key: 'instantsearch',
  limit: 3,
  transformSource({ source }) {
    return {
      ...source,
      getItemUrl({ item }) {
        return getItemUrl({
          query: item.label,
          category: item.category,
        })
      },
      onSelect({ setIsOpen, setQuery, item, event }) {
        onSelect({
          setQuery,
          setIsOpen,
          event,
          query: item.label,
          category: item.category,
        })
      },
      templates: {
        ...source.templates,
        // Update the default `item` template to wrap it with a link
        // and plug it to the InstantSearch router.
        item(params) {
          const { children } = source.templates.item(params).props

          return (
            <ItemWrapper
              query={params.item.label}
              category={params.item.category}
            >
              {children}
            </ItemWrapper>
          )
        },
      },
    }
  },
})

const querySuggestionsPlugin = createQuerySuggestionsPlugin({
  searchClient,
  indexName: 'instant_search_demo_query_suggestions',
  getSearchParams() {
    // This creates a shared `hitsPerPage` value once the duplicates
    // between recent searches and Query Suggestions are removed.
    return recentSearchesPlugin.data.getAlgoliaSearchParams({
      hitsPerPage: 6,
    })
  },
  // Add categories to the suggestions.
  categoryAttribute: [
    INSTANT_SEARCH_INDEX_NAME,
    'facets',
    'exact_matches',
    INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE,
  ],
  transformSource({ source }) {
    return {
      ...source,
      sourceId: 'querySuggestionsPlugin',
      getItemUrl({ item }) {
        return getItemUrl({
          query: item.query,
          category: item.__autocomplete_qsCategory,
        })
      },
      onSelect({ setIsOpen, setQuery, event, item }) {
        onSelect({
          setQuery,
          setIsOpen,
          event,
          query: item.query,
          category: item.__autocomplete_qsCategory,
        })
      },
      getItems(params) {
        if (!params.state.query) {
          return []
        }

        return source.getItems(params)
      },
      templates: {
        ...source.templates,
        item(params) {
          const { children } = source.templates.item(params).props

          return (
            <ItemWrapper
              query={params.item.query}
              category={params.item.__autocomplete_qsCategory}
            >
              {children}
            </ItemWrapper>
          )
        },
      },
    }
  },
})

Finally, you can implement the onReset function on your Autocomplete instance to also reset the InstantSearch category.

1
2
3
4
5
6
7
8
9
10
11
12
autocomplete({
  // ...
  onReset() {
    setInstantSearchUiState({
      query: '',
      hierarchicalMenu: {
        [INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [],
      },
    })
  },
  // ...
})

Adding contextual Query Suggestions

For an even richer Autocomplete experience, you can pick up the currently active InstantSearch category and provide suggestions for both this specific category and others. This pattern lets you reduce the scope of the search to the current category, like an actual department store, or broaden the suggestions to get out of the current category.

Query Suggestions with current InstantSearch category

First, make sure to set your category attribute as a facet in your Query Suggestions index. In this demo, the attribute to facet is instant_search.facets.exact_matches.hierarchicalCategories.lvl0.value.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/** @jsx h */
import { h, Fragment } from 'preact'

// ...

// Get the current category from InstantSearch.
function getInstantSearchCurrentCategory() {
  const indexRenderState = search.renderState[INSTANT_SEARCH_INDEX_NAME]
  const hierarchicalMenuUiState =
    indexRenderState && indexRenderState.hierarchicalMenu
  const categories =
    (hierarchicalMenuUiState &&
      hierarchicalMenuUiState[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE] &&
      hierarchicalMenuUiState[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE].items) ||
    []
  const refinedCategory = categories.find(category => category.isRefined)

  return refinedCategory && refinedCategory.value
}

// Query Suggestions plugin for the current category.
const querySuggestionsPluginInCategory = createQuerySuggestionsPlugin({
  searchClient,
  indexName: 'instant_search_demo_query_suggestions',
  getSearchParams() {
    const currentCategory = getInstantSearchCurrentCategory()

    return recentSearchesPlugin.data.getAlgoliaSearchParams({
      hitsPerPage: 3,
      facetFilters: [
        `${INSTANT_SEARCH_INDEX_NAME}.facets.exact_matches.${INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE}.value:${currentCategory}`,
      ],
    })
  },
  transformSource({ source }) {
    const currentCategory = getInstantSearchCurrentCategory()

    return {
      ...source,
      sourceId: 'querySuggestionsPluginInCategory',
      getItemUrl({ item }) {
        return getItemUrl({
          query: item.query,
          category: currentCategory,
        })
      },
      onSelect({ setIsOpen, setQuery, event, item }) {
        onSelect({
          setQuery,
          setIsOpen,
          event,
          query: item.query,
          category: currentCategory,
        })
      },
      getItems(params) {
        if (!currentCategory) {
          return []
        }

        return source.getItems(params)
      },
      templates: {
        ...source.templates,
        header({ items }) {
          if (items.length === 0) {
            return null
          }

          return (
            <Fragment>
              <span className="aa-SourceHeaderTitle">In {currentCategory}</span>
              <div className="aa-SourceHeaderLine" />
            </Fragment>
          )
        },
        item(params) {
          const { children } = source.templates.item(params).props

          return (
            <ItemWrapper query={params.item.query} category={currentCategory}>
              {children}
            </ItemWrapper>
          )
        },
      },
    }
  },
})

// Query Suggestions plugin for the other categories.
const querySuggestionsPlugin = createQuerySuggestionsPlugin({
  // ...
  getSearchParams() {
    const currentCategory = getInstantSearchCurrentCategory()

    if (!currentCategory) {
      return recentSearchesPlugin.data.getAlgoliaSearchParams({
        hitsPerPage: 6,
      })
    }

    return recentSearchesPlugin.data.getAlgoliaSearchParams({
      hitsPerPage: 3,
      facetFilters: [
        `${INSTANT_SEARCH_INDEX_NAME}.facets.exact_matches.${INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE}.value:-${currentCategory}`,
      ],
    })
  },
  transformSource({ source }) {
    const currentCategory = getInstantSearchCurrentCategory()

    return {
      ...source,
      // ...
      templates: {
        ...source.templates,
        header({ items }) {
          if (!currentCategory || items.length === 0) {
            return null
          }

          return (
            <Fragment>
              <span className="aa-SourceHeaderTitle">In other categories</span>
              <div className="aa-SourceHeaderLine" />
            </Fragment>
          )
        },
        // ...
      },
    }
  },
})

Next steps

Autocomplete is now the main user interaction point driving InstantSearch to refine search results. From now on, you’re leveraging the complete Autocomplete ecosystem to bring a state-of-the-art search experience for desktop and mobile.

You can now add Autocomplete everywhere on your site and redirect users to the search page whenever they submit a search, or after they select a suggestion. You can also use context from the current page to personalize the autocomplete experience. For example, you could display a preview of matching results in a panel for each suggestion, and let InstantSearch provide these results once on the search page.

Did you find this page helpful?