Go, Templ and HTMX: Out Of Band Swaps

@mrchip53

Go is great but not perfect


I have really started to appreciate developing applications with Go but I will always admit when I think something could be done better. One of the parts of the Go standard library that I think may be a little too rudimentary is the html/templates package.

While developing this blog with htmx, I realized I would like to make components that can be conditionally rendered with minimal effort. I was able to achieve this using html/templates for conditionally rendering the entire html page or just the content section that was being updated via htmx. This is great but I wanted more!

html/templates, why?


I haven’t really been a big fan of the html/templates’ syntax. I think it can start to get cluttered with larger templates, although maybe I’m just using it wrong. Either way, I’ve been feeling like it’s time to move on for a while now.

The issue that I needed to solve with my html/template implementation is that everything outside of the content container only gets rendered on initial load and never again. This can easily be solved with out of band swaps using htmx but I was unsure of how to cleanly work this design into my html/templates templates.

My initial reaction was to avoid out of band swaps altogether when it came to updating my page’s title element with htmx extensions. After adding accounts to the blog, I determined this was not a feasible long-term approach. I can no longer kick the can down the road. I need my navbar updated when people login and I need better templating to cleanly do it!

To circle back around, I started this journey so I can update my navbar and my page content at the same time using htmx without a full page reload/postback.

For quick background, the login flow of my blog is the following:

  1. Enter details and click login on login page
  2. Form posts to login post route
    • If successful, redirect to home page. If done with htmx, only update the content div, navbar div and page title.
    • If failed, send back an error message/toast.
  3. ???
  4. Profit!

So let’s look at what the html/templates template was doing before adding the navbar reload.

base.html:

 1{{ if not .isHXRequest }}
 2<!DOCTYPE html>
 3<html lang="en">
 4
 5<head>
 6  <meta charset="UTF-8">
 7  <title>{{ .title }}</title>
 8  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
 9  <link rel="preconnect" href="https://fonts.googleapis.com">
10  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11  <!-- <link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet"> -->
12  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap" rel="stylesheet">
13  <link rel="stylesheet" href="/css/main.css">
14  <link id="theme" rel="stylesheet" href="/css/themes/{{ .theme }}.css">
15  <script src="/js/hyperscript.min.js"></script>
16  <script src="/js/htmx.min.js"></script>
17  <script src="/js/htmx.title.js"></script>
18  <script src="/js/htmx.theme.js"></script>
19</head>
20
21<body hx-ext="title,theme">
22  <nav class="flex justify-center items-center py-3 sticky top-0 bg-neutral-800 z-50 md:flex-nowrap flex-wrap">
23    <div class="md:w-1/2 w-5/6">
24      <div class="flex">
25      <div hx-boost="true" hx-target="#main-container" hx-swap="innerHTML swap:300ms settle:300ms show:window:top"
26         class="flex md:flex-nowrap flex-wrap items-center justify-between w-full mr-auto ml-auto">
27        <a class="text-xl py-[0.3125rem] mr-4 no-highlights" href="/">blog.simoni.dev</a>
28        <input class="peer hidden" type="checkbox" id="navbar-check"
29          _="on change debounced at 150ms
30            if my.checked then
31              add { height: 0px; } to #navbar then
32              remove .collapsed from #navbar then
33              add .collapsing to #navbar then
34              measure #navbar scrollHeight then
35              add { height: ${scrollHeight}px; } to #navbar then
36              settle then
37              add .collapsed to #navbar then
38              add .show to #navbar then
39              remove .collapsing from #navbar
40            else
41              remove .collapsed from #navbar then
42              remove .show from #navbar then
43              add .collapsing to #navbar then
44              add { height: 0px; } to #navbar then
45              settle then
46              add .collapsed to #navbar then
47              remove .collapsing from #navbar
48            end" />
49
50        <label class="md:hidden peer-checked:[&>#bar1]:translate-y-2 peer-checked:[&>#bar1]:rotate-45 peer-checked:[&>#bar2]:opacity-0 peer-checked:[&>#bar2]:translate-x-4 peer-checked:[&>#bar3]:-translate-y-2 peer-checked:[&>#bar3]:-rotate-45" for="navbar-check">
51          <div id="bar1" class="my-1 h-1 w-6 bg-white rounded-full transition-transform duration-300"></div>
52          <div id="bar2" class="my-1 h-1 w-6 bg-white rounded-full transition-all duration-300"></div>
53          <div id="bar3" class="my-1 h-1 w-6 bg-white rounded-full transition-transform duration-300"></div>
54        </label>
55
56        <div id="navbar" class="flex md:basis-auto basis-full flex-grow-[1] items-center collapsed md:!h-auto md:!flex">
57          <ul class="flex flex-col pl-0 mb-0 list-none mt-2 md:mt-0 me-auto md:flex-row">
58            <li
59                    class="flex pt-1 mr-6 md:hover:text-purple-600 active:text-purple-600 md:hover:border-b-purple-600 border-b-2 border-b-transparent transition-all">
60              <a class="flex py-4 md:py-0 items-center static text-xl no-highlights" href="/">Home</a>
61            </li>
62            <li
63                    hx-boost="false"
64                    class="flex pt-1 mr-6 md:hover:text-purple-600 active:text-purple-600 md:hover:border-b-purple-600 border-b-2 border-b-transparent transition-all">
65              <a class="flex py-4 md:py-0 items-center static text-xl no-highlights" href="https://simoni.dev/">Portfolio</a>
66            </li>
67            <li
68                    class="hidden flex pt-1 mr-6 hover:text-purple-600 hover:border-b-purple-600 border-b-2 border-b-transparent transition-all">
69              <a class="flex py-4 md:py-0 items-center static text-xl" href="/login.html">Log in</a>
70            </li>
71          </ul>
72        </div>
73      </div>
74<!--      <button hx-get="/settings" class="flex items-center justify-center hover:shadow-lg w-8 h-8 rounded-full mr-2 flex-grow-0 flex-shrink-0 bg-gray-500">-->
75<!--        {{ .initials }}-->
76<!--      </button>-->
77    </div>
78    </div>
79  </nav>
80  <div id="main-container" class="flex flex-col gap-10 w-full items-center my-10" hx-boost="true" hx-target="#main-container" hx-swap="innerHTML swap:300ms settle:300ms show:window:top">
81    {{ end }}
82
83      {{ template "content" . }}
84
85    {{ if not .isHXRequest }}
86  </div>
87
88  <div id="toastContainer" class="fixed flex flex-col gap-2 bottom-0 right-0 p-3"></div>
89</body>
90
91</html>
92{{ end }}

You can see a couple lines with {{ if not .isHXRequest }}. This is saying if we are not an htmx request then include this html otherwise exclude it. The navbar is in one of these blocks, uh-oh! I could add more if/else statements and really muddy this thing up or I could start componentizing the html and then dynamically serve certain components depending on the request type. I decided on the latter and determined html/templates was not clean enough to do it.

The {{ template "content" . }} is the content that goes inside the content container and gets sent over no matter the type of request.

Here is the template used for the index page.

index.html:

 1{{ define "content" }}
 2    {{ $adminRoute := .adminRoute }}
 3    {{ $canDelete := .canDelete }}
 4    {{ range .posts }}
 5        <section class="md:w-1/2 w-5/6">
 6            <div class="flex">
 7                <h1 class="mb-4">
 8                    <a class="hover:underline" href="{{ . | getSlug }}">{{ .Title }}</a>
 9                </h1>
10                {{ if $.canDelete }}
11                    <button hx-delete="{{ $.adminRoute }}/post/{{ .ID }}" hx-target="#main-container" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" aria-label="Delete Post">
12                        <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
13                            <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
14                        </svg>
15                    </button>
16                {{ end }}
17            </div>
18            <h2 class="mb-6 text-gray-400">
19                <a class="hover:underline" href="/user/{{ .Author }}">@{{ .Author }}</a>
20            </h2>
21            <div class="mb-4 text-2xl">
22                {{ .Description }}
23            </div>
24            <div class="flex flex-wrap gap-4 text-xl">
25                <span class="text-gray-400">
26                    {{ .CreatedAt | formatAsDateTime }}
27                </span>
28                {{ range .Tags }}
29                <a class="text-purple-600 hover:underline hover:text-purple-400 transition" href="/tag/{{ .Name }}">
30                    #{{ .Name }}
31                </a>
32                {{ end }}
33            </div>
34        </section>
35    {{ end }}
36    {{ if .noPosts }}
37        <div class="text-4xl">
38            No posts yet!
39        </div>
40    {{ end }}
41{{ end }}

Using gin and multitemplate, this all gets tied together with a line like this:

1r := multitemplate.NewRenderer()
2
3r.AddFromFilesFuncs("index", funcMap, basePath, path.Join(templatePath, "index.html"))

I think that is a lot and rather hideous too.

In comes templ


I had recently been referred to templ by a friend but had put off looking into it due to time. After finding these flaws in html/templates I decided to take a dive in.

Templ seems to use its own “syntax” that looks very similar to Go in its templates then uses a cli tool to convert those templates to actual Go source files. This was off putting to me at first, but I quickly adjusted to the new workflow. It also allows templates to support if/else statements and for loops among other things. My templates are now much cleaner and more dynamic than ever!

Here is the same base file as a templ template.

BasePage.templ:

 1package pages
 2
 3import "blog.simoni.dev/templates/components"
 4import "blog.simoni.dev/templates"
 5
 6templ Base() {
 7    <!DOCTYPE html>
 8    <html lang="en">
 9
10    <head>
11      <meta charset="UTF-8" />
12      @components.Title(false)
13      <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
14      <link rel="preconnect" href="https://fonts.googleapis.com" />
15      <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
16      <!-- <link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet"> -->
17      <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap" rel="stylesheet" />
18      <link rel="stylesheet" href="/css/main.css" />
19      <link id="theme" rel="stylesheet" href={ templates.GetThemeLink(ctx) } />
20      <script src="/js/hyperscript.min.js"></script>
21      <script src="/js/htmx.min.js"></script>
22      <script src="/js/htmx.title.js"></script>
23      <script src="/js/htmx.theme.js"></script>
24      <script type="text/javascript">
25          function copyToClipboard(text) {
26            navigator.clipboard.writeText(text).then(function () {
27              console.log("Copied to clipboard");
28            }, function (err) {
29              console.log("Failed to copy to clipboard");
30            });
31          }
32        </script>
33    </head>
34
35    <body hx-ext="theme">
36      @components.Navbar(false)
37      <div id="main-container" class="flex flex-col gap-10 w-full items-center my-10" hx-boost="true" hx-target="#main-container" hx-swap="innerHTML swap:300ms settle:300ms show:window:top">
38        { children... }
39      </div>
40      <div id="toastContainer" class="fixed flex flex-col gap-2 bottom-0 right-0 p-3"></div>
41    </body>
42    </html>
43}

You can see I have been able to immediately break out my navbar and title into components and templ even understands the concept of children!

My index template now works differently because of this.

Index.templ:

 1package pages
 2
 3import (
 4    "blog.simoni.dev/templates/components"
 5    "blog.simoni.dev/templates"
 6    "blog.simoni.dev/models"
 7)
 8
 9templ IndexPage(posts []models.BlogPost, canDelete bool) {
10    if templates.IsHxRequest(ctx) {
11        @HxPage() {
12            @IndexContent(posts, canDelete)
13        }
14    } else {
15        @Base() {
16            @IndexContent(posts, canDelete)
17        }
18    }
19}
20
21templ IndexContent(posts []models.BlogPost, canDelete bool) {
22    for _, post := range posts {
23        <section class="md:w-1/2 w-5/6">
24            <div class="flex">
25                <h1 class="mb-4">
26                    <a class="hover:underline" href={ templates.GetPostSlug(post) }>{ post.Title }</a>
27                </h1>
28                if canDelete {
29                    <button hx-delete={ templates.GetDeletePostLink(templates.GetAdminRoute(ctx), post.ID) } hx-target="#main-container" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" aria-label="Delete Post">
30                        <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
31                            <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
32                        </svg>
33                    </button>
34                }
35            </div>
36            <h2 class="mb-6 text-gray-400">
37                <a class="hover:underline" href={ templates.GetUserLink(post.Author) }>&commat;{ post.Author }</a>
38            </h2>
39            <div class="mb-4 text-2xl">
40                { post.Description }
41            </div>
42            <div class="flex flex-wrap gap-4 text-xl items-center">
43                <span class="text-gray-400">
44                    { templates.FormatAsDateTime(post.CreatedAt) }
45                </span>
46                for _, tag := range post.Tags {
47                    @components.TagLink(tag)
48                }
49            </div>
50        </section>
51    }
52    if len(posts) == 0 {
53        <div class="text-4xl">
54            No posts yet!
55        </div>
56    }
57}

Now the index page template itself checks if it is an htmx request and determines what to do. Awesome! Using this flow, I can now wrap my main content component with different base pages depending on the request type.

So how do we update our navbar and title for htmx request then? We make a new base page dedicated for htmx request, of course!

HxPage.templ:

1package pages
2
3import "blog.simoni.dev/templates/components"
4
5templ HxPage() {
6    @components.Title(true)
7    @components.Navbar(true)
8    { children... }
9}

Now any page components we want to refresh on htmx requests can go in here and be out of band swapped using the hx-swap-oob html attribute like the title. Goodbye title plugin I made!

Title.templ:

 1package components
 2
 3import "blog.simoni.dev/templates"
 4
 5templ Title(oobSwap bool) {
 6    <title id="pageTitle"
 7        if oobSwap {
 8            hx-swap-oob="true"
 9        }
10    >{ templates.GetPageTitle(ctx) }</title>
11}

What next?

In conclusion, while html/templates does seem to be cumbersome to work with in certain scenarios, templ is great! It allows easy componentization of your html similar to React I feel.

I will definitely be playing more with this tech stack and discovering more of its potential as time goes on!

01/05/2024 7:05 PM

Comments

You must be logged in to comment.
This post has no comments. Be the first!