Vue 3 Autocomplete – How to build a Reusable Composable

We have been tasked to build a headless Vue 3 autocomplete composable which provides functionality similar to Google Search. In this tutorial, we will implement said composable using Vue 3, the Composition API, and Typescript.

You can find the source code for this tutorial on GitHub.

What will we build?

That is a great question…yeah, so a useAutocomplete composable. It will accept a URL, and some options, and provide a method that makes a GET request. Furthermore, it provides a loading state so that you can use that to display a spinner for example.

We will use the composable at the end to build a small app to search for public APIs.

The Detailed Requirements

Since this tutorial only focuses on pure logic, we only develop the composable.

In the next part, we develop the <Autocomplete> component which will use this composable.

Let’s outline the list of requirements, the composable will:

  • Accept a target URL to make a request.
  • Accept a query parameter used by the server queryParam.
  • Accept a transformeData method to transform the data received in the response.
  • export a fetchSuggestions method that makes a GET request using the Fetch API.
  • export a isLoading ref that tracks the loading state.
  • export a hasFailed ref that tracks the error state.
  • export the transformed data list.

I hope you’re as excited as I am, let’s get started!

Project Setup

First, bootstrap a new Vue app with Vite. You can choose the default options. I will be using Typescript, but that’s not mandatory.

npm create vite@latest

Next, cd into the directory and install the necessary dependencies:

npm i

Once everything is done we should have a directory structure similar to this.

Creating the Composable

First, create the file src/use/useAutocomplete/useAutocomplete.ts . Then, export a function called useAutocomplete that accepts a URL and options object:

export interface Options {
  queryParam: string
  transformData?: (data: any) => any

}

export function useAutocomplete<T>(
  url: string,
  { queryParam, transformData = (data) => data }: Options,
) {
  // we will do cool stuff here
}

Afterward, validate that url has the correct format using Regexp. The options object must contain the queryParam property. We want to fail early in this case since it doesn’t make sense to make any requests or handle any state.

function isValidURL(url: string): boolean {
  const pattern = new RegExp(
    '^(https?:\\/\\/)?' +
      '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' +
      '((\\d{1,3}\\.){3}\\d{1,3}))' +
      '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' +
      '(\\?[;&a-z\\d%_.~+=-]*)?' +
      '(\\#[-a-z\\d_]*)?$',
    'i',
  )

  return pattern.test(url)
}

export function useAutocomplete<T>(
  url: string,
  { queryParam, transformData = (data) => data }: Options,
) {
  if (!isValidURL(url)) {
    throw new Error(`${url} is not a valid URL!`)
  }

  if (!queryParam) {
    throw new Error(`'queryParam' option is required`)
  }
}

Once we are happy with the parameters we initialize the local state. It will contain as discussed earlier a isLoading boolean, a data list that will store the response data and hasFailed which track request failure.

export function useAutocomplete<T>(
  url: string,
  { queryParam, transformData = (data) => data }: Options,
) {
  if (!isValidUrl(url)) {
    throw new Error(`${url} is not a valid URL!`)
  }

  if (!options.queryParam) {
    throw new Error(`'queryParam' option is required`)
  }

  const isLoading = ref(false)
  const data = ref<T[]>([])
  const hasFailed = ref(false)

  return {
    isLoading,
    hasFailed,
    data,
  }
}

The data type of the response is unknown and our Vue 3 Autocomplete composable should not be coupled to a specific response type (reusable). Hence, we don’t specify it here. Therefore, we accept a generic type T and use it as the data type for the array.

Example usage of the composable would be similar to this:

interface City {
  name: string
}

const { data } = useAutocomplete<City>('')

data.value.forEach(city => {
  city.name
});

As a result, we get autocompletion support, which is cool!

Finally, implement the fetchSuggestions method. It will accept the value to send as the query. Then, it will use the Fetch API to do a GET request. Once we get the data we run it through our transformer function and assign the result to the data ref.

async function fetchSuggestions(query: string): Promise<void> {
  try {
    isLoading.value = true
    hasFailed.value = false

    const _url = new URL(url)

    _url.search = new URLSearchParams({
      [queryParam]: query,
    }).toString()

    const response = await fetch(_url)
    const responseJson = await response.json()
    data.value = transformData(responseJson)
  } catch (error) {
    hasFailed.value = true
  } finally {
    isLoading.value = false
  }
}

return {
  isLoading,
  hasFailed,
  data,
  fetchSuggestions,
}

By using try/catch/finally along with async/await we assign the reactive state to the appropriate values.

Debounce, Debounce, Debounce

We currently have a problem, it’s race conditions. Imagine sending a request when typing a letter and sending a subsequent request when typing another letter, what if the first request resolves after the second one? then we are displaying the wrong results. How to solve this problem?

Well, that’s what debounce is for! Debouncing is a technique used to ensure that rapid successive events are only acted upon once. In our example, we give the user a window of one second to type. And as long as the user is typing we don’t send a request unless one second has elapsed from the first input event.

We can grab a debounce method from Josh and add it to our composable:

// outside of the useAutocomplete function
export interface Options {
  queryParam: string
  transformData?: (data: any) => any
  debounceDuration?: number
}

const DEFAULT_DEBOUNCE_DURATION_IN_MILLIS = 1_000

function debounce(callback: (...args: any[]) => any, wait: number) {
  let timeoutId: number | null = null

  return (...args: any[]) => {
    window.clearTimeout(timeoutId!)

    timeoutId = window.setTimeout(() => {
      callback.apply(null, args)
    }, wait)
  }
}

export function useAutocomplete<T>(
  url: string,
  {
    queryParam,
    transformData = (data) => data,
    debounceDuration = DEFAULT_DEBOUNCE_DURATION_IN_MILLIS ,
  }: Options,
) {
  // ...other code

  // the return inside the useAutocomplete function
  return {
    isLoading,
    hasFailed,
    data,
    fetchSuggestions: debounce(fetchSuggestions, debounceDuration),
  }
}

Accept the debounce duration as an option to make it configurable and up to the consumer to specify, otherwise, we use a default value of one second.

It might be better actually to define the debounce function in a utils file, then it’s easier to unit test.

This should be it, lets’s now try our new beautiful composable in a simulated real-world example.

Testing our Vue 3 Autocomplete Composable

To give it a whirl, import it in App.vue and use it to display an autocomplete result of public APIs using the free https://api.publicapis.org/.

First, specify the data type coming back in the response

<script lang="ts" setup>  
  import { useAutocomplete } from './use/useAutocomplete/useAutocomplete'

  interface PublicApi {
    API: string
    Description: string
    Link: string
  }

  const { fetchSuggestions, data, isLoading } = useAutocomplete<PublicApi>(
    'https://api.publicapis.org/entries',
    {
      queryParam: 'title',
      transformData: (data: { entries: PublicApi[] }) => data.entries,
      debounceDuration: 500,
    },
  )

  
</script>

<template>
  <div :class="$style.wrapper">
    <div :class="$style.content">
      <h1 :class="$style.headline">Search for a public APIs</h1>
    </div>
  </div>
</template>

<style module>
  .wrapper {
    font-family: 'Courier New', Courier, monospace;
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-top: 50px;
  }

  .content {
    width: 500px;
    display: flex;
    flex-direction: column;
  }

  .headline {
    text-align: center;
  }
</style>

Next, add an input field and a list to display the results. when we type in the input we call the fetch suggestions method. We also create reactive data to keep track of the selected suggestion, we will use that later.

<script lang="ts" setup>
  import { ref } from 'vue'
  import { useAutocomplete } from './use/useAutocomplete/useAutocomplete'

  // ...other code

  const searchQuery = ref('')
  const selectedItem = ref<PublicApi | null>(null)
  const canShowSuggestions = ref(false)

  function onInput() {
    if (searchQuery.value?.length > 2) {
      canShowSuggestions.value = true
      fetchSuggestions(searchQuery.value)
    } else {
      data.value = []
      selectedItem.value = null
    }
  }
</script>

<template>
  <div :class="$style.wrapper">
    <div :class="$style.content">
      <h1 :class="$style.headline">Search for a public APIs</h1>

      <input
        type="text"
        :class="$style.input"
        placeholder="search..."
        v-model="searchQuery"
        @input="onInput"
      />

      <ul :class="$style.list">
        <strong
          v-if="isLoading"
          :class="$style.loader"
        >
          Loading...
        </strong>

        <li
          v-for="(item, index) in data"
          :key="index"
        >
          <button :class="$style.item">
            {{ item.API }}
          </button>
        </li>
      </ul>
    </div>
  </div>
</template>

<style module>
   //... other styles 

  .input {
    padding: 10px;
    border: 1px solid lightgray;
  }

  .list {
    list-style-type: none;
    padding: 0;
    margin: 0;
  }

  .loader {
    margin-top: 10px;
  }

  .item {
    border: 1px solid lightgray;
    padding: 10px;
    cursor: pointer;
    width: 100%;
    background-color: #fff;
    margin-top: 10px;
  }
</style>

If the value is more than 2 characters long we make the request, otherwise, we reset the data list to an empty array. If we run the project we should see the result working

Vue 3 autocomplete 1

Next, add a click event handler on the suggestions and display the selected item in a card

<script lang="ts" setup>
  // ...other code

  function onItemClick(item: PublicApi) {
    selectedItem.value = item
    canShowSuggestions.value = false
    searchQuery.value = item.API
  }
</script>

<template>
  <div :class="$style.wrapper">
    <div :class="$style.content">
      <h1 :class="$style.headline">Search for a public APIs</h1>

      <input
        type="text"
        :class="$style.input"
        placeholder="search..."
        v-model="searchQuery"
        @input="onInput"
      />

      <ul :class="$style.list">
        <strong
          v-if="isLoading"
          :class="$style.loader"
        >
          Loading...
        </strong>

        <li
          v-for="(item, index) in data"
          :key="index"
        >
          <button
            :class="$style.item"
            @click="onItemClick(item)"
          >
            {{ item.API }}
          </button>
        </li>
      </ul>

      <div
        v-if="selectedItem"
        :class="$style.card"
      >
        <p>
          <strong>{{ selectedItem.API }}</strong>
        </p>

        <p>{{ selectedItem.Description }}</p>

        <p>{{ selectedItem.Link }}</p>
      </div>
    </div>
  </div>
</template>

<style module>
  // ...other styles

  .card {
    margin-top: 10px;
    padding: 20px;
    border: 1px solid lightgray;
    word-wrap: break-word;
  }
</style>

Voila! The final result should be like this:

Vue 3 autocomplete

Conclusion

Building a vue 3 autocomplete composable was fun! Thank you for reading, I hope you have learned something valuable from this tutorial.

Amenallah Hsoumi
Amenallah Hsoumi

Senior Software Engineer, Indie Hacker, building cool stuff on the web!

Articles: 19