{
"$type": "site.standard.document",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiek6y2duvbc5qe2twes6obxkwwslgphr7g5lhhyvan3pmywkzeajy"
},
"mimeType": "image/png",
"size": 40555
},
"description": "People talk about htmx as though it's saving the web from single-page apps. Well, I guess I missed the memo, because I used htmx to build a single-page app.",
"path": "/words/building-a-single-page-app-with-htmx/",
"publishedAt": "2024-10-07T00:00:00Z",
"site": "at://did:plc:vrrdgcidwpvn4omvn7uuufoo/site.standard.publication/3mmyfl3pxzi2a",
"tags": [
"htmx",
"javascript"
],
"textContent": "People talk about htmx as though it's saving the web from single-page apps.\nReact has mired developers in complexity (so the story goes) and htmx is offering a desperately-needed lifeline.\n\nhtmx creator Carson Gross wryly explains the dynamic like this:\n\nno, this is a Hegelian dialectic:\n\nthesis: traditional MPAs\n\nantithesis: SPAs\n\nsynthesis (higher form): hypermedia-driven applications w/ islands of intereactivity\n\nWell, I guess I missed the memo, because I used htmx to build a single-page app.\n\nIt's a simple proof of concept todo list.\nOnce the page is loaded, there is no additional communication with a server.\nEverything happens locally on the client.\n\nHow does that work, given that htmx is focused on managing hypermedia exchanges over the network?\n\nWith one simple trick: the \"server-side\" code runs in a service worker.\n\nBriefly, a service worker acts as a proxy between a webpage and the wider Internet.\nIt intercepts network requests and allows you to manipulate them.\nYou can alter requests, cache responses to be served offline or even create new responses out of whole cloth without ever sending the request beyond the browser.\n\nThat last capability is what powers this single-page app.\nWhen htmx makes a network request, the service worker intercepts it.\nThe service worker then runs the business logic and generates new HTML, which htmx then swaps into the DOM.\n\nThere are a couple of advantages over a traditional single-page app built with something like React, too.\nService workers must use IndexedDB for storage, which is stateful between page loads.\nIf you close the page and then come back, the app retains your data — this happens \"for free\", a pit of success consequence of choosing this architecture.\nThe app also works offline, which doesn't come for free but is pretty easy to add once the service worker is set up already.\n\nOf course, service workers have a bunch of pitfalls as well.\nOne is the absolutely abysmal support in developer tools, which seem to intermittently swallow console.log and unreliably report when a service worker is installed.\nAnother is the lack of support for ES modules in Firefox, which forced me to put all my code (including a vendored version of IDB Keyval, which I included because IndexedDB is similarly annoying) in a single file.\n\nThis is not an exhaustive list!\nI would describe the general experience of working with service workers as \"not fun\".\n\nBut!\nIn spite of all that, the htmx single-page app works.\nLet's dive in!\n\nBehind the Scenes\n\nLet's start with the HTML:\n\nThis should look familiar if you've ever built a single-page app: the empty husk of an HTML document, waiting to be filled in by JavaScript.\nThat long inline <script> tag just sets up the service worker and is mostly stolen from MDN.\n\nThe interesting bit here is the <body> tag, which uses htmx to set up the meat of the app:\n\nhx-boost=\"true\" tells htmx to use Ajax to swap in the responses of link clicks and form submissions without a full page navigation\n\nhx-push-url=\"false\" prevents htmx from updating the URL in response to said link clicks and form submissions\n\nhx-get=\"./ui\" tells htmx to load the page at /ui and swap it in\n\nhx-target=\"body\" tells htmx to swap the results into the <body> element\n\nhx-trigger=\"load\" tells htmx that it should do all this when the page loads\n\nSo basically: /ui returns the actual markup for the app, at which point htmx takes over any links and forms to make it interactive.\n\nWhat's at /ui?\nEnter the service worker!\nIt uses a small home-brewed Express-like \"library\" to handle boilerplate around routing requests and returning responses.\nHow that library actually works is beyond the scope of this post, but it's used like this:\n\nWhen a GET request is made to /ui, this code…\n\ngrabs the query string for the filter\n\nsaves the filter in IndexedDB\n\ntells htmx to update the URL accordingly\n\nrenders the App \"component\" to HTML with the active filter and list of todos\n\nreturns the rendered HTML to the browser\n\nsetFilter and listTodos are pretty simple functions that wrap IDB Keyval:\n\nThe App component looks like this:\n\n(As before, we'll skip some of the utility functions like html, which just provides some small conveniences when interpolating values.)\n\nApp can be broken down into roughly three sections:\n\nThe filters form.\nThis renders a radio button for each filter.\nWhen a radio button changes, it submits the form to /ui, which re-renders the app using the steps described above.\nThehx-boost attribute from before intercepts the form submission and swaps the response back into the <body> without refreshing the page.\n\nThe todos list.\nThis loops over all the todos matching the current filter, rendering each using the Todo component.\n\nThe add todo form.\nThis is a form with an input that submits the value to /todos/add.\nhx-target=\".todos\" tells htmx to replace an element on the page with class todos; hx-select=\".todos\" tells htmx that rather than using the entire response, it should just use an element with class todos.\n\nLet's take a look at that /todos/add route:\n\nPretty simple!\nIt just saves the todo and returns a response with the re-rendered UI, which htmx thens swap into the DOM.\n\nNow, let's look at that Todo component from before:\n\nThere are three main parts here: the checkbox, the delete button and the todo text.\n\nFirst, the checkbox.\nIt triggers a GET request to /todos/${id}/update every time it's checked or unchecked, with a query string done matching its current state; htmx swaps the full response into the <body>.\n\nHere's the code for that route:\n\n(Notice that the route also supports changing the todo text. We'll get to that in a minute.)\n\nThe delete button is even simpler: it makes a DELETE request to /todos/${id}.\nAs with the checkbox, htmx swaps the full response into the <body>.\n\nHere's that route:\n\nThe final part is the todo text, which is made more complicated by the support for editing the text.\nThere are two possible states: \"normal\", which just displays a simple <span> with the todo text (I'm sorry that this isn't accessible!) and \"editing\", which displays an <input> that allows the user to edit it.\nThe Todo component uses the editing \"prop\" to determine which state to render.\n\nUnlike in a client-side framework like React, though, we can't just toggle state somewhere and have it make the necessary DOM changes.\nhtmx makes a network request for the new UI, and we need to return a hypermedia response that it can then swap into the DOM.\n\nHere's the route:\n\nAt a high level, the coordination between webpage and service worker looks something like this:\n\nhtmx listens for double-click events on todo text <span>s\n\nhtmx makes a request to /ui/todos/${id}?editable=true\n\nThe service worker returns the HTML for the Todo component that includes the <input> rather than the <span>\n\nhtmx swaps the current todo list item with the HTML from the response\n\nWhen the user changes the input, a similar process happens, calling the /todos/${id}/update endpoint instead and swapping the whole <body>.\nIf you've used htmx, this should be a pretty familiar pattern.\n\nThat's it!\nWe now have a single-page app built with htmx (and service workers) that doesn't rely on a remote web server.\nThe code I omitted for brevity is available on GitHub.\n\nTakeaways\n\nSo, this technically works.\nIs it a good idea?\nIs it the apotheosis of hypermedia-based applications?\nShould we abandon React and build apps like this?\n\nhtmx works by adding indirection to the UI, loading new HTML from across a network boundary.\nThat can make sense in a client-server app, because it reduces indirection with regard to the database by colocating it with rendering.\nOn the other hand, the client-server story in a framework like React can be painful, requiring careful coordination between clients and servers via an awkward data exchange channel.\n\nWhen all interactions are local, though, the rendering and data are already colocated (in memory) and updating them in tandem with a framework like React is easy and synchronous.\nIn this case, the indirection that htmx requires starts to feel more burdensome than liberatory.\nFor fully local apps, I don't think juice is worth the squeeze.\n\nOf course, most apps aren't fully local — usually, there's a mix of local interactions and network requests.\nMy sense is that even in that case, islands of interactivity is a better pattern than splitting your \"server-side\" code between the service worker and the actual server.\n\nIn any event, this was mostly an exercise to see what it might look like to build a fully local single-page app using hypermedia, rather than imperative or functional programming.\n\nNote that hypermedia is a technique rather than a specific tool.\nI chose htmx because it's the hypermedia ~~library~~ framework du jour, and I wanted to stretch it as far as I could.\nThere are other tools like Mavo that explicitly focus on this use case, and indeed you can see that the Mavo implementation of TodoMVC is far simpler than what I've built here.\nBetter still would be some sort of HyperCard-esque app in which you could build the whole thing visually.\n\nAll in all, my little single-page htmx todo app was fun to build.\nIf nothing else, take this as a reminder that you can and should occasionally try using your tools in weird and unexpected ways!",
"title": "Building a Single-Page App with htmx"
}