HTMX with Haskell's Lucid & Servant

A "Vomit Draft Editor" made with functional HTML generation.

Introduction

HTMX is a JavaScript library that lets you create interactive websites entirely out of a single backend server that generates HTML snippets. It works by endowing HTML elements with special “hx” attributes that setup triggers for HTTP requests and targets for where to put HTML responses.

Critically, this can mean avoiding a lot of the complexity surrounding passing data back and forth between a frontend client and a backend server, let alone setting up a frontend client. For more background, see my earlier post about using HTMX with Python’s FastAPI and Htbuilder.

This post is an overview of how to build a HTMX powered website, whose backend server written in Haskell’s Servant API framework, and whose HTML is generated using Lucid package (see Footnote 1 for tutorial info).

In particular, we’ll go over a simple project I made of a “Vomit Draft Text Editor” (all code available in this public repo). Besides the Haskell implementation, the idea was to pick a project with more complex state and flow than a data entry form / admin UI, which is my usual use case for HTMX.

Note: if you’re interested in other approaches to web development with functional languages I strongly recommend also looking into elm, and elixir’s Pheonix, which is apparently very loved.

What is a Vomit Draft Editor?

A vomit draft usually refers to a draft (traditionally of a script or novel) where the author tries their best not to stop writing, and absolutely not to go back and tweak existing sentences. The ability to write a vomit draft is a skill I absolutely do not possess, so I thought - could a restrictive text editor force me to try?

Here is a non-functioning preview of the idea.

Vomit Draft Editor

Once you start typing, you can only stop typing for 30 seconds before your text is locked in!

Remaining: 30

The flow is:

  1. Every second you don’t type in the box, the timer ticks down.
  2. Whenever edit the text in the box, the timer resets to its starting value.
  3. If the timer hits 0:
    • If there is text in the box, we close the box and a new box appears below it for you to continue typing.
    • Otherwise, “time’s up!” All the text you’ve written so far is merged into a single read-only box to take for editing (or to try a vomit draft).
  4. There’s also a “Finalise” button which stops the timer and merges all the blocks too.

I’ve put certain phrases in bold because they are the core API endpoints we will implement.

See Footnote 2 some other additions that may improve the editor (and make it harder to work around).

The HTMX Solution

Before jumping into code, let’s go through a solution of what the DOM should look like, what the primary HTMX powered elements are, when they trigger HTTP requests, and what the target of those requests are. In this case, what part of the DOM’s outerHYML should be replaced by response of those requests (note: HTMX has many other options for what to do with responses).

Figure 1 below details the DOM, what triggers requests, and what the targets of those requests are. Afterwards, we will outline what the responses should look like.

Note: I stumbled my way to this solution and wish I had charted this out before I started writing code. When the flow isn’t straightforward HTMX, good code generally comes from a think before you type approach, rather than* think by typing*, which is my usual style. The same is true for Haskell more generally.

Figure 1: HTML & HX Posts; arrow direction points at what outerHTML will be replaced by the response.

POST /{timeRemaining}/tick should replace the #timer element (i.e., itself) with a version that displays Remaining time: {timeRemaining} seconds, and after 1 second, triggers one of POST /{timeRemaining-1}/tick, POST /close-block or POST /finish depending on how much time is remaining and if there’s text in the active textarea.

POST /reset-timer should simply return a newly initialised #timer element.

POST /close-block should replace the #active-block element with a new version, containing: 1. A read-only version of the previous active textarea. 2. A new active textarea. 3. A newly initialised #timer element.

POST /finish should take all the closed box and the active box, and join their text together into a single read only text area, and clear any timers and buttons.

Note: because all the <textarea> elements share the name “box” and are part of the same form, all POST requests from the form will have request body: box=["{textarea1Content}", "{textarea2Content}", ... "{activeTextareaContent}"].

Now to the implementation. We’ll start with a brief discussion of the stack, and then go through the key contributions of each. For more details, take a look at the full solution in the repo.

What is the point of this stack?

Putting aside Haskell for a moment, the two main ideas I’m playing with here are:

  1. Programmatically generating HTML with code that really looks like the HTML it outputs. In other words, generating HTML with the full logic of a programming language (conditionals, loops, composition, nesting, abstraction) but not at the cost of the readability of HTML (i.e., its visual logic of nesting elements). This is something I believe HTML templating languages such as jinja and JSX often fall short at.

  2. HTMX as a means to avoiding writing front-end code dedicated to: event listeners, managing HTTP requests, serialising and deserialising request and response objects, state management, re-rendering elements, etc. In other words, that sort of code often adds a lot of code volume, fragmentation, complexity and failure points.

I go into these two ideas in more detail in my earlier post about a similar stack in Python.

As for the Haskell implementation:

<div id="container">
  <ul class="greetings">
    <li>hello</li>
    <li>hi</li>
    <li>whattup</li>
  </ul>
</div>

Can be rendered by:

import Lucid

greetings :: Html ()
greetings = 
  div_ [id_ "container"] $ do
    ul_ [class_ "greetings"] $ do
      li_ "hello"
      li_ "hi"
      li_ "whattup"

In fact, the combination of the do-notation and absence of bracketing enabled by Haskell’s ML-style syntax can lead to HTML generating functions that are more readable than the HTML itself. This is furthered enhanced by Haskell’s unparalleled type safety and features for composition (e.g., partial application).

However, compilation errors with Servant can be quite hard to troubleshoot, so if you want a simpler API framework, I hear scotty is good.

Generating the HTML with Lucid

Next, we’ll survey the Lucid HTML generation of the above endpoints, noting some nice features of Lucid. Then, we’ll outline how Servant has to be setup, discuss some limitations and then conclude.

To begin, we need to implement our initial homepage request, i.e., our GET /, endpoint, which taking is simply.

type HTML = Html () -- shorthand for redability

renderHomepage :: HTML
renderHomepage = do
  pageHead
  pageBody

Where we try to carve out the essential DOM structure in pageBody (see Figure 1), i.e., relegating bulky HTML (e.g., <head>), attributes and innerText to other functions or where clauses.

pageBody :: HTML
pageBody =
  div_ $ do
    h1_ "Vomit Draft Editor"
    p_ instructions
    form_
      [ hxPost_ "/finish",
        hxTarget_ . toIdSelector $ containerId,
        hxSwap_ "outerHTML"
      ]
      $ div_ [idAttr containerId]
      $ do
        writableBlock
        submitButton

instructions :: Text
instructions =
  pack
    ( "Once you start typing you can only stop typing for "
        ++ show defaultTimeLimit
        ++ " seconds before your text is locked in!"
    )

submitButton :: HTML
submitButton = button_ [type_ "submit"] "Finalise"

We already see our first HTMX endpoint setup in POST /finish, the rest are part of writableBlock :: HTML. See Footnote 3 for more information on HTMX Attribute bindings in Lucid, and see Footnote 4 for more info on the helper functions like idAttr and toIdSelector.

We can also create base elements / element builders, e.g.,

baseTextArea :: [Attributes] -> HTML -> HTML
baseTextArea attrs =
  textarea_ $
    name_ "boxes" : cols_ "100" : attrs

-- Used in both of:

readOnlyBlock :: Int -> Text -> HTML
readOnlyBlock numRows box =
  baseTextArea
    [ rows_ . toText $ numRows,
      readonly_ ""
    ]
    $ toHtml box

writableBlock :: HTML
writableBlock =
  div_ [idAttr activeBlockId] $ do
    baseTextArea
      [ rows_ . toText $ defaultRowsPerBlock,
        hxPost_ "/reset-timer",
        hxTrigger_ onInputEdit,
        hxTarget_ . toIdSelector $ timerId,
        hxSwap_ "outerHTML"
      ]
      mempty -- no innerHTML
    renderTimer defaultTimeLimit initModel
  where
    onInputEdit =
      T.intercalate
        ", "
        [ "input changed delay:300ms",
          "paste changed delay:300ms",
          "cut changed delay:300ms"
        ]

See Footnote 5 for more examples, and Ui.hs for all the other endpoint implementations.

The Servant API

The core architecture I went with is splitting the program into two modules Ui.hs, Main.hs. The former contains all the code for the HTML generation and a model (i.e., type) for encoding the session state (on top of the state encoding in what ever endpoint we’re responding to). The latter, simply contains the code for spinning up the API Server, defining its endpoints, and using functions from Ui.hs to handle client requests, in particular, not knowing the composition of the model.

Firstly, we need to define what the UI model is, and how to produce models from HTTP Form Data (i.e., what HTMX sends), and how to produce HTML responses from Lucid’s HTML type. In UI, we set:

import Lucid (Html)
import Web.FormUrlEncoded (FromForm)    -- http-api-data package

newtype Model
  = Model {boxes :: [Text]}             -- Text requires less type-conversion than String for filling in arguments in Lucid
  deriving (Generic)                    -- needed for FromForm to work

instance FromForm Model                 -- unfortunate coupling between Main and UI but this has to be defined with the type.

Then, with the help of the dani-servant-lucid2 package for converting Lucid’s HTML type to an HTTP HTML Response, we can define our API as follows.

import Servant
import qualified Servant.API.ContentTypes.Lucid as SL
import Ui
import qualified Ui as U

type ModelRequestBody = ReqBody '[FormUrlEncoded] Model

type GetHtmlResponse = Get '[SL.HTML] U.HTML

type PostHtmlResponse = Post '[SL.HTML] U.HTML

type API =
  "static" :> Raw
    :<|> GetHtmlResponse
    :<|> "reset-timer" :> ModelRequestBody :> PostHtmlResponse
    :<|> Capture "remainingTime" Int :> "tick" :> ModelRequestBody :> PostHtmlResponse
    :<|> "close-block" :> ModelRequestBody :> PostHtmlResponse
    :<|> "finish" :> ModelRequestBody :> PostHtmlResponse
    :<|> "times-up" :> ModelRequestBody :> PostHtmlResponse

Then, to implement - say - the POST /{remainingTime}/tick endpoint, we need to provide a function of type Int -> Model -> Handler U.HTML, where Handler is the Monad that handles conversion from/to HTTP request/responses.

In UI.hs the implementation of rendering new HTML from remainingTime and the <textarea> values is a function Int -> Model -> U.HTML, so all we need for our implementation is:

renderResetTimer :: Model -> Handler U.HTML
renderResetTimer model =
    return $ renderTimer defaultTimeLimit model

When, the endpoint only has 1 argument, like POST /finish, if we have a Ui.hs function renderFinishedContainer :: Model -> HTML we simply implement the endpoint with return . renderFinishedContainer :: Model -> Handler HTML. So the full implementation is as follows.

server :: Server API
server =
  serveDirectoryWebApp "static/"
    :<|> return renderHomepage
    :<|> renderResetTimer
    :<|> renderDecrementedTimer
    :<|> return . renderClosedAndNewBlock
    :<|> return . renderFinishedContainer
    :<|> return . renderTimesUpContainer
  where
    renderResetTimer :: Model -> Handler U.HTML
    renderResetTimer model =
      return $ renderTimer defaultTimeLimit model

    renderDecrementedTimer :: Int -> Model -> Handler U.HTML
    renderDecrementedTimer timeRemaining model =
      return $ renderTimer (timeRemaining - 1) model

The rest of Main.hs is standard and can be found here, which I largely drew from this starter code, from the original servant-lucid package for Lucid v1 rather than the current Lucid v2. See Footnote 6 for more info on Lucid v1.

What's Missing?

Personally, I think the frustrating moments web development come from when a website is supposed to be doing something (e.g., firing off an HTTP request, responding to an event, re-rendering something) but nothing happens, not even an error.

In the implementation of this post, there are still 2 such failure points I struggled with.

  1. (More so) generating a hx-target to an element that doesn’t exist in the DOM.
  2. (Less so) generating a hx-get or hx-post to an endpoint that doesn’t exist in the API.

The first may be solvable with clever unit test on rendered HTML, e.g., for every hx-target=#element check that id=element exists somewhere.

The second, could be solved if there was a way to generate the API endpoints from the hx-target, e.g., being able to define functions like:

genEndpoint :: String -> API
genEndpoint x = (SpecialApiPath x) :> ModelRequestBody :> PostHtmlResponse

Finally, a version of these experiments for CSS (i.e., some alternative or way to generate it that is type safe, readable and without silent errors) would be incredible. The closest thing I’ve found is elm-ui (with this talk) as an alternative to CSS for elm. It actually allows you to center a div with centerX and centerY attributes!

Conclusion

Hopefully this experiment in web development has been valuable (or at least interesting).

Happy coding!

Comments

Comments are a static snapshot of a GitHub Issue. Please leave a comment and after reviewing it, I'll rebuild the site with it.

Footnotes and References


1

Besides YouTube, tutorials are a bit lacking here and tend to focus on the improvements of Lucid over another package Blaze HTML. Nevertheless, Monday Morning Haskell’s tutorial is a good start. Chris Done’s post goes into other benefits and subtleties around how the Html monad works, as well as the Hackage homepage.

Note: I prefer to use the original lucid v1

2

The following some additional features that may improve the editor (and make it harder to work around):

  • Prevent the user from going back to edit earlier portions of the block (e.g., prevent scrolling up, prevent moving the cursor back).
  • Hotkeys for forcing the closure of a box (e.g., ctrl + enter).
  • Prevent deletion of text (e.g., backspacing or cutting text).
3

To get HTMX bindings for Lucid, there’s this package, but it clashed with my dependencies. However, making your own attributes in Lucid is very easy, here are the ones I made for this project:

hxPost_ :: Text -> Attribute
hxPost_ = makeAttribute "hx-post"

hxTrigger_ :: Text -> Attribute
hxTrigger_ = makeAttribute "hx-trigger"

hxTarget_ :: Text -> Attribute
hxTarget_ = makeAttribute "hx-target"

hxSwap_ :: Text -> Attribute
hxSwap_ = makeAttribute "hx-swap"
4

Even with the OverloadedStrings extension set, one will likely find themselves managing conversions between the following types:

  • HTML (i.e., type HTML = Html () from Lucid): generally used with toHTML to set innerText.
  • Text (i.e., from Data.Text): generally used to set attribute values.
  • String: generally the outcome of building up endpoints, e.g., "/" ++ show timeRemaining ++ "/tick".

And if you’re a bit too obsessive about readability like me, you’ll painstakingly try to avoid writing expressions like:

div_ [id_ . pack $ containerId]`
-- or
div_ [hxTarget_ . pack $ "#" ++ containerId]

So I’ve created helpers like:

idAttr :: String -> Attributes
idAttr = id_ . pack

-- to be able to write: div_ [idAttr containerId]`

toIdSelector :: String -> Text
toIdSelector idStr = pack $ "#" ++ idStr

-- to be able to write: div_ [hxTarget_ . toIdSelector $ containerId]`
5

In Lucid v1, elements could be partially applied in the following sense.

baseTextArea :: HTML -> HTML
baseTextArea =
  textarea_
    [ name_ "segments",
      cols_ "100"
    ]

And used like so:

readOnlyBlock :: Int -> Text -> HTML
readOnlyBlock numRows box =
  with
    baseTextArea
    [ rows_ . toText $ numRows,
      readonly_ ""
    ]
    $ toHtml box

Lucid v2 uses a builder style, e.g.,

baseTextArea :: [Attributes] -> HTML -> HTML
baseTextArea attrs =
  textarea_ $
    name_ "boxes" : cols_ "100" : attrs

Usable without the with function, i.e.,

readOnlyBlock :: Int -> Text -> HTML
readOnlyBlock numRows box =
  baseTextArea
    [ rows_ . toText $ numRows,
      readonly_ ""
    ]
    $ toHtml box

However, generalising takes a bit more work, but leads to more readable composition, e.g.,

-- More intuitive
type Element = HTML -> HTML

withId :: Element -> String -> Element
withId element idStr = with element [id_ . pack $ idStr]

v1 :: HTML
v1 = withId div_ "someId" "innerText"

-- Less readable
v1MoreAttrs :: HTML
v1MoreAttrs = with (withId div_ "someId") [class_ "more attrs"] "innerText"

vs

-- Trickier
withId :: ([Attributes] -> t) -> String -> ([Attributes] -> t)
withId element idStr attrs = element $ (id_ . pack $ idStr) : attrs

v2 :: HTML
v2 = withId div_ "someId" [] "innerText"

-- More readable
v2MoreAttrs :: HTML
v2MoreAttrs = withId div_ "someId" [class_ "more attrs"] "innerText"

And also some other features for free, e.g.,

v1 :: HTML
v1 =
  ul_ $ do
     mapM_ (li_ . toHtml . show) ([1, 2, 3] :: [Int])

vs

v2 :: HTML
v2 = ul_ $ mapM_ (li_ . toHtml . show) [1, 2, 3]

See this changelog and creator’s blog post for more details.

6

I sometimes still use Lucid v1 for backwards compatibility, e.g., in this very website, which is needed for compatibility with hlucid-svg (as far as I remember).

If you want to reuse my Main.hs function with Lucid v1, just replace import qualified Servant.API.ContentTypes.Lucid as SL with import qualified Servant.HTML.Lucid as SL.