skies.dev

How to Use Headless UI's Combobox with Async Data

9 min read

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.

pages/index.tsx
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.

pages/index.tsx
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.

pages/index.tsx
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.

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.

pages/index.tsx
// ...

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.

pages/index.tsx
// ...

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.
pages/index.tsx
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.

pages/api/person.ts
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.

pages/index.tsx
// ...
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

Hey, you! ๐Ÿซต

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like