htmx and hyperscript

Introduction

As IHP itself is a hypermedia based tool, defaulting on hyperlinks, forms and RESTful routes, it works well with htmx and hyperscript.

These tools can in many cases entirely replace the need for adding Single Page Applications (SPA) and npm to your workflow.

Instead of the separation of concerns paradigm, htmx and hyperscript favours Locality of Behaviour.

To learn more, htmx has an excellent collection of essays about using the hypermedia approach instead of the current SPA paradigm.

To have htmx and hyperscript play well with Turbolinks page transitions, you can add this to your main javascript file.

document.addEventListener('turbolinks:load', () => {
    htmx.process(document.body);
    _hyperscript.processNode(document.body);
});

This makes sure that htmx and hyperscript code does not stop working after a Turbolinks page transition or an AutoRefresh.

htmx

htmx gives you access to AJAX, as a way to update views locally with an API conviently residing in HTML attributes.

Instead of the typical Single Page Application pattern parsing JSON and turning into html in the DOM, the API endpoints simply just return HTML to be patched into the DOM.

Installation

The recommended way of installing htmx is downloading htmx.min.js from unpkg.com and save it to static/vendor/htmx.min.js in your project directory.

Then add it to your Web/View/Layout.hs file before your app.js import.

scripts :: Html
scripts = [hsx|
        {when isDevelopment devScripts}
        ...
        <script src={assetPath "/vendor/htmx.min.js"}></script>
        <script src={assetPath "/helpers.js"}></script>
        <script src={assetPath "/ihp-auto-refresh.js"}></script>
        <script src={assetPath "/app.js"}></script>
    |]

htmx usage

Assume the simple controller as an example:

data CounterController
    = CounterAction
    | IncrementCountAction {counterId :: !(Id Counter)}
    | DecrementCountAction {counterId :: !(Id Counter)}
    deriving (Eq, Show, Data)

Add parseRoute @CounterController to the list of instance FrontController WebApplication in FrontController.hs (or you’ll get a 404 on calling it), and add an instance AutoRoute CounterController to Routes.hs (or you’ll get a compilation error about it not being an instance of AutoRoute).

Instead of using the render function, htmx routes are better used with respondHtml to avoid the layout being shipped as part of the response. The same function can be used for initializing the view as well as upating.

module Web.Controller.Counter where
import Web.Controller.Prelude
import Web.View.Counter.Counter

instance Controller CounterController where
    action CounterAction = do
        maybeCounter <- query @Counter |> fetchOneOrNothing
        case maybeCounter of
            Nothing -> do
                counter <- newRecord @Counter |> set #count 0 |> createRecord
                render CounterView {..}
            Just counter ->
                render CounterView {..}

    action IncrementCountAction{counterId} = do
        counter <- fetch counterId
        updatedCounter <- counter |> incrementField #count |> updateRecord
        respondHtml $ counterView updatedCounter

    action DecrementCountAction{counterId} = do
        counter <- fetch counterId
        updatedCounter <- counter |> decrementField #count |> updateRecord
        respondHtml $ counterView updatedCounter

We define the CounterView like this, separating the counterView function into a function that can be used be the initial view as well as the updater routes (IncrementCountAction and DecrementCountAction).

module Web.View.Counter.Counter where
import Web.View.Prelude

data CounterView = CounterView {counter :: Counter}

instance View CounterView where
    html CounterView{..} = [hsx|
    <h2>htmx counter</h2>
    {counterView counter}
    <button
        hx-post={IncrementCountAction counter.id}
        hx-target="#counter"
    >
        Increment
    </button>
    <button
        hx-post={DecrementCountAction counter.id}
        hx-target="#counter"
    >
        Decrement
    </button>
|]

counterView :: Counter -> Html
counterView counter = [hsx|<div id="counter">Count: {counter.count}</div>|]

The #counter element will then be fully replaced with the response from the increment and decrement routes with the database as the source of truth.

With the help of REST controllers returning the html output of an hsx function, one can make dynamic sites without integrating complicated frontend tooling.

This is what is known as Hypermedia as the Engine of Application State (HATEOAS).

hyperscript

hyperscript is commonly used as a companion library for htmx as an alternative to js with a human readable syntax and Locality of behaviour.

Installation

The recommended way of installing htmx is downloading _hyperscript.min.js from unpkg.com and save it to static/vendor/_hyperscript.min.js in your project directory.

Then add it to your Web/View/Layout.hs file before your app.js import.

scripts :: Html
scripts = [hsx|
        {when isDevelopment devScripts}
        ...
        <script src={assetPath "/vendor/_hyperscript.min.js"}></script>
        <script src={assetPath "/helpers.js"}></script>
        <script src={assetPath "/ihp-auto-refresh.js"}></script>
        <script src={assetPath "/app.js"}></script>
    |]

hyperscript usage

The default _ attribute is currently not supported by HSX, but using the data-script attribute works equally well.

HSX also supports multi line attribute strings out of the box, giving you more readable formatting.

The following example shows you how to call a js function with js and manipulating the innerText of the button itself.

clickAlert :: Html
clickAlert = [hsx|
    <button
        class="btn"
        data-script="
            on click
                log 'Button clicked'
            then
                js alert('Thank you for the click')
            end
            then
                set me.innerText to 'Already clicked'
        "
    >
        Not yet clicked
    </button>
|]