Building a Command Palette with Remix and Tailwind UI

Recently, the Tailwind Labs team published a new Combobox component on Headless UI and used it together with Dialog to create a Command Palette component in Tailwind UI.

I saw how simple it was and decided to add one for my work app. I'm not going to give you the code of the UI in this case because it's a paid component but let's see how to implement most of the other things with Remix.

The API Endpoint

We will build a command palette to search people on the DB and show them. Let's start with a resource route we'll use as an API endpoint to get the data.

import { LoaderFunction, json } from "remix";
import type { User } from "~/types";

export type LoaderData = {
  users: User[];
};

export let loader: LoaderFunction = async ({ request }) => {
  let url = new URL(request.url);
  let term = url.searchParams.get("term");

  // this function should query the DB or fetch an API to get the users
  let users = await getUsersByName(term);

  return json({ users });
};

We have a simple endpoint to give us the data we need.

The Component

Now let's build the UI. We will imagine a CommandPaletteUI component that receives some props and renders the UI from the simple command palette example on Tailwind UI.

Let's build the component with some mocked data:

// fake package
import { CommandPaletteUI } from "@tailwindui/react";
import { useState } from "react";

let people = [
  { id: 1, name: "Michael Jackson", url: "#" },
  { id: 2, name: "Ryan Florence", url: "#" },
  { id: 3, name: "Kent C. Dodds", url: "#" },
];

export function CommandPalette() {
  let [query, setQuery] = useState("");
  let [value, setValue] = useState(() => people[0]);

  let filteredPeople = people.filter((person) =>
    person.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <CommandPaletteUI
      data={filteredPeople}
      value={value}
      onSelect={setValue}
      query={query}
      onQueryChange={setQuery}
      isLoading={false}
    />
  );
}

Now, let's start fetching the data from the API endpoint and updating the UI instead of using the hardcoded list of people.

To do the fetch, we can use the useFetcher hook, which is perfect for this use cases.

// fake package
import { CommandPaletteUI } from "@tailwindui/react";
import { useState, useEffect } from "react";
import { useFetcher } from "remix";
// because of `import type` this will let us import the LoaderData without
// importing the rest of the module code
import type { LoaderData } from "~/routes/api.command-palette";

export function CommandPalette() {
  let [query, setQuery] = useState("");
  let [value, setValue] = useState(() => people[0]);

  let { data, load, state } = useFetcher<LoaderData>();
  let people = data?.users ?? []; // initially data is undefined

  useEffect(
    function getInitialData() {
      load("/api/command-palette");
    },
    [load]
  );

  useEffect(
    function getFilteredPeople() {
      load(`/api/command-palette?term=${query}`);
    },
    [load, query]
  );

  return (
    <CommandPaletteUI
      data={people}
      value={value}
      onSelect={setValue}
      query={query}
      onQueryChange={setQuery}
      isLoading={state === "loading"}
    />
  );
}

And that's it. Remix makes data fetching so easy that the change from mocked data to actual data is small and straightforward without many changes.