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
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:
Conclusion
Building a vue 3 autocomplete composable was fun! Thank you for reading, I hope you have learned something valuable from this tutorial.