Simple Interactive Islands with F# and HTMX

Date: 2023-12-22 | create | tech | fsharp | htmx | giraffe | html |

HTMX is a tiny JS library that gives your HTML superpowers - allowing it to act more like a modern interactive app than a crufty old enterprise page. It does this by allowing you to swap out only bits of a page rather than requiring a full reload to change the UI (this is what those fancy clientside frameworks like Svelte, React, Vue, etc are doing under the hood to make them feel "modern"). This essentially means that you can build modern apps without any clientside framework - just you and your server-side rendered HTML/X.

I've recently been doing a lot of server-side HTML rendering with F# (in fact that's what's currently running this site). In this post we'll explore how to integrate HTMX into F# / Giraffe server-side rendered pages to give them superpowers.

Q: How can you integrate HTMX with F# / Giraffe server-side rendered HTML pages?

Answer

In this post we'll be building a simple example app that utilizes HTMX for pagination. The paradigm we'll be using is "Interactive Islands" which allows us to sprinkle extra interactivity where it's necessary without it invading our entire application (this is a similar approach to Astro).

HTMX - Interactive Islands

The example app does this:

  • On page load: (normal HTML load)
    • Creates several random pieces of data and saves to DB
    • Returns a table containing paginated data based on the URL's page param
  • Pagination (HTMX)
    • On Next / Previous button clicks - makes HTMX request for just the table component and swaps it out

This will provide a simple overview of how you can mix your normal HTML loads with Interactive Islands powered by HTMX.

HTMX - Example Project

The example app is composed of:

This example app is obvs overkill for this example but it provides a semi-realistic environment from which to build from. Diving into these technologies / paradigms is out of scope for this post so checkout the linked guides if interested.

I'll be sharing source code to highlight how things work but if you'd like the full project code you can get that (along with dozens of other projects) in my Example Project Repo, available to HAMINIONs subscribers.

Note: My F# project boilerplate CloudSeed has recently been ported to F# / Giraffe w server-side HTML/X as well if you want to start using it to build your own projects.

Installing HTMX

The first thing we gotta do is install HTMX. This is simple as it's just a tiny JS package served via CDN so we can simply include it in a script tag at the top of our page.

<script src="https://unpkg.com/htmx.org@1.9.9"></script>

Once that's in there, you're good to go with HTMX! Simple!

HTMX - Example Screenshot

Rendering Interactive Islands with HTMX

Now that we have HTMX available, let's build up our little HTML table.

The way I'm thinking of HTML/X Targets is Interactive Islands. We can think of each of these Islands as a unit of interactivity that's semi-self contained. HTMX calls this a "Target" and this seems similar to me to what we might call a Component in mainstream clientside frameworks.

The main difference I see between Targets / Islands and typical clientside Components is that a Target / Island is typically a larger, self-contained Component that itself may contain many subcomponents underneath it. This is because there's a bit of overhead involved with Target / Island creation.

So some examples:

  • A button is probably too small to warrant being a target / Island - we wouldn't want to make requests to switch out each button on the page
  • A page section / widget is probably a good granularity to warrant being a target / island as we get a good amount of interactivity with a single request. You could imagine that a given page section may contain many components itself (buttons, UI elements, content, etc).

This is my current mental model but I'm still playing around with this stuff so that may change in the future.

For our simple example page, we really only have two targets:

  • Main Page - This is the full page render (what you'd expect on a normal request). While not technically a Target I like to be explicit and handle all cases so I'm handling the default here.
  • SentinelTable - This is the data table we'll be rendering. It includes the table data itself along with the buttons so that it's a self-contained Interactive Island that both displays its data and provides controls for updating it.

Target Parsing from Request

So how do we know what Target the page is trying to get from us?

The best way I've found to do this is to rely on the headers HTMX attaches to requests it sends:

  • HX-Request - To determine if this is an HTMX request or not
  • HX-Target - To see what target (if any) this thing is trying to render

By utilizing these headers we can uniquely identify what the page is trying to re-render and route that to the Target we have registered for it.

In F# / Giraffe we can use a simple helper function I built to parse this from the request. This helper:

  • Checks both HX-Request and HX-Target headers
  • Tries to convert the string into an Enum which we will use later to route to the correct component with type safety (more on this later)

For more on how F# / Giraffe routing / payloads work, take a look at:

let getTargetFromContext
    (targetsStrEnum : StrEnum<'a>)
    (ctx : HttpContext)
    : 'a option
    =

    let hxRequest = ctx.TryGetRequestHeader "HX-Request"

    if Option.isNone hxRequest 
    then None 
    else

    ctx.TryGetRequestHeader "HX-Target"
    |> Option.bind (
        fun targetString -> targetsStrEnum.GetEnum(targetString)
    )

Rendering the page based on Targets

Now that we know how to get which Target the page is trying to render, let's look at how we selectively render them.

This is our page entrypoint which will handle full and Target re-renders. Note that I am using the same entrypoint to render the page and all its sub targets. This is in keeping with the idea of Interactive Islands where we are really building a simple page that just happens to support partial re-renders of its islands.

This may look weird at first but I think leads to much simpler state and view management. When we think of "reactive" or "functional" views, it really doesn't get much more functional than rendering conditionally based on passed-in params from the URL (URL as state source-of-truth).

This page:

  • Takes in some props like the Target we are trying to render and the Page / PageSize for pagination
  • Creates some sample data so we have stuff to render
  • Fetches the paginated results from the db
  • Renders the SentinelTable component (we'll show this rendering in the next section)
  • Creates the full html page document
  • Selectively returns the Target based on props utilizing our type-safe(!) Targets enum we touched on earlier (and which we'll dive into later)

Note: It's a bit wasteful to render both the full page and table component if we may not use both but I thought it was easier to read for this example and F# server-side HTML rendering is fast so doesn't really matter.

type MainPageProps = 
    {
        Target : MainPageTargets
        Page : int
        SentinelCount : int
    }

let renderMainPageAsync 
    (serviceTree : SentinelServiceTree) 
    (props : MainPageProps) 
    = 
    async {
        do!
            seq {0 .. 5}
            |> Seq.map (fun _ -> 
                sendCreateSentinelCommandAsync serviceTree)
            |> Async.Sequential 
            |> Async.Ignore
        
        let! sentinelResult = 
                sendGetSentinelsQueryAsync 
                    serviceTree 
                    { 
                        Page = props.Page
                        Count = props.SentinelCount 
                    }

        let sentinels = 
            match sentinelResult with
            | Ok s -> 
                s 
                |> Seq.toList
            | Error s -> raise (System.SystemException("Failed to get Sentinels"))

        let sentinelTable = 
            renderSentinelTableComponent 
                {
                    HasNext = true
                    Sentinels = sentinels
                    Page = props.Page
                }

        let template = 
            $"""
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <script src="https://unpkg.com/htmx.org@1.9.9"></script>
                <meta charset="UTF-8">
                <title>Sentinels Example</title>
            </head>
            <body>
                <main>
                    <h1>Sentinels Table</h1>  
                </main>
                { sentinelTable }
            </body>
            </html>
            """

        return 
            match props.Target with 
            | MainPageTargets.SentinelTable -> sentinelTable
            | MainPageTargets.FullPage -> template
    }

Rendering the Data Table

So that's how we're routing from the HTMX request to the target we want. Now we are going to dive into rendering the HTML which actually has the HTMX controls embedded in it and sends those requests.

Here we have a few things:

  • renderSentinelTablePagination - This renders the buttons which serve as our Hypermedia Controls for updating the SentinelTable Target
    • hx-get - This is how we send the HTMX request (like an a href)
    • hx-target - This is how we tell it what our Target is. Note that in this Interactive Island paradigm this is the same as the Island ID
    • hx-push-url - This allows us to update the url so we get the forward / back browser functionality we'd expect
  • renderSentinelTableComponent - Takes in the paginated data from our handler (above) and renders it into a table. This is the entrypoint to the SentinelTable Target

For a deeper dive into raw string HTML rendering with F#, check out: Build a Simple Markdown Blog with F# / Giraffe.

type SentinelTableComponentProps = 
    {
        HasNext : bool
        Page : int
        Sentinels : Sentinel list
    }

let renderSentinelTablePagination 
    (props : 
    {| 
        HasNext : bool 
        Page : int
    |})
    : string 
    =
    let sentinelTableId = mainPageTargetsStrEnum.GetString(MainPageTargets.SentinelTable)
    let previousButton = 
        if props.Page < 1
        then ""
        else 
            $"""
            <button 
                hx-get="/sentinels?page={props.Page - 1}"
                hx-target="#{sentinelTableId}"
                hx-push-url="true" >
                Previous
            </button>
            """

    let nextButton =
        if not props.HasNext
        then ""
        else 
            $"""
            <button 
                hx-get="/sentinels?page={props.Page + 1}"
                hx-target="#{sentinelTableId}"
                hx-push-url="true" >
                Next
            </button>
            """
    
    $"""
    { previousButton }
    { nextButton }
    """

let renderSentinelTableComponent
    (props : SentinelTableComponentProps)
    : string
    = 
    let sentinelRows = 
        htmlForEach
            props.Sentinels
            (fun sentinel -> 
                $"""
                <tr>
                    <td>{ sentinel.id }</td>
                    <td>{ sentinel.data.name }</td>
                </tr>
                """
            )

    let sentinelTableId = mainPageTargetsStrEnum.GetString(MainPageTargets.SentinelTable)
    $"""
    <div id="{ sentinelTableId }">
        <table>
            <tr>
                <th>ID</th>
                <th>Data</th>
            </tr>
            { sentinelRows }
        </table>
        %s{ 
            renderSentinelTablePagination 
                {|
                    HasNext = props.HasNext
                    Page = props.Page
                |}
        }
        <div>
        Page: { props.Page }
        </div>
    </div>
    """

Type-Safe Targets with Fsharp and HTMX

Okay so at this point we've walked through how to get HTMX on your page, rendering our HTML pages with embedded HTMX controls, and routing to the correct target based on HTMX header.

In this section I want to dive into how we're actually accomplishing Type-Safe targets with strings <> Enums because this was a bit of a faff to setup but I think is very important to leverage the full power of the F# type system. The reason this was hard is because F# does not natively support string Enums (something like the StrEnum in Python). This is not that surprising as C# does not do that either but it's a bit of a pain.

The reason this is a pain is that HTMX / HTML themselves are not very type-safe (they really just big strings). But it's absolutely critical to get these target ids and urls correct otherwise your whole page fails. This means most of these errors will be runtime errors (and thus relatively hard to catch unless you have tests covering them which usually means you find them in prod).

So we want to try and move at least some of the most important parts of HTMX into type-safe land so we can get build time errors instead of runtime errors.

For this I wanted to create a string enum that allows for easy parsing between:

  • string -> Enum - so can move from request data into type-safe code representation
  • Enum -> string - so can go from my type-safe code into the string we'll use in the HTML

This is how I did it:

  • StrEnum - Takes in an Enum and a map of Enum -> String and provides easy access to Enum -> String and String -> Enum converters
let parseEnum<'T when 'T :> Enum> (value : string) =
    System.Enum.Parse(typedefof<'T>, value, true) :?> 'T

type StrEnum<'TEnum when 'TEnum :> Enum>(enumToStringMap : 'a -> string) =
    
    let getAllCases =
        fun () ->
            System.Enum.GetNames(typeof<'TEnum>)

    let enumNameToStringLookup 
        = 
        getAllCases()
        |> Array.map (
            fun case -> 
                (case, (enumToStringMap (parseEnum<'TEnum> case)))
        )
        |> dict 

    let enumStringToNameLookup
        =
        getAllCases()
        |> Array.map (
            fun case -> 
                ((enumToStringMap (parseEnum<'TEnum> case)), case)
        )
        |> dict 

    member __.GetEnum (s : string) : 'TEnum option =
        if not (enumStringToNameLookup.ContainsKey(s))
        then None 
        else 

        let enumName = enumStringToNameLookup[s]
        Some (
            parseEnum<'TEnum> enumName
        )

    member __.GetString (e : 'TEnum) : string =
        enumNameToStringLookup[e.ToString()]

Using this to register the Targets for my page:

type MainPageTargets =
    | FullPage = 0
    | SentinelTable = 1

let mainPageTargetsStrEnum = StrEnum<MainPageTargets>(
    fun e ->
        match e with 
        | MainPageTargets.FullPage -> "full-page"
        | MainPageTargets.SentinelTable -> "sentinel-table"
)

Then utilizing these registered targets and StrEnum to get the type-safe Target from the F# / Giraffe request. (we saw this earlier but copypasting again for clarity)

let getTargetFromContext
    (targetsStrEnum : StrEnum<'a>)
    (ctx : HttpContext)
    : 'a option
    =

    let hxRequest = ctx.TryGetRequestHeader "HX-Request"

    if Option.isNone hxRequest 
    then None 
    else

    ctx.TryGetRequestHeader "HX-Target"
    |> Option.bind (
        fun targetString -> targetsStrEnum.GetEnum(targetString)
    )

Hopefully this helps if you're looking to build type-safe targets as well. Also hopefully I didn't totally overcomplicate this.

Let me know if you have been personally victimized by F#'s lack of String Enum support or if you have suggestions for making this easier. I'd love to find a better solution than this.

Next

That's it! A lil server-side rendered HTML page empowered by HTMX and built with F#.

Let me know if you have any Qs or if you have suggestions for doing this easier / better. I'm still learning / playing around with this stuff so I'm sure there's lots of ways to improve.

If you liked this post, you might also like:

If you're looking to build your own apps with F#, consider starting with CloudSeed - my F# project boilerplate.

Want more like this?

The best / easiest way to support my work is by subscribing for future updates and sharing with your network.