Read time: 7 mins

HTMX Search

Out of the Box

The htmx docs have a basic example of active search. It's inticing to have a feature like that with just so few lines of declarative html. Let's look at it real quick before we get into my more involved example.

<h3>
  Search Contacts
  <span class="htmx-indicator">
    <img src="/img/bars.svg"/> Searching...
   </span>
</h3>
<input class="form-control" type="search"
       name="search" placeholder="Begin Typing To Search Users..."
       hx-post="/search"
       hx-trigger="input changed delay:500ms, search"
       hx-target="#search-results"
       hx-indicator=".htmx-indicator">

<table class="table">
    <thead>
    <tr>
      <th>First Name</th>
      <th>Last Name</th>
      <th>Email</th>
    </tr>
    </thead>
    <tbody id="search-results">
    </tbody>
</table>

This is pretty straightforward. The input triggers on change and posts to /search with it's value.

Then it pulls out the #search-results and replaces tbody element with it.

Easy peasy … but what if you want to add a checkbox for some kind of filter? What if you want the search inputs preserved in the url for sharing/refreshing support?

That's the issue I ran into. That functionality complicates the process but I think I've figured out a minimal way to accomplish this while still staying true to HATEOAS and not adding too much overhead.

Expanded

I'm building a personal insights app with Clojure, Biff, XTDB, Tailwindcss, and HTMX. Part of that app includes habit tracking. This implementation of typeahead search focuses on just the page that lists the habits for editing and review.

Other Inputs

For my habits tracker I needed to add a boolean attribute called sensitive. This acts as a flag to hide the habit from general views. To see sensitive habits the user has to explicitly indicate they should present. I tend to demo my apps to friends and sometimes I don't want all of my habits to be on display.

So now I have two inputs, a text input and a checkbox. In the future I might add more inputs for different filtering options. So I need to include all of that input data in a single request to the server.

The best way I could think to do that was with a form component. Here is some code in Clojure/Hiccup that shows the form. biff/form is a framework convenience that outputs [:form] element with some magic for hidden elements and a csrf token.

(defn habits-search-component [{:keys [sensitive search]}]
  [:div.my-2
   (biff/form
    {:id         "habit-search"
     :hx-post    "/app/habits"
     :hx-swap    "outerHTML"
     :hx-trigger "search delay:500ms"
     :hx-select  "#habits-list"
     :hx-target  "#habits-list"}
    [:div.flex.flex-col.justify-center.my-6

     [:input.form-control.w-full.md:w-96.mb-2
      {:type        "search"
       :name        "search"
       :script      "on keyup htmx.trigger('#habit-search', 'search', {})"
       :placeholder "Begin Typing To Search Habits..."}]

     [:div.flex.flex-row.justify-start.items-center
      [:label.mr-4 {:for "sensitive"} "Sensitive"]
      [:input.rounded.mr-2
       {:type         "checkbox"
        :name         "sensitive"
        :script       "on change htmx.trigger('#habit-search', 'search', {})"
        :autocomplete "off"
        :checked      sensitive}]]])])

This works for keeping all the inputs bundled together with each request. On any action the inputs fire off a custom event that triggers the form. There is no submit button. The :script attribute accomplishes this with a touch of hyperscript.

The habits page backend endpoint needs to also accept a post request and utilize the input data to filter the results. In my backend I have the same clojure function that serves the initial GET request also server the POST request.

(defn habits-page
  "Accepts GET and POST. POST is for search form."
  [{:keys [session biff/db params]}]
  (let [user-id                        (:uid session)
        {:user/keys [email time-zone]} (xt/entity db user-id)
        habits                         (habits-query (pot/map-of db user-id))
        sensitive                      (some-> params :sensitive checkbox-true?)
        search                         (some-> params :search search-str-xform)]
    (ui/page
     {}
     [:div
      (header (pot/map-of email))
      [:button.bg-blue-500.hover:bg-blue-700.text-white.font-bold.py-2.px-4.rounded.w-full.md:w-96.mt-6
       ;; TODO not implemented yet
       "Add habit"]
      (habits-search-component {:sensitive sensitive :search search})
      [:div {:id "habits-list"}
       (->> habits
            (filter (fn [{:habit/keys [name notes]
                          this-habit-is-sensitive :habit/sensitive
                          id          :xt/id}]
                      (let [matches-name  (str/includes? (str/lower-case name) search)
                            matches-notes (str/includes? (str/lower-case notes) search)]
                        (and (or sensitive
                                 (-> id (= edit-id))
                                 (not this-habit-is-sensitive))
                             (or matches-name
                                 matches-notes)))))
            (map habits-list-item))]])))

This works well, and we could stop there. However, I want one more thing. I want the search paramters to be synced to query parameters. Then if the user shares a link or refreshes the page the search results are preserved.

Query Params Too

The most minimally viable way I could think to sync the state of search to the query parameters of the url was to use a single javascript function and to alter the backend a little bit.

Let's start with the js function. Biff has a static js file called main.js in a resources directory that is for one off functions like this. It has no build step of any kind and so far, this is the only function I've added to that file.

function setURLParameter(paramName, value) {
  console.log("setting url param: ", paramName, value)
  const url = new URL(window.location);
  // if the value is an empty string or null remove it otherwise set it
  if (value === '' || value === null) {
    url.searchParams.delete(paramName);
  } else {
    url.searchParams.set(paramName, value.toString());
  }
  // keep the url bar in sync
  window.history.pushState({}, null, url.toString());
}

All it does is take in a name and a value. It uses URL.searchParams and window.history.pushState to add and remove values and keep the browser in sync. Pretty simple.

Now how does our search component call this? With a smidge more hypserscript. Below is the full definition of my current search component function. Notice the changes to the :script attribute of each input.

(defn habits-search-component [{:keys [sensitive search]}]
  [:div.my-2
   (biff/form
    {:id         "habit-search"
     :hx-post    "/app/habits"
     :hx-swap    "outerHTML"
     :hx-trigger "search delay:500ms"
     :hx-select  "#habits-list"
     :hx-target  "#habits-list"}
    [:div.flex.flex-col.justify-center.my-6

     [:input.form-control.w-full.md:w-96.mb-2
      (merge {:type        "search"
              :name        "search"
              :placeholder "Begin Typing To Search Habits..."
              :script      "on keyup setURLParameter(me.name, me.value) then htmx.trigger('#habit-search', 'search', {})"}

             (when (not (str/blank? search))
               {:value search}))]

     [:div.flex.flex-row.justify-start.items-center
      [:label.mr-4 {:for "sensitive"} "Sensitive"]
      [:input.rounded.mr-2
       {:type         "checkbox"
        :name         "sensitive"
        :script       "on change setURLParameter(me.name, me.checked) then htmx.trigger('#habit-search', 'search', {})"
        :autocomplete "off"
        :checked      sensitive}]]])])

Now the :script attribute calls the setURLParameter function with the name and value of the input the attribute is on. me is a reserved symbol in hyperscript for this purpose.

Changing the backend endpoint to accommodate query params and form params was pretty straightfoward. Below is the full habits-page component. The important change is within the let block and the assignment of the sensitive and search attributes.

(defn habits-page
  "Accepts GET and POST. POST is for search form as body."
  [{:keys [session biff/db params query-params]}]
  (let [user-id                        (:uid session)
        {:user/keys [email time-zone]} (xt/entity db user-id)
        habits                         (habits-query (pot/map-of db user-id))
        edit-id                        (some-> params :edit (java.util.UUID/fromString))
        sensitive                      (or (some-> params :sensitive checkbox-true?)
                                           (some-> query-params :sensitive checkbox-true?))
        search                         (or (some-> params :search search-str-xform)
                                           (some-> query-params :search search-str-xform)
                                           "")]
    (ui/page
     {}
     [:div
      (header (pot/map-of email))
      [:button.bg-blue-500.hover:bg-blue-700.text-white.font-bold.py-2.px-4.rounded.w-full.md:w-96.mt-6
       "Add habit"]
      (habits-search-component {:sensitive sensitive :search search})
      [:div {:id "habits-list"}
       (->> habits
            (filter (fn [{:habit/keys [name notes]
                          this-habit-is-sensitive :habit/sensitive
                          id          :xt/id}]
                      (let [matches-name  (str/includes? (str/lower-case name) search)
                            matches-notes (str/includes? (str/lower-case notes) search)]
                        (and (or sensitive
                                 (-> id (= edit-id))
                                 (not this-habit-is-sensitive))
                             (or matches-name
                                 matches-notes)))))
            (map (fn [z] (habit-list-item (-> z (assoc :edit-id edit-id))))))]])))

Basically this changes is saying let the search and sensitive symbols be the form params if present or the query params. If neither is present then use a default value – an empty string and false in this case.

Now as the user types a search string or checks a box htmx will post for new habits list content and also keep the url in sync. If the user bookmarks the url and comes back to it the backend will act on those search inputs and return the exact same page.

Overview

To wrap it up here is a sequence diagram of how this flows.

  sequenceDiagram
    browser ->> server: GET /habits
    server ->> browser: Habbits page + scripts/stylesheets
    note over browser: Start typing in search box or press filter toggle
    browser --> browser: setURLParameter(n,v) & 'search' event
    browser ->> server: POST /habits
    server ->> browser: Habbits page (hx-target habits-list)
    browser --> browser: replace habits-list

To recap this accomplishes the typeahead search functionality with extra search inputs beyond just a string of text to search on. It also keeps all search state encoded in the url to allow deep linking.