As I was exploring htmx, I wanted to try out an interactive workflow with it. A classic example is a data list, modal to create / update data and a delete.
Setup with Gofiber
Initialize go module and install dependencies
go mod init htmx-crud
go get github.com/gofiber/fiber/v2
go get github.com/gofiber/template/html/v2
Setup Server html template engine
main.go
package main
import (
"log"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/log"
"github.com/gofiber/template/html/v2"
)
func main() {
// Setup html template engine
engine := html.New("./views", ".html")
app := fiber.New(fiber.Config{
Views: engine,
})
// Get request to render index page
app.Get("/", func(c *fiber.Ctx) error {
// Render index template
return c.Render("index", nil, "layouts/main")
})
// Start server
log.Fatal(app.Listen(":3000"))
}
This is a simple server setup with gofiber. We are using html template engine to render the html.
for this initial setup, we need to create a this folder structure
htmx-crud
├── main.go
└── views
├── index.html
└── layouts
└── main.html
Setup the Views
views/layouts/main.html
A layout with bootstrap and htmx
- We are using bootstrap for styling
- Htmx is included from the cdn
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bootstrap demo</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
</head>
<body>
{{embed}}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
</body>
</html>
views/index.html
A simple index page with a list of items (using a reusable html for later), a button to add new item and a modal to add new item.
- A template that calls the partial list
- A button to issue request to get form and to open modal ( with some js ) after form is rendered
- A modal to render the form
<main>
<div class="container pb-5 mt-5">
<div class="row">
<div class="col">
<h1>List of Items</h1>
{{ template "partial/list" . }}
<!-- Button to swap the modal body ( this will trigger modal to open ) -->
<button type="button" class="btn btn-primary" hx-get="/form" hx-target="#modal-form" hx-swap="outerHTML">
Add Item
</button>
<!-- Modal to render the form -->
<div class="modal fade" id="modalItem" tabindex="-1">
<div class="modal-dialog">
<div id="modal-form"></div>
</div>
</div>
</div>
</div>
</div>
</main>
views/partial/list.html
A reusable html to list items
- A list of items will be passed down from the server
- we are triggering
hx-get="/list"
onlistUpdated
event, which can be triggered by sendingHx-Trigger: listUpdated
header with response from the server. Which can be used with form submission handlers.
<!-- List item block, get from /list when listUpdated event is triggered -->
<div class="item-list mb-3" hx-trigger="listUpdated from:body" hx-get="/list" hx-target="this">
<ul class="list-group">
<!-- Loop over the items from server -->
{{ range .Items }}
<li class="list-group-item">
<p>
{{ .Name }}
</p>
<!-- Button to swap modal content ( this swap will trigger modal open ) -->
<button class="btn btn-primary" hx-get="/form/{{ .Id }}" hx-target="#modal-form" hx-swap="outerHTML">Update</button>
<!-- Button to delete item -->
<button class="btn btn-danger" hx-delete="/item/{{ .Id }}" hx-confirm="You sure ?">Delete</button>
</li>
{{ end }}
</ul>
</div>
views/partial/form.html
A form to add / update item. This is the the modal body
- The form will be used for both add and update
- If Id is passed down, it’s for update, else it’s for add ( this is for simplicity, in real world, we can have separate forms )
<div class="modal-body" id="modal-form">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Add Item</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Form with hx-put if Id is available or else hx-post, it will replace response form ( when data is invalid ) or render nothing -->
<form {{ if .Id }}hx-put="/form/{{ .Id }}"{{ else }}hx-post="/form"{{ end }} hx-target="#modal-form" hx-swap="outerHTML">
<div class="modal-body">
<div class="mb-3">
<label for="item-input" class="form-label">Item</label>
<input
type="text"
id="item-input"
name="item"
class="form-control{{ if ne .ValueError nil }} is-invalid{{end}}"
value="{{ .Value }}"
>
<!-- If there is a value error, display it -->
{{ if ne .ValueError nil }}
<div class="invalid-feedback">
{{ .ValueError }}
</div>
{{end}}
</div>
</div>
<div class="modal-footer">
<!-- Close button and submit button -->
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
Setting up a store
A simple store to hold the data and some operations on it. for simplicity the ID is just length of the array + 1.
store.go
package main
type ItemStore struct {
Items []Item
}
type Item struct {
Id int
Name string
}
func (i *ItemStore) Create(name string) {
id := len(i.Items) + 1
i.Items = append(i.Items, Item{Id: id, Name: name})
}
func (i *ItemStore) Update(id int, name string) {
for k, v := range i.Items {
if v.Id == id {
i.Items[k].Name = name
}
}
}
func (i *ItemStore) Find(id int) Item {
for _, v := range i.Items {
if v.Id == id {
return v
}
}
return Item{}
}
func (i *ItemStore) Delete(id int) {
for k, v := range i.Items {
if v.Id == id {
i.Items = append(i.Items[:k], i.Items[k+1:]...)
}
}
}
This is used in the main.go to handle the requests.
itemStore := ItemStore{}
itemStore.Create("Item 1")
itemStore.Create("Item 2")
Setting up the routes
The /
route to render the index.html along with layout/main.html
- This will combine index and main layout and render the html
app.Get("/", func(c *fiber.Ctx) error {
// Render index within layouts/main
return c.Render("index", fiber.Map{
"Items": itemStore.Items,
}, "layouts/main")
})
The /list
route to return the list of items. This will be used by the list block triggered when hx-trigger="listUpdated from:body"
in views/partial/list.html
- Items are passed to the list block to render the list Initially.
// get list
app.Get("/list", func(c *fiber.Ctx) error {
return c.Render("partial/list", fiber.Map{
"Items": itemStore.Items,
})
})
The /form
routes to render the add and update form. This will be used by the add / update buttons.
- Add form will just render the modal body with empty values.
- Update form will render the modal body with the item values. (the Id will indicate the form is update form)
// get create form
app.Get("/form", func(c *fiber.Ctx) error {
return c.Render("partial/form", fiber.Map{})
})
// get update form
app.Get("/form/:id", func(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
item := itemStore.Find(id)
return c.Render("partial/form", fiber.Map{
"Id": item.Id,
"Value": item.Name,
})
})
The post and put routes of /form
to handle the form submission.
- Form submission handlers will validate the data and send the appropriate response.
- No content response is used in the frontend to indicate the form submission is successful.
- If the validation fails, the modal content will be re-rendered with the error message.
<form ... hx-target="#modal-form" hx-swap="outerHTML">
- These handlers will also send
Hx-Trigger "listUpdated"
which will signal the list block to update the list.
// recieve create form
app.Post("/form", func(c *fiber.Ctx) error {
viewModel := fiber.Map{}
item := c.FormValue("item")
// Simple validation
if item == "" {
viewModel["ValueError"] = "Item is required"
return c.Render("partial/form", viewModel)
}
itemStore.Create(item)
// Indicate list update
c.Set("Hx-Trigger", "listUpdated")
log.Default().Println("list update event")
return c.SendStatus(fiber.StatusNoContent)
})
// recieve update form
app.Put("/form/:Id", func(c *fiber.Ctx) error {
id := c.Params("Id")
num, _ := strconv.Atoi(id)
item := c.FormValue("item")
// Simple validation
if item == "" {
viewModel["ValueError"] = "Item is required"
return c.Render("partial/form", viewModel)
}
itemStore.Update(num, item)
// Indicate list update
c.Set("Hx-Trigger", "listUpdated")
return c.SendStatus(fiber.StatusNoContent)
})
And finally, the delete handler to delete the item.
- When delete is successful, it will send
Hx-Trigger "listUpdated"
which will signal the list block to update the list.
// delete item
app.Delete("/item/:Id", func(c *fiber.Ctx) error {
id := c.Params("Id")
num, _ := strconv.Atoi(id)
itemStore.Delete(num)
c.Set("Hx-Trigger", "listUpdated")
return c.SendStatus(fiber.StatusNoContent)
})
This setup is a basic and simple example, to see how much htmx interactivity can be achieved.
The minimal Javascript
There is a bit of javascript we need to handle
<script>
const modal = new bootstrap.Modal(document.getElementById("modalItem"))
htmx.on("htmx:afterSwap", (e) => {
if (e.detail.target.id == "modal-form") {
modal.show()
}
})
htmx.on("htmx:beforeSwap", (e) => {
if (e.detail.target.id == "modal-form" && !e.detail.xhr.response) {
modal.hide()
e.detail.shouldSwap = false
}
})
</script>
This small script will handle the modal show and hide.
- for the button click -> form render
<button type="button" class="btn btn-primary" hx-get="/form" hx-target="#modal-form" hx-swap="outerHTML">
Add Item
</button>
when this button is clicked,
- it will issue a get request to
/form
and replace the#modal-form
with the response. - the
htmx:afterSwap
event will be triggered and the modal will be shown.
- for the form submission
<form hx-post="/form" hx-target="#modal-form" hx-swap="outerHTML">
when this form is submitted,
- it will issue a post request to
/form
and- if the response is empty, (and if event target is modal-form) it will just hide the modal.
- if the response is not empty, it will replace the
#modal-form
with the response.
- for the delete button
<button class="btn btn-danger" hx-delete="/item/{{ .Id }}" hx-confirm="You sure ?">Delete</button>
when this button is clicked,
- we get a confirmation dialog, if confirmed,
- it will issue a delete request to
/item/:id
and replace the#modal-form
with the response. - the sever will send
Hx-Trigger: listUpdated
which will trigger the list block to update the list.
Thoughts
The main pattern here to notice is that, we only send info from server to client which indicates some sort of render.
No reactivity, no state management, no complexity at clientside.
The job of the client is to replace html elements sent from server at the right occations. The client side logic is simply event based dom updates.
I love how we didn’t touch the data from the javascript.