How to Use Headless UI's Combobox with Async Data
We're going to build an autocomplete component using the Headless UI library for React. I'll show you how to use TypeScript with Headless UI and how you can use Tailwind CSS to style the combobox. ๐
At the time of writing, all the examples on Headless UI's documentation have the combobox's options hard-coded on the client. I'm going to show you how to source the autocomplete component's options from the backend. We'll use Next.js in our example which will give us an integrated frontend and backend we can work with.
Check out the completed project's source code on GitHub.
Getting Started
The first thing we want to do is set up our Next.js project.
npx create-next-app@latest --typescript
We'll call our app headless-ui-combobox-demo
and open the newly created project
in our editor.
Next, bring in the libraries we're going to use. To start, we'll install Headless UI and Tailwind CSS. Here's the command to install Headless UI.
yarn add @headlessui/react
To install Tailwind CSS, I recommend following the guide on installing Tailwind CSS with Next.js because there's a few steps you'll need to do to get it working with Next.js.
At this point we can get rid of all the boilerplate code that was added when we generated the Next.js project. What we're left with is a basic home page.
import type {NextPage} from 'next';
const Home: NextPage = () => {
return (
<main>
<h1>Combobox Example</h1>
{/* Combobox will go here */}
</main>
);
};
export default Home;
We can test that Tailwind is set up properly by adding some classes to the title.
import type {NextPage} from 'next';
const Home: NextPage = () => {
return (
<main>
<h1 className="text-3xl font-bold">Combobox Example</h1>
{/* Combobox will go here */}
</main>
);
};
export default Home;
Now we should see a basic page with only our styled heading.
Setting up the combobox to work locally
The first thing we can do is set up our combobox to match what the documentation shows. We'll copy the setup they show in the basic example into our app.
import type {NextPage} from 'next';
import {useState} from 'react';
import {Combobox} from '@headlessui/react';
const people = [
'Durward Reynolds',
'Kenton Towne',
'Therese Wunsch',
'Benedict Kessler',
'Katelyn Rohan',
];
const Home: NextPage = () => {
const [selectedPerson, setSelectedPerson] = useState(people[0]);
const [query, setQuery] = useState('');
const filteredPeople =
query === ''
? people
: people.filter((person) => {
return person.toLowerCase().includes(query.toLowerCase());
});
return (
<main>
<h1 className="text-3xl font-bold">Combobox Example</h1>
<Combobox value={selectedPerson} onChange={setSelectedPerson}>
<Combobox.Input onChange={(event) => setQuery(event.target.value)} />
<Combobox.Options>
{filteredPeople.map((person) => (
<Combobox.Option key={person} value={person}>
{person}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</main>
);
};
export default Home;
At this point we should have the basic combobox working with the hardcoded data.
Styling combobox states
When we're hovering over one of the combobox's options, we may want to show the region with a different color background to indicate the option is active. Headless UI gives us two ways to style the various states of the combobox. Those two options are using
In the video I show how to style the combobox with both approaches.
I think using the data attributes is a nicer solution because it doesn't clutter up our render logic,
and this approach pairs well with Tailwind CSS.
To use Headless UI's data attributes, we need to install
Headless UI's Tailwind CSS plugin
and add it to our tailwind.config.js
.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [require('@headlessui/tailwindcss')],
}
Once we've got that, we should be able to use the modifiers
like ui-active:
and ui-not-active:
to style various states of the combobox.
We're changing the structure (i.e. the object shape) of the options, so we'll need to tell
Headless UI how to display the object we're passing in. We'll
do this with the displayValue
prop on Combobox.Input
.
To make TypeScript happy with Headless UI,
we need to define a Person
interface and explicitly
set the type in the displayValue
callback function.
// ...
export interface Person {
id: number;
name: string;
}
const people: Person[] = [
{id: 1, name: 'Durward Reynolds'},
{id: 2, name: 'Kenton Towne'},
{id: 3, name: 'Therese Wunsch'},
{id: 4, name: 'Benedict Kessler'},
{id: 5, name: 'Katelyn Rohan'},
];
const Home: NextPage = () => {
// ...
return (
<main>
<h1 className={'mt-5 text-center text-3xl font-bold'}>
Combobox Example
</h1>
<Combobox value={selectedPerson} onChange={setSelectedPerson}>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
displayValue={(person: Person) => person.name}
/>
<Combobox.Options>
{filteredPeople.map((person) => (
<Combobox.Option
key={person.id}
value={person}
className="ui-active:bg-blue-500 ui-active:text-white ui-not-active:bg-white ui-not-active:text-gray-800 py-2 px-3"
>
{person.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</main>
);
};
export default Home;
At this point we should be able to see a blue background over the currently active option.
Custom comparator function
The combobox library looks at reference equality internally.
For example, let's say you had two simple objects that look similar.
const a = {foo: 'bar'};
const b = {foo: 'bar'};
So even though these objects are
identical, a
and b
refer to two separate objects in memory.
This is how the library checks equality.
console.log(a == b); // false
console.log(a === b); // false
But in this case, what we'd want is to check equality this way.
console.log(a.foo === b.foo); // true
Headless UI exposes the by
prop where we can write a comparator function.
This way we can explicitly tell Headless UI how we want to compare two objects,
even if they refer to two separate objects in memory.
// ...
const comparePeople = (a?: Person, b?: Person): boolean =>
a?.name.toLowerCase() === b?.name.toLowerCase();
const Home: NextPage = () => {
// ...
return (
<div>
{/* ... */}
<main>
{/* ... */}
<Combobox
value={selectedPerson}
by={comparePeople}
onChange={setSelectedPerson}
>
{/* ... */}
</Combobox>
</main>
</div>
);
};
export default Home;
Now we can be less careful about ensuring the same object references are passed to the library.
Styling the combobox
Next, we'll add some classes to our combobox to make it look better. We'll integrate Tailwind Lab's Heroicon icon library to help.
yarn add @heroicons/react
We'll update the markup to support styling the combobox. In addition, we're
- adding a
MagnifyingGlassIcon
to signal that this is a search autocomplete. - centering the combobox on the page.
- adding a ring around the combobox when it's in focus.
- adding some slight color changes and drop shadow.
import {MagnifyingGlassIcon} from '@heroicons/react/20/solid';
const Home: NextPage = () => {
// ...
return (
<main className="mx-auto max-w-md">
<h1 className="mt-5 text-center text-3xl font-bold">Combobox Example</h1>
<div className="mt-5 shadow-xl focus-within:ring-2 focus-within:ring-blue-500">
<Combobox
value={selectedPerson}
by={comparePeople}
onChange={setSelectedPerson}
>
<div className="flex items-center bg-gray-100 px-3">
<MagnifyingGlassIcon className="inline-block h-5 w-5 text-gray-500" />
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
displayValue={(person: Person) => person?.name ?? ''}
className="w-full bg-gray-100 py-2 px-3 outline-none"
/>
</div>
<Combobox.Options>
{filteredPeople?.map((person) => (
<Combobox.Option
key={person.id}
value={person}
className="ui-active:bg-blue-500 ui-active:text-white ui-not-active:bg-white ui-not-active:text-gray-800 py-2 px-3"
>
{person.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</div>
</main>
);
};
export default Home;
Now the combobox should look pretty good.
Now let's look at how we can get the combobox to interact with a backend.
Creating a backend endpoint to return combobox options
Let's create the endpoint to return the autocomplete results. In a real app, we'd use a real backend system like ElasticSearch to get these results, but in this example, we're going to continue hardcoding the data on the backend. The important thing to learn here is how the combobox (on the client) can fetch the results from the backend.
We'll create our simple backend by moving some of the logic we had previously written
on the client to the backend. We'll create a new file called pages/api/person.ts
to
write our endpoint.
Make sure you export the Person
type from pages/index.tsx
so
that we can access it from pages/api/person.ts
.
Our endpoint will filter the people by our query parameter q
.
import type {NextApiRequest, NextApiResponse} from 'next';
import {Person} from '../index';
const people: Person[] = [
{id: 1, name: 'Durward Reynolds'},
{id: 2, name: 'Kenton Towne'},
{id: 3, name: 'Therese Wunsch'},
{id: 4, name: 'Benedict Kessler'},
{id: 5, name: 'Katelyn Rohan'},
];
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Person[]>,
) {
// our backend accepts a "q" query param.
// this is the query from the autocomplete component.
const query = req.query.q?.toString() ?? '';
// this logic is moved from the client
const filteredPeople =
query === ''
? people
: people.filter((person) => {
return person.name.toLowerCase().includes(query.toLowerCase());
});
res.status(200).json(filteredPeople);
}
With our backend in place, we can update our client to fetch the autocomplete results from the backend.
Calling the backend using SWR
We'll use the popular client-side fetching library SWR to fetch the autocomplete results from the backend. SWR handles caching our API responses, which will make the combobox UX faster, and it won't put as much pressure on the server.
yarn add swr
Since we're now fetching the autocomplete results from the backend,
we'll render a LoadingSpinner
component state when we're fetching the results.
// ...
import useSWR from 'swr';
function LoadingSpinner() {
return (
<svg
className="h-5 w-5 animate-spin text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);
}
async function fetcher(url: string, query: string): Promise<Person[]> {
const data = await fetch(`${url}?q=${query}`);
return data.json();
}
const Home: NextPage = () => {
const [selectedPerson, setSelectedPerson] = useState<Person | undefined>(
undefined,
);
const [query, setQuery] = useState('');
const {data: filteredPeople, error} = useSWR(['/api/person', query], fetcher);
const isLoading = !error && !filteredPeople;
return (
<main className={'mx-auto max-w-md'}>
<h1 className={'mt-5 text-center text-3xl font-bold'}>
Combobox Example
</h1>
<div
className={
'mt-5 shadow-xl focus-within:ring-2 focus-within:ring-blue-500'
}
>
<Combobox
value={selectedPerson}
by={comparePeople}
onChange={setSelectedPerson}
>
<div className={'flex items-center bg-gray-100 px-3'}>
<MagnifyingGlassIcon
className={'inline-block h-5 w-5 text-gray-500'}
/>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
displayValue={(person: Person) => person?.name ?? ''}
className={'w-full bg-gray-100 py-2 px-3 outline-none'}
autoComplete={'off'}
/>
{isLoading && <LoadingSpinner />}
</div>
<Combobox.Options>
{filteredPeople?.map((person) => (
<Combobox.Option
key={person.id}
value={person}
className="ui-active:bg-blue-500 ui-active:text-white ui-not-active:bg-white ui-not-active:text-gray-800 py-2 px-3"
>
{person.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</div>
</main>
);
};
export default Home;
And that's it! We're now set up the Headless UI Combobox to work with our backend.
Summary
In this article you should've learned how to:
- use Headless UI Combobox library with React
- style the combobox component with Tailwind CSS
- integrate the combobox to work with async data