← Blog

A headless autocomplete for Elm

Every product needs an autocomplete, and every product needs a different autocomplete. One is a country picker that takes a single value. One lets a user pile up a dozen tags and never offer the same one twice. One renders as a list, one as a grid of avatars, one inside a table cell that must not grow. The markup is different, the styling is different, the keyboard behaviour is argued over in design review.

And underneath all of them sits exactly the same problem, solved wrong in exactly the same way.

The part that is actually hard

Type three characters quickly and a naive implementation fires three requests. They come back out of order. The list flickers between the results for sin, si and sing, and settles on whichever response was slowest — which is to say, on the query the user has already stopped caring about. Add a spinner and you now have a spinner that lies.

Debouncing, cancelling, keeping the response that matches the current query, knowing whether you are between keystrokes or genuinely waiting: that is the autocomplete problem. It has nothing to do with how the list looks, and it is identical in every one of those different-looking components.

So the package ships the logic and no view

futureworkz/elm-autocomplete provides the state and the timing, and deliberately provides no view functions at all. That refusal is the design.

You keep an Autocomplete a in your model, wire its Msg into your update, and hand it one function — a fetcher:

type alias Choices a =
    { query : String       -- what the user has typed
    , choices : List a     -- the previous list
    , ignoreList : List a  -- e.g. values already selected
    }

fetcher : Autocomplete.Choices String -> Task String (Autocomplete.Choices String)

The package calls it when — and only when — the query has settled. Your fetcher is a Task, so it can hit an HTTP endpoint, filter a list already in memory, or consult two sources and merge them; the package neither knows nor cares. The ignoreList is what makes multi-select fall out for free: pass the values the user has already picked and they stop being offered.

What you get back is a value you render yourself, with your own markup, your own classes, your own keyboard handlers. The package exposes Autocomplete for the logic, Autocomplete.View for the plumbing a view needs, and Autocomplete.Styled for elm-css users — and stops there. Debouncing rides on jinjor/elm-debounce, because that problem was already solved by someone else.

Why this is the right shape in Elm specifically

A view-owning component in Elm has to guess your Html msg, your styling approach and your event model, and every guess is a place where a real design system will fight it. A view-less component has to guess nothing. It composes with whatever you already have, including the parts of your app that were never designed to accommodate it.

There is a general lesson here that we keep re-learning: the reusable part of a component is the part with no pixels in it. Timing, ordering, state transitions, "which request still matters" — that is library work. Appearance is product work, and product work does not want a library's opinion.

Elm 0.19, MIT, elm install futureworkz/elm-autocomplete. The repository's examples/ directory has the single-value and multi-select versions in full.