A simple todo app with htmx/go/templ/tailwind
In this article we will implement a simple todo app using go in the server, templ as the templating engine, tailwind for styling and htmx to give our hypertext steroids! This is going to be an extensive post and we will not only focus on just creating the todo app, but instead we will implement things that will help us with our productivity, such as live reload. Also we will create some paterns that can easily be used in every kind of application we can think of, such as error handling and how to present them on screen and also add a well structed way to validate user's input. With the power of htmx this is also going to feel like a Single Page Application(SPA), where a navigation will only update the parts of the html that changed. You can find the repo of the app here but I strongly advice you to follow along and code with me. Let's get started! Create the project go mod init github.com/antonisgkamitsios/simple-todo-app mkdir -p cmd/web touch cmd/web/main.go You can name the module whatever you like, i just prefer using the same name as the repo. In the main.go file we can start by adding the code for our server. First we will add a config struct that will hold all the configuration for our server and also an application struct that will hold the configuration as well as the db instance and we will attach to it all the handlers, helpers etc, this will help us organize the code better and also allow every method attached to the application struct to have access to the application's fields. // file cmd/web/main.go package main type config struct { port int env string } type application struct { config config } For the routing we will use https://github.com/julienschmidt/httprouter, because it is close to the standard lib and it adds my personal favorite an auto redirect if the url ends in a trailing shlash :p. There are plenty of reasons to chose this router or others, for the most part it won't play any significant role for what we are trying to achieve Let's install it: go get github.com/julienschmidt/httprouter now in the main.go file we can declare a routes function that will hold all of our endpoints. It is easier to have them under one function and later in this post we will move this function to its own dedicated file. // file cmd/web/main.go import "net/http" ... func (app *application) routes() http.Handler { router := httprouter.New() router.HandlerFunc(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello world")) }) return router } func main() { app := &application{} srv := &http.Server{ Addr: ":3000", Handler: app.routes(), } fmt.Println("server is running on port 3000") log.Fatal(srv.ListenAndServe()) } In the main function we create an http.Server struct and we pass a hardcoded port and a handler and we call listenAndServe on it so it will listen on requests. The routes function only contains one endpoint so far, the root endpoint. Note: the hardcoded port in a production app would change and perhaps we could take advantage of the config struct and the "flag" package that go provides. Also the http.Server struct as is it's not production ready. We should probably add timeouts (idle, read and write) but are out of scope of this post At this point we should start our server and see if everything works as expected go run ./cmd/web ❯ server is running on port 3000 and in a new terminal we could use curl localhost:3000 or in the browser and we should see the anticipated "Hello world" message. BUT this is a templ/htmx/tailwind post so we should probably add those. We will start incrementally, meaning that we will first add templ check that it works, then add tailwind, check that it works and then add htmx and check that it works before we proceed. Templ in order to install templ we should follow the instructions on the templ's site go install github.com/a-h/templ/cmd/templ@latest go get github.com/a-h/templ we need the go install in order to run the templ command in our terminal and the go get in order to add this module into our project and then create a simple templ component and render it mkdir views touch views/Index.templ and inside Index.templ // file views/Index.templ package view templ Index() { Hello World } now we can run in the terminal templ generate for the go code of our templ component to be generated. In the main.go file we can update the root handler to serve this component like this: // file cmd/web/main.go ... func (app *application) routes() http.Handler { router := httprouter.New() router.HandlerFunc(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) { // call the render method of our new component (ignore any errors for now) view.Index().Render(r.Context(), w) }) return router }

In this article we will implement a simple todo app using go in the server, templ as the templating engine, tailwind for styling and htmx to give our hypertext steroids! This is going to be an extensive post and we will not only focus on just creating the todo app, but instead we will implement things that will help us with our productivity, such as live reload. Also we will create some paterns that can easily be used in every kind of application we can think of, such as error handling and how to present them on screen and also add a well structed way to validate user's input. With the power of htmx this is also going to feel like a Single Page Application(SPA), where a navigation will only update the parts of the html that changed.
You can find the repo of the app here but I strongly advice you to follow along and code with me.
Let's get started!
Create the project
go mod init github.com/antonisgkamitsios/simple-todo-app
mkdir -p cmd/web
touch cmd/web/main.go
You can name the module whatever you like, i just prefer using the same name as the repo.
In the main.go file we can start by adding the code for our server. First we will add a config struct that will hold all the configuration for our server and also an application struct that will hold the configuration as well as the db instance and we will attach to it all the handlers, helpers etc, this will help us organize the code better and also allow every method attached to the application struct to have access to the application's fields.
// file cmd/web/main.go
package main
type config struct {
port int
env string
}
type application struct {
config config
}
For the routing we will use https://github.com/julienschmidt/httprouter, because it is close to the standard lib and it adds my personal favorite an auto redirect if the url ends in a trailing shlash :p. There are plenty of reasons to chose this router or others, for the most part it won't play any significant role for what we are trying to achieve
Let's install it:
go get github.com/julienschmidt/httprouter
now in the main.go file we can declare a routes function that will hold all of our endpoints. It is easier to have them under one function and later in this post we will move this function to its own dedicated file.
// file cmd/web/main.go
import "net/http"
...
func (app *application) routes() http.Handler {
router := httprouter.New()
router.HandlerFunc(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world"))
})
return router
}
func main() {
app := &application{}
srv := &http.Server{
Addr: ":3000",
Handler: app.routes(),
}
fmt.Println("server is running on port 3000")
log.Fatal(srv.ListenAndServe())
}
In the main function we create an http.Server struct and we pass a hardcoded port and a handler and we call listenAndServe on it so it will listen on requests. The routes function only contains one endpoint so far, the root endpoint.
Note: the hardcoded port in a production app would change and perhaps we could take advantage of the config struct and the "flag" package that go provides. Also the http.Server struct as is it's not production ready. We should probably add timeouts (idle, read and write) but are out of scope of this post
At this point we should start our server and see if everything works as expected
go run ./cmd/web
❯ server is running on port 3000
and in a new terminal we could use
curl localhost:3000
or in the browser and we should see the anticipated "Hello world" message.
BUT this is a templ/htmx/tailwind post so we should probably add those. We will start incrementally, meaning that we will first add templ check that it works, then add tailwind, check that it works and then add htmx and check that it works before we proceed.
Templ
in order to install templ we should follow the instructions on the templ's site
go install github.com/a-h/templ/cmd/templ@latest
go get github.com/a-h/templ
we need the go install
in order to run the templ command in our terminal and the go get
in order to add this module into our project
and then create a simple templ component and render it
mkdir views
touch views/Index.templ
and inside Index.templ
// file views/Index.templ
package view
templ Index() {
<div>Hello Worlddiv>
}
now we can run in the terminal
templ generate
for the go code of our templ component to be generated.
In the main.go file we can update the root handler to serve this component like this:
// file cmd/web/main.go
...
func (app *application) routes() http.Handler {
router := httprouter.New()
router.HandlerFunc(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
// call the render method of our new component (ignore any errors for now)
view.Index().Render(r.Context(), w)
})
return router
}
once again we are ready to run our server
go run ./cmd/web
and verify on the browser that the content is the same (or at least the same because we wrapped it in div)
Tailwind
let's also add tailwind to our project: following tailwind's instructions:
npm install tailwindcss @tailwindcss/cli
touch tailwind.config.js
and in the tailwind.config.js file we should add:
// file tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./**/*.templ', './**/*.go'],
};
and let's make the folder where our css will live
mkdir -p assets/css
touch assets/css/app.css
then in the app.css we can add:
// file assets/css/app.css
@import "tailwindcss";
we also need to specify the output folder, let's create it:
mkdir static
touch static/styles.css
and now we are ready to run this command to watch for tailwind changes
npx @tailwindcss/cli -i assets/css/app.css -o static/styles.css --watch
but in order to see changes on our screen we need to do some setup first:
we need to send proper html markup on the client which will inlcude the static generated css file as well.
In order to do that we will need to create base layout for our site:
mkdir view/layout
touch view/layout/Base.templ
and inside we can add:
// file view/layout/Base.templ
package layout
templ Base() {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Simple Todo Apptitle>
head>
<body>
{ children... }
body>
html>
}
notice the children
keyword here, with this we will be able to wrap a component with our base component:
// file view/Index.templ
package view
// new import
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
templ Index() {
// wrap the contents with our Base component
@layout.Base() {
<div>Hello Worlddiv>
}
}
and let's give it a try
remember the steps are:
- run
templ generate
- restart the server
- and assume that the tailwind proccess is running with watch on another terminal
Note we can see that this process is getting a little bit tedious and in the next chapter we will try to improve this!
let's run the server again and inspecting the browser we should see that the html has a proper markup with a head and a body tag.
So now in the head we could instruct the browser to fetch our css file, let's add this:
// file view/layout/Base.templ
package layout
templ Base() {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
// add the link tag to fetch the styles
<link rel="stylesheet" type="text/css" href="static/styles.css">
<title>Simple Todo Apptitle>
head>
<body>
{ children... }
body>
html>
}
we know the drill we have to run templ generate
and restart the server so let's do this and see the result in the browser:
as we can see in the network tab the static/styles.css gives us a 404. This is because we have not instructed our server to serve static files, yet!
to achieve this we must add a route in our routes function to serve our static content
// file cmd/web/main.go
func (app *application) routes() http.Handler {
router := httprouter.New()
// add a static route to serve all the content inside our static dir
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
view.Index().Render(r.Context(), w)
})
return router
}
with this restarting the server will no longer give a 404.
So now we are ready to update the styles inside the templ files and see effects on our screen, let's give it a try:
//file view/Index.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
templ Index() {
@layout.Base() {
// add a new tailwind class
<div class="text-red-500">Hello Worlddiv>
}
}
Now in a framework like vite or whatever whenever we were to make a change we would expect this change to take immediate effect on our screen but right now this is not the case. We have to run templ generate
and restart the server AGAIN.
As you can understand this is very time consuming process so in the next chapter we will try to add live reloading
Adding live reloading
in order to achive that we are going to use our friend Makefile and also templ's feature to proxy request to our app and with some injected javascript code to reload our browser. But it would we cool if also the server would restart with a change (otherwise the thempl changes won't be visible). For this https://github.com/air-verse/air comes to the resque. Let's put it all together and it will become crystal clear.
let's create our make file
touch touch Makefile
and inside:
# run templ generation in watch mode to detect all .templ files and
# re-create _templ.txt files on change, then send reload event to browser.
# Default url: http://localhost:7331
live/templ:
templ generate --watch --proxy="http://localhost:3000" --proxybind="localhost" --open-browser=false -v --path="./view"
# run air to detect any go file changes to re-build and re-run the server
live/server:
go run github.com/cosmtrek/air@v1.51.0 \
--build.cmd "go build -o tmp/bin/main ./cmd/web" --build.bin "tmp/bin/main -port=3000" --build.delay "100" \
--build.exclude_dir "node_modules" \
--build.include_ext "go" \
--build.stop_on_error "false" \
--misc.clean_on_exit true
# run tailwind css to generate the styles.css bundle in watch mode
live/tailwind:
npx --yes @tailwindcss/cli -i assets/css/app.css -o static/styles.css --minify --watch=always
# watch for any js or css change in the assets/ folder, then reload the browser via templ proxy.
live/sync_assets:
go run github.com/cosmtrek/air@v1.51.0 \
--build.cmd "templ generate --notify-proxy" \
--build.bin "true" \
--build.delay "100" \
--build.exclude_dir "" \
--build.include_dir "static" \
--build.include_ext "js,css"
# run all the 4 processes in parallel
live:
make -j4 live/templ live/server live/tailwind live/sync_assets
what is happening here is:
- live/templ uses the proxy flag to proxy any changes and trigger a reload on our localhost:3000 server.
- live/server executes air to watch for all *.go files and execute the go build command
- live/tailwind starts tailwind on watch mode. Notice the watch=always flag, there seems to be an issue with tailwind closing when stdin closes and this flag fixes it. (related pr)
- live/sync_assets watches for any changes in css and js files inside the static dir and when that happens we execute
templ generate --notify-proxy
to notify our proxy and reload our browser Finaly the live command executes all 4 of them in parallel
With this we can now run
make live
and every time something changes in the code (be it a templ file or a tailwind class) the browser will automatically reload with our newest changes.
Note in order for the live reload to work we should open localhost:7331
Warning I am currently using go 1.23.6 and templ v0.3.833 and i was running into some issues with tailwind classes not always being applied despite the fact that the browser reloaded. It seems that the last (live/sync_assets) is causing the problem, so if you're having the same issue what I did it was I removed the css
from the --build.include_ext
like so:
...
live/sync_assets:
go run github.com/cosmtrek/air@v1.51.0 \
--build.cmd "templ generate --notify-proxy" \
--build.bin "true" \
--build.delay "100" \
--build.exclude_dir "" \
--build.include_dir "static" \
--build.include_ext "js"
...
There is also an issue with those versions that the live/templ
command seems to be triggering a reload for every go file that changes, so imagine we change our middleware or our handler's code and the browser will trigger a reload. Perhaps this is not something that we want. If we don't want that we can add one additional flag to the templ generate command the --path and specify the path that we want to templ to watch, in our case the ./view directory. So in the make file we would change it to something like:
live/templ:
templ generate --watch --proxy="http://localhost:3000" --open-browser=false -v --path="./view"
You can find more about generate's flags here
Also we will need to add a middleware to disable caching when we are in development mode (reference)
Let's add it:
touch cmd/web/middleware.go
and inside:
// file cmd/web/middleware.go
package main
import (
"net/http"
)
func (app *application) disableCacheInDevMode(next http.Handler) http.Handler {
if app.config.env != "development" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
next.ServeHTTP(w, r)
})
}
what this does is if we are in dev mode we add some header to disable caching.
As you can see we are relying on the application's config to determine if we are in dev mode or not, but if you can remember we haven't initialized the config so right now it is an empty string, let's change that:
// file cmd/web/main.go
...
func main() {
// add a hardcoded development env
cfg := config{
env: "development",
}
app := &application{
// add it to our application struct
config: cfg,
}
srv := &http.Server{
Addr: ":3000",
Handler: app.routes(),
}
fmt.Println("server is running on port 3000")
log.Fatal(srv.ListenAndServe())
}
The hardcoded "development" environmet is something that in a production grade application would change and the value could either come from command line flags or from the environment.
We also have to wrap our handlers with this newly created middleware like so:
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
...
// wrap router with our new middleware
return app.disableCacheInDevMode(router)
}
with this we are all set to enjoy live reloading
You can find more information in the templ's documentation
Creating the project's structure
With live reload out of the way and the Base component that returns proper html and the server that can serve static files we are in a good spot to think about the application we are trying to make. The todo app will be a simple crud app like many we have seen online. Let's think about the general layout our web app will have. In order to demonstrate the power of htmx we will have a top navigation menu with 2 links: one will be the home page with some static content and the other will be the todos which will display all of our todos plus a button to create a new todo. In the todos page every todo will have an edit/delete button.
A drawing of what we are trying to achieve:
Let's start by creating our index handler and it's route.
We will start to think about the structure of our project early on, one convention that I'll make is that I will group the handlers in files and the routes in another file. To see this in action:
touch cmd/web/handler_home.go
and in the handler_home.go:
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/view"
)
func (app *application) handleHome(w http.ResponseWriter, r *http.Request) {
view.Index().Render(r.Context(), w)
}
now let's create a new routes file like this
touch cmd/web/routes.go
and inside let's move the routes function from our main file
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
return app.disableCacheInDevMode(router)
}
and in the main.go remove the routes
// file cmd/web/main.go
...
type application struct {
config config
}
// routes removed
func main() {
...
}
everything should be working fine, but let's add a more generalized way to render templ views:
touch cmd/web/helpers.go
// file cmd/web/helpers.go
package main
import (
"net/http"
"github.com/a-h/templ"
)
func (app *application) render(
w http.ResponseWriter,
r *http.Request,
status int,
component templ.Component) {
w.WriteHeader(status)
err := component.Render(r.Context(), w)
if err != nil {
app.logError(r, err)
http.Error(w, http.StatusText(status), status)
}
}
we should see an error that the app.logError is not defined. Let's define it:
touch cmd/web/errors.go
and inside:
// file cmd/web/errors.go
package main
import "net/http"
func (app *application) logError(r *http.Request, err error) {
var (
method = r.Method
uri = r.URL.RequestURI()
)
app.logger.Error(err.Error(), "method", method, "uri", uri)
}
Again there will be an error because application struct does not have a logger field. We should add it, in the main.go file:
// file cmd/web/main.go
package main
import (
"log/slog" // new import
"net/http"
"os" // new import
)
type config struct {
port int
env string
}
type application struct {
config config
// add new logger field
logger *slog.Logger
}
func main() {
// initialize logger and pass it to the application struct
logger := slog.New((slog.NewTextHandler(os.Stdout, nil)))
cfg := config{
env: "development",
}
app := &application{
logger: logger,
config: cfg,
}
srv := &http.Server{
Addr: ":3000",
Handler: app.routes(),
// here we explicitly provide to the server our logger for logging errors
ErrorLog: slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
}
// change fmt to logger.Info
logger.Info("server is running on port 3000")
// remove log.Fatal and replace it with waiting on the error and if so call the logger.Error + os.Exit
err := srv.ListenAndServe()
logger.Error(err.Error())
os.Exit(1)
}
with this we have a generalized way to logging warnings or errors.
what we've done so far is: provide a common function to render views and use it across the entire app and also provide a way to log errors and use it across the entire app as well, so if for any reason we would like to report errors to sentry for example we could do this in one place.
With this code out of the way we can update our handler_home to use our new render function:
// file cmd/web/handler_home.go
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/view"
)
func (app *application) handleHome(w http.ResponseWriter, r *http.Request) {
// use the new app.render function
app.render(w, r, http.StatusOK, view.Index())
}
but our index page is very blank at the moment let's add some content and some simple styles to it:
// file view/Index.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
templ Index() {
@layout.Base() {
<section class="text-center py-20 bg-blue-500 text-white">
<h1 class="text-4xl font-bold">Stay Organized, Stay Productiveh1>
<p class="mt-4 text-lg">Manage your tasks efficiently with our simple and intuitive to-do app.p>
section>
<section class="max-w-6xl mx-auto py-16 px-4">
<h2 class="text-3xl font-bold text-center text-gray-800">Why Use Our App?h2>
<div class="grid md:grid-cols-3 gap-6 mt-10">
<div class="bg-white p-6 shadow-md rounded-md">
<h3 class="text-xl font-semibold text-gray-700">Easy to Useh3>
<p class="text-gray-600 mt-2">A simple interface that helps you focus on your tasks, not the complexity.p>
div>
<div class="bg-white p-6 shadow-md rounded-md">
<h3 class="text-xl font-semibold text-gray-700">Cloud Synch3>
<p class="text-gray-600 mt-2">Access your to-do list from any device, anytime.p>
div>
<div class="bg-white p-6 shadow-md rounded-md">
<h3 class="text-xl font-semibold text-gray-700">Stay Organizedh3>
<p class="text-gray-600 mt-2">Keep track of your tasks, deadlines, and progress effortlessly.p>
div>
div>
section>
}
}
now the index looks nice. But we are lacking navigation, let's think about it for a moment. The header should not change between navigations and we shouldn't also have to type it in every page that we create. In order to avoid that let's add it in the Base.templ file so it can be reused!
mkdir view/components
touch view/components/Header.templ
and inside:
// file view/components/Header.templ
package components
templ Header() {
<header class="border-b border-harvest-gold-400">
<nav class="bg-white shadow-md">
<div class="max-w-6xl mx-auto px-4">
<div class="flex justify-between items-center py-4">
<a href="/" class="text-xl font-bold text-gray-800">Todo Appa>
<div>
<a href="/" class="text-gray-600 hover:text-blue-500 px-4">Homea>
<a href="/todos" class="text-gray-600 hover:text-blue-500 px-4">Todosa>
div>
div>
div>
nav>
header>
}
and in the Base.templ file
file view/layout/Base.templ
package layout
import "github.com/antonisgkamitsios/simple-todo-app/view/components" // new import
templ Base() {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="static/styles.css"/>
<title>Simple Todo Apptitle>
head>
<body>
// add the Header component
@components.Header()
{ children... }
body>
html>
}
now let's add one more end point the /todos to demonstrate the power of htmx:
let's first create a dummy todos page:
touch view/Todos.templ
and inside:
// file view/Todos.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
templ Todos() {
@layout.Base() {
<div>This is a simple todos page!div>
}
}
now to create our handler:
touch cmd/web/handler_todos.go
and inside:
// file cmd/web/handler_todos.go
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/view"
)
func (app *application) handleTodos(w http.ResponseWriter, r *http.Request) {
app.render(w, r, http.StatusOK, view.Todos())
}
and let's register it to our routes
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
// register the new todo handler
router.HandlerFunc(http.MethodGet, "/todos", app.handleTodos)
return app.disableCacheInDevMode(router)
}
now in our browser we could navigate through the menu to our two endpoints.
But if you can notice there is a full page reload every time we are navigating to a new endpoint, which can be pretty annoying, imagine having an input on our header and on every navigation it gets reset. We will work towards fixing this issue now:
HTMX to the rescue
To fix this we will need htmx, following htmx installation guide, all we need to do is add a script into our main html document like so:
// file view/layout/Base.templ
package layout
import "github.com/antonisgkamitsios/simple-todo-app/view/components"
templ Base() {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="static/styles.css"/>
// Add the script tag to fetch htmx
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous">script>
<title>Simple Todo Apptitle>
head>
<body>
@components.Header()
{ children... }
body>
html>
}
with this htmx is all set.
Now let's think about the problem we are trying to solve. If the browser requests a full page, via an initial navigation or a full page reload we want the server to serve the entire html document, but it the user navigates in one of our endpoints we want the server to only return the markup corrensponding to the requested endpoint and nothing more, and htmx would take this markup and replace the contents of our screen with this.
This is achievable if we can distinguish the full page requests with htmx page request. Luckily for us htmx has our back because if the request was made via an hx action the request will have an "Hx-Request" header. (you can read more about htmx headers here)
Let's start working on this. In order to pass the information down to the templ component we can take advantage that templ has access to the request's context.
Let's start by writing the code to extract and set information to the context:
mkdir -p internal/htmx
touch internal/htmx/htmx.go
and inside:
// file internal/htmx/htmx.go
package htmx
import (
"context"
"net/http"
)
type contextKey string
const htmxRequestKey = contextKey("htmxRequestKey")
func ContextSetHtmxRequest(r *http.Request, isHtmxRequest bool) *http.Request {
ctx := context.WithValue(r.Context(), htmxRequestKey, isHtmxRequest)
return r.WithContext(ctx)
}
func ContextGetHtmxRequest(ctx context.Context) bool {
if comesFromHtmx, ok := ctx.Value(htmxRequestKey).(bool); ok {
return comesFromHtmx
}
return false
}
Now let's write ourselves a middleware that will extract from the header if it is htmx request or not and store this information to the context so the handlers can have access to it:
and inside it:
// file cmd/web/middleware.go
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/internal/htmx"
)
...
func (app *application) comesFromHTMX(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// if the request has "Hx-History-Restore-Request" is means it had a cache miss
// and it wants back the entire html
isHTMXRequest := r.Header.Get("Hx-Request") != "" &&
r.Header.Get("Hx-History-Restore-Request") == ""
next.ServeHTTP(w, htmx.ContextSetHtmxRequest(r, isHTMXRequest))
})
}
notice the Hx-History-Restore-Request
header here. If on navigation back the htmx does not find on local storage the page in cache, it will request it from the server with this specific header and in this case we want the server to return the full html
Now we should wrap all of our endpoints with this middleware:
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
....
// wrap router with our new middleware
return app.disableCacheInDevMode(app.comesFromHTMX(router))
}
Now that we have the information of whether a request comes from htmx or not we can work on our Base.templ to serve different contents:
// file view/layout/Base.templ
package layout
import "github.com/antonisgkamitsios/simple-todo-app/view/components"
import "github.com/antonisgkamitsios/simple-todo-app/internal/htmx"
templ Base() {
if htmx.ContextGetHtmxRequest(ctx) {
<div id="contents">
{ children... }
div>
} else {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="static/styles.css"/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous">script>
<title>Simple Todo Apptitle>
head>
<body>
@components.Header()
<div id="contents">
{ children... }
div>
body>
html>
}
}
Notice that if the request comes from htmx we serve only the children wrapped in a div with id contents (the id is important) we also wrapped on the full page response the children in the same div, so the subsequent requests can find this div to swap
So far so good but we haven't add any htmx request to our page, let's fix that:
// file view/components/Header.templ
package components
templ Header() {
<header class="border-b border-harvest-gold-400">
<nav class="bg-white shadow-md">
<div class="max-w-6xl mx-auto px-4">
<div class="flex justify-between items-center py-4">
<a href="/" class="text-xl font-bold text-gray-800">Todo Appa>
// add htmx attributes on the parent div of the anchor tags
<div hx-boost="true" hx-target="#contents" hx-swap="outerHTML show:no-scroll">
<a href="/" class="text-gray-600 hover:text-blue-500 px-4">Homea>
<a href="/todos" class="text-gray-600 hover:text-blue-500 px-4">Todosa>
div>
div>
div>
nav>
header>
}
here we can see we added some things in the div containing our anchor tags:
- hx-boost="true" which will indicate that all the children will boost their anchor tags and forms to instead fire an ajax request and also push this url into the browser history
- hx-target="#contents" where we say to htmx: when you get the results from the server target the element with id "contents" and finaly
- hx-swap="outerHTML show:no-scroll" to tell htmx that the swapping mechanism will be to swap the entire div#contents not only its children, which is the default behaviour. Also notice the
show:no-scroll
in hx-swap this is to prevent htmx scrolling into the div#contents when navigating (source).
With this we can test that navigating from home to todos and vice versa via the menu items does not trigger the browser to reload. Inspecting the network tab into our developer tools we can see that everytime we navigate, an xhr is fired and it contains only the contents of the page wrapped in a contents div. If we full page reload we should see the entire page getting fetched. There is only a slight problem: if we navigate to these two pages we see that the title stays the same. We should fix that by first provide a title in the Base component so different pages can have different title:
// file view/layout/Base.templ
package layout
import "github.com/antonisgkamitsios/simple-todo-app/view/components"
import "github.com/antonisgkamitsios/simple-todo-app/internal/htmx"
// add the title argument
templ Base(title string) {
if htmx.ContextGetHtmxRequest(ctx) {
<div id="contents">
{ children... }
div>
} else {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="static/styles.css"/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous">script>
// render the title passed from the arguments
<title>Simple Todo App | { title }title>
head>
<body>
@components.Header()
<div id="contents">
{ children... }
div>
body>
html>
}
}
With this we should also update our home and todos page to pass them the title of the page:
// file view/Index.templ
...
templ Index() {
@layout.Base("Home") {
...
}
}
// file view/Todos.templ
...
templ Todos() {
@layout.Base("Todos") {
...
}
}
Right now on a fresh reload we can see that the title applies, but when navigating between pages htmx does not update our title, in order to fix that we need to also pass the title as a top level element in the htmx response:
// file view/layout/Base.templ
...
templ Base(title string) {
if htmx.ContextGetHtmxRequest(ctx) {
// add the title also in the htmx response
<title>Simple Todo App | { title }title>
<div id="contents">
{ children... }
div>
} else {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="static/styles.css"/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous">script>
<title>Simple Todo App | { title }title>
head>
<body>
@components.Header()
<div id="contents">
{ children... }
div>
body>
html>
}
}
Now we can see that navigating between pages updates the title correctly.
The things we have done here are very powerful as we have eliminated the need to fetch unnecessary data (data that are only needed for the initial load, like scripts and css files etc) and also we can preserve state on the menu (if there was any), and not forget that we can change the title of our page very easily now.
We will continue by adding the the CRUD functionality for our todos.
Listing Todos
Let's start by creating the foundation for our todos model. We will need a way to store those todos and retrieve/update/delete them. For this we should use a database, but for the purposes of our post, and because this post already is a bit long we will not integrate a real database, instead we will use an in memory struct to hold our todos for simplicity. But the way we will implement it, if in the future we want to add a real db we will be able to do it with very little changes. Let's start by first creating the directory that will hold our models and add the necessary files inside:
mkdir internal/data
touch internal/data/todos.go
and inside the todos.go we should add the structure of our todo model first:
// file internal/data/todos.go
package data
import "time"
type Todo struct {
ID int64
CreatedAt time.Time
Title string
Done bool
}
now what we should do is create one more file and inside declare our own in memory database so we can attach it to our application struct and all the handlers will have access to it, let's begin:
touch internal/data/models.go
and inside:
// file internal/data/models.go
package data
type DummyDB struct {
Todos []Todo
}
type Models struct {
Todos TodoModel
}
func NewModels(db *DummyDB) Models {
return Models{
Todos: TodoModel{DB: db},
}
}
func NewDummyDB() *DummyDB {
return &DummyDB{
Todos: make([]Todo, 0),
}
}
Here we declare a new DummyDB memory database that will simulate a database and it has one field the Todos which is a slice of Todo items. In a real project this would not exist and instead we would use a *sql.DB
for example. Next we are defining our Models struct, this is to organise better our models in and because this will be attached to our application we will access our todos with app.models.Todos
. Then the NewModels is a constructor function that returns a new Models struct and initialize a TodoModel passing inside the DummyDb we provided in the arguments. And also the NewDummyDB which is also a constructor for our DummyDB and initializes our todos with an empty slice. Right now the code does not compile because we have not declared the TodoModel struct, so let's add it:
// file internal/data/todos.go
package data
import "time"
type Todo struct {
ID int64
CreatedAt time.Time
Title string
Done bool
}
// add the new struct
type TodoModel struct {
DB *DummyDB
}
And now the code compiles, but we have to make aware the application struct about our new Models struct, let's proceed to address this:
// file cmd/web/main.go
package main
import (
"log/slog"
"net/http"
"os"
"github.com/antonisgkamitsios/simple-todo-app/internal/data" // new import
)
type config struct {
port int
env string
}
type application struct {
config config
logger *slog.Logger
// add a new models field
models data.Models
}
func main() {
logger := slog.New((slog.NewTextHandler(os.Stdout, nil)))
cfg := config{
env: "development",
}
// create the dummy memory database
db := data.NewDummyDB()
app := &application{
logger: logger,
config: cfg,
// add the database to our application struct
models: data.NewModels(db),
}
srv := &http.Server{
Addr: ":3000",
Handler: app.routes(),
ErrorLog: slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
}
logger.Info("server is running on port 3000")
err := srv.ListenAndServe()
logger.Error(err.Error())
os.Exit(1)
}
With this set up we are ready to proceed to writing some code to retreive our todos from our memory db:
// file internal/data/todos.go
package data
import "time"
type Todo struct {
ID int64
CreatedAt time.Time
Title string
Done bool
}
type TodoModel struct {
DB *DummyDB
}
// add the new GetAll method on the TodoModel
func (m TodoModel) GetAll() ([]Todo, error) {
return m.DB.Todos, nil
}
What we've done is that we attached the GetAll method on the TodoModel, which has access to our DummyDB were we can retrieve our todos. Now with a real db this would need a bit more setup, to execute the query, check for errors etc, and would increase the complexity of our app.
And now we are ready to access our todos in a handler!
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/view" // new import
)
func (app *application) handleTodos(w http.ResponseWriter, r *http.Request) {
todos, err := app.models.Todos.GetAll()
// check for error and if there is we should do something!
if err != nil {
// todo: handle error
return
}
// we should pass our todos into the view
app.render(w, r, http.StatusOK, view.Todos(todos))
}
Right now there are two things that need to be addressed: The first one is that we need to update the templ view to accept todos and render them. The second one is that we don't handle the case when an error occur, in our dummy db we simply fetch a slice, but in a real db many things can go wrong such as the sql statement might be wrong or the db times out etc. If the request simply returns nothing we have a problem. Let's start with addressing the first one:
Passing todos into the view
// file view/Todos.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
import "github.com/antonisgkamitsios/simple-todo-app/internal/data" // new import
// update signature to accept a slice of Todos
templ Todos(todos []data.Todo) {
@layout.Base("Todos") {
<div class="max-w-xl mt-8 mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">My Todosh1>
if len(todos) == 0 {
<div>
<p class="text-gray-500 text-center">No todos yet. Add some tasks!p>
div>
} else {
<ul class="space-y-3">
for _,todo := range todos {
<li class="flex items-center justify-between bg-gray-200 p-3 rounded-lg">
<span class="text-lg">{ todo.Title }span>
li>
}
ul>
}
div>
}
}
What we have done here is that based on the todos slice that the handler will pass we either render the todos or if there are no todos yet we render an informative message on the screen that there are no todos.
If we save our application and navigate to the /todos endpoint we should see this message. Let's add some dummy initial data to verify that the todos are being rendered:
// file internal/data/models.go
...
func NewDummyDB() *DummyDB {
return &DummyDB{
// populate the slice with some initial data
Todos: []Todo{{ID: 1, Title: "Clean room"}, {ID: 2, Title: "Change clothes"}},
}
}
Now if we refresh the browser (remember go changes don't trigger a browser reload if we did the fix on the previous section with live reload) we should see the todos on our screen, pretty neat!
Now we should address the second and most major issue
Handling errors in handlers
In an application many things can go wrong, a form failed validation, the user is not authorized, the database timed out and many more. In order to create a robust application we need to handle those errors and also present something to the user so we let them now what went wrong. In this section we will work towards creating a way to display errors in the end users in an unanimous way.
First we should start by creating the error component that we will display when something is wrong:
touch view/Error.templ
and inside:
// file view/Error.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
templ Error(title, msg string) {
@layout.Base(title) {
<div class="container mx-auto my-12">
<div class="py-12 px-4 border p-11 text-center text-2xl">
{ msg }
div>
div>
}
}
This component will be used when we want to present an error on the screen, and we will pass to it a message and a title in order to make it more reusable. And now we can create some methods that will be used to render the errors:
// file cmd/web/errors.go
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/view" // new import
)
func (app *application) logError(r *http.Request, err error) {
var (
method = r.Method
uri = r.URL.RequestURI()
)
app.logger.Error(err.Error(), "method", method, "uri", uri)
}
// Add a new helper function to render the error component
func (app *application) renderError(w http.ResponseWriter, r *http.Request, status int, title, message string) {
app.render(w, r, status, view.Error(title, message))
}
// A new function to render server errors to the client, first will log the error and then server the error component
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) {
app.logError(r, err)
app.renderError(
w,
r,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
"Something went wrong",
)
}
// A new function to render client error to the user
func (app *application) clientError(w http.ResponseWriter, r *http.Request, status int, message string) {
app.renderError(
w,
r,
status,
http.StatusText(status),
message,
)
}
First the renderError function is just a helper that its purpose is to be used inside the serverError and the clientError functions, it's simple right now but in the near future we will add more functionality to it.
The serverError and the clientError functions will be used across our application in case we want to render a server or a client error. Let's see it in action:
Going back in our todos handler in case the database returns an unexpected error perhaps we want to log the error and inform the user that something went wrong with our application and is our fault not his so a serverError:
// file cmd/web/handler_todos.go
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/view" // new import
)
func (app *application) handleTodos(w http.ResponseWriter, r *http.Request) {
todos, err := app.models.Todos.GetAll()
if err != nil {
// add our new helper to render the error to the end user
app.serverError(w, r, err)
return
}
app.render(w, r, http.StatusOK, view.Todos(todos))
}
And in order to see this error we have to change the GetAll method to return an error instead:
// file internal/data/todos.go
...
func (m TodoModel) GetAll() ([]Todo, error) {
// return a new error instead of the slice
return nil, errors.New("the db is offline :(")
}
With this we can save and do a full reload on /todos. We can see that the error page is getting rendered and also the error is getting logged on the console, but if we try to navigate from home to todos with the help of htmx nothing happens. This is because htmx does not swap content in case of a 4xx or 5xx. We want to change this default behaviour. I personally like to change it for all 4xx and 5xx responses and give my server the ability to inform the user at all times. To do this we have to go to the Base.templ and update the configuration of htmx in the meta of our document:
// file view/layout/Base.templ
templ Base(title string) {
...
DOCTYPE html>
<html lang="en">
<head>
...
// Add the config to allow htmx to swap contents on all codes
<meta name="htmx-config" content='{"responseHandling": [{"code":".*", "swap": true}]}'/>
...
head>
<body>
...
body>
html>
}
Now we can navigate and and everytime we visit the /todos page we will get the error. You can learn more about the htmx config and the status codes here. Finally let's return the GetAll method to its previous state:
// file internal/data/todos.go
...
func (m TodoModel) GetAll() ([]Todo, error) {
return m.DB.Todos, nil
}
Insersing Todos
In this section we will add the ability for our app to insert new todos. We will see how we can add basic form validation and validation errors with htmx, how we can use out of bound updates to update multiple targets in a response and also we will add the ability for our app to display flash messages in a success on in an error (spoiler alert we will use the error flash for our error response handling).
Let's start by first thinking how we want the app to behave: In the todos page we want a "create todo" button, and when clicked it will be replaced with a simple form to add a todo with two buttons: "Add" and "Cancel". On cancel the form disappears and the button returns and in case of a success the todo will be prepended on the list and the form will be cleared out a simple sketch of this:
Let's start by updating our todos model to allow an insertion of a todo:
// file internal/data/todos.go
package data
import "time" // new import
type Todo struct {
ID int64
CreatedAt time.Time
Title string
Done bool
}
type TodoModel struct {
DB *DummyDB
}
func (m TodoModel) GetAll() ([]Todo, error) {
return m.DB.Todos, nil
}
// Add a new Insert method
func (m TodoModel) Insert(todo *Todo) error {
id := len(m.DB.Todos) + 1
todo.ID = int64(id)
todo.CreatedAt = time.Now()
todo.Done = false
m.DB.Todos = append(m.DB.Todos, *todo)
return nil
}
Now we should think about validation, we don't want arbitrary data to hit our model layer because this can compromise our application. The general idea will be to add a validation layer between the user submitting the todo and us storing it in the db. Let's start by working on our validation mechanism:
First we need to create the file that will store our validation package
mkdir internal/validator
touch internal/validator/validator.go
And let's add the code for our validation package:
// file internal/validator/validator.go
package validator
import (
"net/mail"
"slices"
"strings"
"unicode/utf8"
)
type Validator struct {
FieldErrors map[string]string
}
func New() *Validator {
return &Validator{FieldErrors: make(map[string]string)}
}
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0
}
func (v *Validator) AddError(key, message string) {
if v.FieldErrors == nil {
v.FieldErrors = make(map[string]string)
}
if _, exists := v.FieldErrors[key]; !exists {
v.FieldErrors[key] = message
}
}
func (v *Validator) Check(ok bool, key, message string) {
if !ok {
v.AddError(key, message)
}
}
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}
func MaxChars(value string, n int) bool {
return utf8.RuneCountInString(value) <= n
}
func MinChars(value string, n int) bool {
return utf8.RuneCountInString(value) >= n
}
func FieldsEqual(value1, value2 string) bool {
return value1 == value2
}
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
return slices.Contains(permittedValues, value)
}
func ValidMail(value string) bool {
_, err := mail.ParseAddress(value)
return err == nil
}
What is happening here is that we create a Validator struct with the FieldErrors inside which will store all the validation errors in a map where the key will be the field and the value will be the error's message.
Then we add the New function which is a constructor for our Validator struct. The Valid method checks on the FieldErrors field and if it is empty means it is valid. Then the AddError method allows us to modify the FieldErrors and add one error if it does not exist. Finally the Check method accepts a boolean and if it is false we add the error with the key and message provided. The boolean will most probably come from our helper functions we have added like NotBlank, MaxChars, MinChars, etc.
Note: we will not need all those field validation helpers, I have just added them for completion if needed on another project. There are some great articles about validations: #1, #2
We should create a reusable struct that will hold the form's inputs that the user will submit and also the Validator struct inlined so that the views can have easy access to it. Also we should think about what validations we want our schema to have: In order to demonstrate it perfectly let's say we want the title not to be empty and also be more than 10 characters and less than 150:
// file internal/data/todos.go
package data
import (
"time"
"github.com/antonisgkamitsios/simple-todo-app/internal/validator" // new import
)
...
// Add the struct that will represent the Form for the new todo
type TodoForm struct {
Title string
validator.Validator
}
// Add the validation method that will modify the FieldErrors in case of an error
func ValidateTodo(todo *Todo, v *validator.Validator) {
v.Check(validator.NotBlank(todo.Title), "title", "This field cannot be empty")
v.Check(validator.MinChars(todo.Title, 10), "title", "This field must be more than 10 characters long")
v.Check(validator.MaxChars(todo.Title, 150), "title", "This field must be less than 151 characters long")
}
...
Now we should also add a way for our application to decode forms, we could do it with the reqeust's PostForm map but I think we should use a package to make our lives easier, not so much for our dummy application but for a real app with lots of forms that contain many fields. The package we will use is the github.com/go-playground/form, so let's add it:
go get github.com/go-playground/form/v4@v4
And then we should do some preperation to make it easy to use in our entire application, first we need to initialize and pass it in our application struct:
// file cmd/web/main.go
package main
import (
"log/slog"
"net/http"
"os"
"github.com/antonisgkamitsios/simple-todo-app/internal/data"
"github.com/go-playground/form/v4" // new import
)
...
type application struct {
config config
logger *slog.Logger
models data.Models
// Add a pointer to a Decoder
formDecoder *form.Decoder
}
func main() {
logger := slog.New((slog.NewTextHandler(os.Stdout, nil)))
cfg := config{
env: "development",
}
db := data.NewDummyDB()
// initialize the Decoder
formDecoder := form.NewDecoder()
app := &application{
logger: logger,
config: cfg,
models: data.NewModels(db),
// pass the formDecoder to the application
formDecoder: formDecoder,
}
...
}
And now we should create a helper method to decode forms:
// file cmd/web/helpers.go
...
func (app *application) decodePostForm(r *http.Request, dst any) error {
err := r.ParseForm()
if err != nil {
return err
}
err = app.formDecoder.Decode(dst, r.PostForm)
if err != nil {
var invalidDecodeError *form.InvalidDecoderError
if errors.As(err, &invalidDecodeError) {
panic(err)
}
return err
}
return nil
}
What we are doing here is we're using the ParseFrom method to populate the PostForm and then using the new package to populate the dst parameter. Also if it errors and the error is of type form.InvalidDecoderError we want to panic because that means we passed an invalid argument to Decode and it is not a user's error rather than a developer's error.
We also need to add the form tags in the data.TodoForm struct for the decoder to be able to see them:
// file internal/data/todos.go
...
type TodoForm struct {
Title string `form:"title"`
validator.Validator `form:"-"`
}
...
With that we are able to use our new helper in a handler, but first we should write some code in order to make the button for creating the todo and on click showing the form:
// file view/Todos.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
import "github.com/antonisgkamitsios/simple-todo-app/internal/data"
templ Todos(todos []data.Todo) {
@layout.Base("Todos") {
<div class="max-w-xl mx-auto mt-8 bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">My Todosh1>
// Add the button that will replace its contents with the form
<button
hx-get="/todos/new"
hx-swap="outerHTML"
class="border-2 rounded-lg border-green-200 px-4 py-2 flex items-center gap-1.5 cursor-pointer hover:bg-green-50 transition-colors my-2 mx-auto min-w-[200px]"
>
<span class="text-xl font-bold leading-none relative top-[-1px]">+span> New todo
button>
if len(todos) == 0 {
<div>
<p class="text-gray-500 text-center">No todos yet. Add some tasks!p>
div>
} else {
<ul class="space-y-3">
for _,todo := range todos {
<li class="flex items-center justify-between bg-gray-200 p-3 rounded-lg">
<span class="text-lg">{ todo.Title }span>
li>
}
ul>
}
div>
}
}
We added the new todo button and we indicate that we want to fire a GET request to the "/todos/new" endpoint and swap the entire button. If we try clicking on it we will get the button swapped with a message "404 page not found". This is because the default behaviour of our server when it does not find the endpoint is to send this message so the htmx does what it is ordered to do. Now we have to add the component for this endpoint:
// file view/Todos.templ
...
// Add the TodoForm Component
templ TodoForm() {
<form class="bg-white p-6 rounded-lg max-w-xl mx-auto flex items-start justify-center gap-2">
<input
type="text"
name="title"
id="title"
class="w-full border-2 border-gray-300 p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-green-400"
placeholder="Enter a todo..."
/>
<button
type="submit"
class="border-2 bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600 transition"
>
Add
button>
<button
type="button"
class="border-2 border-gray-300 px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition"
>
Cancel
button>
form>
}
And then add the handler like so:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodosNew(w http.ResponseWriter, r *http.Request) {
app.render(w, r, http.StatusOK, view.TodoForm())
}
And then register the handler to our routes:
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
router.HandlerFunc(http.MethodGet, "/todos", app.handleTodos)
// Add the new endpoint
router.HandlerFunc(http.MethodGet, "/todos/new", app.handleTodosNew)
return app.disableCacheInDevMode(app.comesFromHTMX(router))
}
With this all set we can now test that pressing the button indeed swaps it with our newly created form.
Now we should work with the two buttons "Add" and "Cancel". Pressing cancel should return us to the original state, we can do it easily by firing a get request on the /todos and swapping the entire #contents with the response. You could argue that we could create a dedicated endpoint that will return only the button, but for the purposes of our application this will do just fine.
// file view/Todos.templ
templ TodoForm() {
<form class="bg-white p-6 rounded-lg max-w-xl mx-auto flex items-start justify-center gap-2">
<input
type="text"
name="title"
id="title"
class="w-full border-2 border-gray-300 p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-green-400"
placeholder="Enter a todo..."
/>
<button
type="submit"
class="border-2 bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600 transition"
>
Add
button>
// Add the htmx attributes for the cancel button
<button
type="button"
class="border-2 border-gray-300 px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition"
hx-get="/todos"
hx-target="#contents"
hx-swap="outerHTML"
>
Cancel
button>
form>
}
Now pressing the cancel button will get us to the initial state
Handling Creation
Creation is a bit more tricky because we have to decode the form, validate it, in case of a validation error we want to display those errors, in case of success we want to create the todo and prepend it to the list of todos.
We can start working with our handler that will parse the form:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
if err != nil {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again")
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
// The form failed validation we must send what the user sent wrong
return
}
err = app.models.Todos.Insert(todo)
if err != nil {
app.serverError(w, r, err)
return
}
// the todo was created just fined we need to send something
}
There are two problems with this code:
1) In case of a validation error we don't do anything. In order to fix that we need to remember that the ValidateTodo method is populating the input's validator field and we can pass this to our form component in order to display the user those errors like this:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
if err != nil {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again")
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
// render the form with the input as props
app.render(w,r,http.StatusUnprocessableEntity, view.TodoForm(input))
return
}
...
}
But the code will not compile because we have to update the view.TodoForm as well to be aware of the new prop and also update the form so that it fires a post request to our endpoint:
// file view/Todos.templ
...
templ TodoForm(f data.TodoForm) {
// Add htmx attributes to send the form to the correct endpoint
<form hx-post="/todos" hx-swap="outerHTML" class="bg-white p-6 rounded-lg max-w-xl mx-auto flex items-start justify-center gap-2">
// Wrap the input on a div and when that div has an element with classname error make its text red
<div class="has-[.error]:text-red-400">
<input
type="text"
name="title"
id="title"
class="w-full border-2 border-gray-300 p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-green-400"
placeholder="Enter a todo..."
/>
if val, exists := f.FieldErrors["title"]; exists {
<div class="text-red-400 error">
{ val }
div>
}
div>
<button
type="submit"
class="border-2 bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600 transition"
>
Add
button>
<button
type="button"
class="border-2 border-gray-300 px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition"
hx-get="/todos"
hx-target="#contents"
hx-swap="outerHTML"
>
Cancel
button>
form>
}
Here we say that if the data.TodoForm has a FieldError with key "title" we should display its value and also add styles to indicate that it has an error. We also added hx-post and hx-swap attributes to the form in order to fire the correct request.
Also we need update our handleTodosNew handler like this to reflect the new signature of the component:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodosNew(w http.ResponseWriter, r *http.Request) {
app.render(w, r, http.StatusOK, view.TodoForm(data.TodoForm{}))
}
...
With this we can register the handler in our routes:
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
router.HandlerFunc(http.MethodGet, "/todos", app.handleTodos)
// Add the create handler
router.HandlerFunc(http.MethodPost, "/todos", app.handleTodosCreate)
router.HandlerFunc(http.MethodGet, "/todos/new", app.handleTodosNew)
return app.disableCacheInDevMode(app.comesFromHTMX(router))
}
Now we should see that pressing the new button and submitting a todo with an empty title or with a title that is less than 10 characters or more than 150 will render the same form back with the errors displayed on our screen:
But there is an issue, if you can see every time we are submitting the form the previous value gets lost, that is pretty annoying but luckily there is a very easy fix: if you can recall the data.TodoForm holds also the information of the value of the Title the user submitted, so we can use it like this:
// file view/Todos.templ
...
templ TodoForm(f data.TodoForm) {
<form hx-post="/todos" hx-swap="outerHTML" class="bg-white p-6 rounded-lg max-w-xl mx-auto flex items-start justify-center gap-2">
<div class="has-[.error]:text-red-400">
// Add the value attribute with the TodoForm's Title
<input
type="text"
name="title"
value={ f.Title }
id="title"
class="w-full border-2 border-gray-300 p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-green-400"
placeholder="Enter a todo..."
/>
if val, exists := f.FieldErrors["title"]; exists {
<div class="text-red-400 error">
{ val }
div>
}
div>
<button
type="submit"
class="border-2 bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600 transition"
>
Add
button>
<button
type="button"
class="border-2 border-gray-300 px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition"
hx-get="/todos"
hx-target="#contents"
hx-swap="outerHTML"
>
Cancel
button>
form>
}
Now that we added in the input the value of the data.TodoForm's Title, whenever the form gets rendered with the data.TodoForm populated the input will be there as well. We can test it and see that between validation errors the input is preserved.
With validation out of the way we need to see what we want to happen when the todo gets created: We want the form to empty itself, and then prepend the newly created todo to the list. To achieve this we are going to use htmx' out of bands updates.
First let's extract the todo card into a standalone component (this will help us with reusing it in different contexts)
// file view/Todos.templ
...
// Add the TodoCard component
templ TodoCard(todo data.Todo) {
<li class="flex items-center justify-between bg-gray-200 p-3 rounded-lg">
<span class="text-lg">{ todo.Title }span>
li>
}
And then also change the Todos component to use this TodoCard:
// file view/Todos.templ
...
templ Todos(todos []data.Todo) {
@layout.Base("Todos") {
<div class="max-w-xl mx-auto mt-8 bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">My Todosh1>
// Add the button that will replace its contents with the form
<button
hx-get="/todos/new"
hx-swap="outerHTML"
class="border-2 rounded-lg border-green-200 px-4 py-2 flex items-center gap-1.5 cursor-pointer hover:bg-green-50 transition-colors my-2 mx-auto min-w-[200px]"
>
<span class="text-xl font-bold leading-none relative top-[-1px]">+span> New todo
button>
if len(todos) == 0 {
<div>
<p class="text-gray-500 text-center">No todos yet. Add some tasks!p>
div>
} else {
<ul class="space-y-3">
for _,todo := range todos {
// Add the new component
@TodoCard(todo)
}
ul>
}
div>
}
}
...
Finally we are ready to create a component that will include a form with a todo card:
// file view/Todos.templ
...
templ TodoFormWithTodo(f data.TodoForm, todo data.Todo) {
@TodoForm(f)
<div hx-swap-oob="afterbegin" id="todos">
@TodoCard(todo)
div>
}
Here we say to the htmx that when you get the response back, you are going to swap the form (if you remember the form has hx-post="/todos" hx-swap="outerHTML"
which means that whatever comes back from the /todos, we will swap the outerHTML of the form with the response). Also the hx-swap-oob="afterbegin"
indicates that the TodoCard will be cherry picked and be put before the first child of the element with id todos
.
For this to work we need to add the id of todos in the ul element in the Todos Component like this:
// file view/Todos.templ
...
templ Todos(todos []data.Todo) {
...
<ul class="space-y-3" id="todos">
for _,todo := range todos {
@TodoCard(todo)
}
ul>
...
}
...
And then we need to update the handler to render this component on success:
...
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
if err != nil {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again")
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
app.render(w, r, http.StatusUnprocessableEntity, view.TodoForm(input))
return
}
err = app.models.Todos.Insert(todo)
if err != nil {
app.serverError(w, r, err)
return
}
// render the new component on success
app.render(w, r, http.StatusCreated, view.TodoFormWithTodo(data.TodoForm{}, *todo))
}
Let's put it into test. When the todo passes validation we should see the newly created todo pepended into the list. But on a reload the new todo goes back into the bottom, this is because the append in the Insert method adds it in the end. In order to fix it we should update the GetAll method to sort them based on created at on descending order:
// file internal/data/todos.go
package data
import (
"slices" // new import
"time"
"github.com/antonisgkamitsios/simple-todo-app/internal/validator"
)
...
func (m TodoModel) GetAll() ([]Todo, error) {
// clone the slice in order to perform in place sorting in descending order
cpyTodos := slices.Clone(m.DB.Todos)
// sort the todos based on the CreatedAt
slices.SortFunc(cpyTodos, func(a, b Todo) int {
return b.CreatedAt.Compare(a.CreatedAt)
})
return cpyTodos, nil
}
With this we are able to see todos with the the most newly created on top.
Final touches
One thing that we can add in order to make our app prettier is to add some animation on when the todo gets added. In order to achieve that we are going to utilize that htmx adds and removes some classes on swapping the old content and settling with the new. More information can be found here. What we need to know is that when the new content is about to be added it gets assigned the .htmx-added
class and when it is settled the class is removed. We can take advantage of that and add some tailwind classes to give a nice result like this:
// file view/Todos.templ
...
templ TodoCard(todo data.Todo) {
<li class="flex items-center justify-between bg-gray-200 p-3 rounded-lg [.htmx-added]:bg-green-500 transition duration-700 ease-in-out">
<span class="text-lg">{ todo.Title }span>
li>
}
...
With this we say that if the TodoCard has the class .htmx-added
give it green background and also add some transition and duration to make it nicer. If we test to add a new todo we can see that the animation blends in nicely!
Finally there is also an issue right now with our form, while the request to create a todo is ongoing the user has no indication that something is happening and also the buttons of the form are still clickable leading to many unpredictable side effects. In our local server this is practically imposible to happen because there is zero delay but in a real case scenario it is very likely to happen. In order to demonstrate the issue let's give our handler a 5 second sleep before responding with anything like this:
// file cmd/web/handler_todos.go
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
// add a five second sleep to simulate delay
time.Sleep(5 * time.Second)
...
}
Now trying to add a todo we see our application practically doing nothing and we have 0 knowledge if the request is still ongoing or if i didnt press the button for example.
In order to change that we are going again to use the classes that htmx adds. When a request is fired htmx adds the htmx-request
class to the element that triggered the request or to the element that the hx-indicator
attribute dictates. We can see it happening to our application, for example with the delay on, trying to create a todo will add to the form the htmx-request
class. But we want to add this class to the buttons in order to make them disabled. In order to do this we will use the hx-indicator
. Let me demonstrate to make this more clear:
// file view/Todos.templ
templ TodoForm(f data.TodoForm) {
// Add the new hx-indicator with selector the class form-button
// also add the hx-disabled-elt attribute to dictate which elements
// we want to be disabled while the request is ongoing
<form
hx-post="/todos"
hx-swap="outerHTML"
hx-indicator=".form-button"
hx-disabled-elt=".form-button"
class="bg-white p-6 rounded-lg max-w-xl mx-auto flex items-start justify-center gap-2"
>
<div class="has-[.error]:text-red-400">
<input
type="text"
name="title"
value={ f.Title }
id="title"
class="w-full border-2 border-gray-300 p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-green-400"
placeholder="Enter a todo..."
/>
if val, exists := f.FieldErrors["title"]; exists {
<div class="text-red-400 error">
{ val }
div>
}
div>
// Add the form-button class to the button plus classes
<button
type="submit"
class="form-button border-2 bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600 transition"
>
Add
button>
// Add the form-button class to the button
<button
type="button"
class="form-button border-2 border-gray-300 px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition"
hx-get="/todos"
hx-target="#contents"
hx-swap="outerHTML"
>
Cancel
button>
form>
}
Notice that we added also the hx-disabled-elt=".form-button"
attribute which dictates which elemetns will be disabled. (More info)
Now if we try to create a todo we will see that the two buttons will get the htmx-request
class instead and also the disabled attribute. With this we can add some tailwind classes to the buttons like this:
<button
type="submit"
class="form-button border-2 bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600 transition [.htmx-request]:bg-green-200 [.htmx-request]:cursor-not-allowed"
>
Add
button>
<button
type="button"
class="form-button border-2 border-gray-300 px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition [.htmx-request]:text-gray-200 [.htmx-request]:border-gray-200 [.htmx-request]:cursor-not-allowed"
hx-get="/todos"
hx-target="#contents"
hx-swap="outerHTML"
>
And now firing a request will disable the buttons plus give the indication that something is happening. You can get very creative with this but for our application this will be good enough. Finally let's remove the fake delay from our handler:
// file cmd/web/handler_todos.go
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
// delay removed
...
}
Note: perhaps it would be cleaner to add specific styles to the buttons in case they are disabled since we are telling htmx to make them disabled via the hx-disabled-elt=".form-button"
attribute. But I wanted to demonstrate also the power of the the hx-indicator and the htmx-request class, which can be useful in many scenarios that we want to indicate to the user that a request is ongoing.
We have covered a lot of ground so far, right now we are able to validate a todo submission, on error display the errors and preserve the state and on success only update html elements that need to be updated, for example only send back the html for the new todo and not all the todos, because this would get heavy if for example we had thousands of todos.
But there is also one last thing that is missing that will boost our app to the moon: The ability to show sucess or error messages inline in the screen like a flash. In the next section we will work towards it.
Adding flash messages
Right now in case of an error in our handleTodosCreate
we will swap the entire contents of target and render the error, we can see this in action if we change the if statement to always render the error:
...
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
// add || true to always pass
if err != nil || true {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again")
return
}
...
}
...
On submit the form gets swapped out and we render the error page inside:
It would be cool if the error was not interfering with the rest of the page and was displayed as a flash message for example.
In order to do that we will need to modify the serverError and clientError methods to accept a parameter to be rendered as a flash message, and also add the structure to render flash messages. Let's begin:
First we need to update the signature of our serverError and clientError functions like this:
// file cmd/web/errors.go
...
// add the new replacePage parameter
func (app *application) renderError(w http.ResponseWriter, r *http.Request, status int, title, message string, replacePage bool) {
// Add the vary header to avoid caching if the "HX-Retarget" header is different
w.Header().Add("Vary", "HX-Retarget")
// If the replacePage is true we want the view.Error to target the #contents of our page and replace it all
if replacePage {
w.Header().Add("HX-Retarget", "#contents")
} else {
// If the replacePage is false we want the view.Error to target only the #errors of our page (not the entire page)
w.Header().Add("HX-Retarget", "#errors")
// swap also the innerHTML of the #errros with the new error message
w.Header().Add("HX-Reswap", "innerHTML ")
}
app.render(w, r, status, view.Error(title, message))
}
// add the new replacePage parameter
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error, replacePage bool) {
app.logError(r, err)
app.renderError(
w,
r,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
"Something went wrong",
// pass down the argument
replacePage,
)
}
// add the new replacePage parameter
func (app *application) clientError(w http.ResponseWriter, r *http.Request, status int, message string, replacePage bool) {
app.renderError(
w,
r,
status,
http.StatusText(status),
message,
// pass down the argument
replacePage,
)
}
What we are doing right now is adding the ability for an error to swap the entirity of our page by using the HX-Retarget response header. So when the replacePage is true the header value will be #contents
meaning the error will replace all of our page (all our contents are wrapped inside the #contents div) and if the replacePage is false the error will target only the #errors div, which we need to add into our Base.templ file:
// file view/layout/Base.templ
package layout
import "github.com/antonisgkamitsios/simple-todo-app/view/components"
import "github.com/antonisgkamitsios/simple-todo-app/internal/htmx"
templ Base(title string) {
if htmx.ContextGetHtmxRequest(ctx) {
<title>Simple Todo App | { title }title>
<div id="contents">
{ children... }
div>
} else {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="static/styles.css"/>
<meta name="htmx-config" content='{"responseHandling": [{"csde":".*", "swap": true}]}'/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous">script>
<title>Simple Todo App | { title }title>
head>
<body>
@components.Header()
// add the new #errors div
<div id="errors">div>
<div id="contents">
{ children... }
div>
body>
html>
}
}
And we allso need to update the signature of all the places we use serverError
and clientError
:
// file cmd/web/handler_todos.go
package main
import (
"net/http"
"github.com/antonisgkamitsios/simple-todo-app/internal/data"
"github.com/antonisgkamitsios/simple-todo-app/view"
)
func (app *application) handleTodos(w http.ResponseWriter, r *http.Request) {
todos, err := app.models.Todos.GetAll()
if err != nil {
// let's add replacePage to true because if something happens and we don't have todos we don't want to show a flash message on a previous screen
app.serverError(w, r, err, true)
return
}
app.render(w, r, http.StatusOK, view.Todos(todos))
}
func (app *application) handleTodosNew(w http.ResponseWriter, r *http.Request) {
app.render(w, r, http.StatusOK, view.TodoForm(data.TodoForm{}))
}
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
if err != nil {
// here we want to add false to the replacePage argument because in case of an error we don't want the state of the form to be removed
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again", false)
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
app.render(w, r, http.StatusUnprocessableEntity, view.TodoForm(input))
return
}
err = app.models.Todos.Insert(todo)
if err != nil {
// Again here we want to add false to the replacePage argument because the state of the form needs to be preserved
app.serverError(w, r, err, false)
return
}
app.render(w, r, http.StatusCreated, view.TodoFormWithTodo(data.TodoForm{}, *todo))
}
Let's test that the replacePage=true works for the handleTodos handler:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodos(w http.ResponseWriter, r *http.Request) {
todos, err := app.models.Todos.GetAll()
// add || true to the if statement and also pass a hardcoded error
if err != nil || true {
app.serverError(w, r, errors.New("oops"), true)
return
}
app.render(w, r, http.StatusOK, view.Todos(todos))
}
...
And with that let's test navigating to the todos page. You should see that all the contents are being swapped by our error message + in the terminal the error is logged. Also refreshing the browser while on /todos should still show the error message and nothing else. Let's revert this change:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodos(w http.ResponseWriter, r *http.Request) {
todos, err := app.models.Todos.GetAll()
if err != nil {
app.serverError(w, r, err, true)
return
}
app.render(w, r, http.StatusOK, view.Todos(todos))
}
...
Now we should test the replacePage=false works:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
// add || true to the if statement so it always passes
if err != nil || true {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again", false)
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
app.render(w, r, http.StatusUnprocessableEntity, view.TodoForm(input))
return
}
err = app.models.Todos.Insert(todo)
if err != nil {
app.serverError(w, r, err, false)
return
}
app.render(w, r, http.StatusCreated, view.TodoFormWithTodo(data.TodoForm{}, *todo))
}
To get to the error we have to go to the /todos, press the button to add a todo and then press the "Add" button. As you can see the message is appearing on top but something does not seem right. If you inspect the browser you can see that 2 div#contents are being rendered, one with our error and one with the previous content.
Why this happens? If you can recall the view.Error component simply wraps the message on a Base component:
// file view/Error.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
templ Error(title, msg string) {
@layout.Base(title) {
<div class="container mx-auto my-12">
<div class="py-12 px-4 border p-11 text-center text-2xl">
{ msg }
div>
div>
}
}
so what happens in our inline error is that we send the error wrapped in a div#contents (because it is an htmx request at the end of the day)
You can validate this by looking on your network tab after firing an "Add" to the form
So we have to make the view.Error component aware of whether it is being rendered as an inline message or not. We can do it by passing down to it the replacePage parameter:
// file cmd/web/errors.go
...
func (app *application) renderError(w http.ResponseWriter, r *http.Request, status int, title, message string, replacePage bool) {
w.Header().Add("Vary", "HX-Retarget")
if replacePage {
w.Header().Add("HX-Retarget", "#contents")
} else {
w.Header().Add("HX-Retarget", "#errors")
w.Header().Add("HX-Reswap", "innerHTML ")
}
// pass the replacePage down to view.Error component
app.render(w, r, status, view.Error(title, message, replacePage))
}
...
and then on the component:
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
templ Error(title, msg string, replacePage bool) {
if replacePage {
@layout.Base(title) {
<div class="container mx-auto my-12">
<div class="py-12 px-4 border p-11 text-center text-2xl">
{ msg }
div>
div>
}
} else {
<div id="error">{ msg }div>
}
}
With this if the error should replace the page we wrap it with our Base component and if not we simply send it inside a div with id error.
We can now test our "Add" button and see that a simple text is appearing on the top of the screen.
Next we should work towards making this message more like a Flash message and also add the ability to hide it.
Taking into considaration that we will need flash messages for also success or warning messages, it is a good opportunity to add a reusable Toast component that will be used by both inline errors and flash messages:
touch view/components/Toast.templ
and inside:
// file view/components/Toast.templ
package components
templ Toast(flashType, message string) {
<div class={ "group fixed top-6 left-[50%] translate-x-[-50%] transition-transform animate-drop", flashType }>
<div class="max-w-lg min-w-80 group-[.success]:bg-teal-500 group-[.error]:bg-red-400 text-sm text-white rounded-xl shadow-lg" role="alert" tabindex="-1" aria-labelledby="hs-toast-solid-color-teal-label">
<div id="hs-toast-solid-color-teal-label" class="flex items-center p-4 text-base">
{ message }
<div class="ms-auto">
<button type="button" class="inline-flex shrink-0 justify-center items-center size-7 rounded-lg text-white hover:text-white opacity-50 hover:opacity-100 focus:outline-none focus:opacity-100" aria-label="Close">
<span class="sr-only">Closespan>
<svg class="shrink-0 size-5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18">path>
<path d="m6 6 12 12">path>
svg>
button>
div>
div>
div>
<script type="text/javascript">
(function(){
const flash = document.currentScript.closest('div')
const closeBt = flash.querySelector('button')
closeBt?.addEventListener('click', () => {flash?.remove()})
setTimeout(() => {
flash?.remove()
},4000)
})()
script>
div>
}
What we've done here is we have created a nice toast component and based on the type success
or error
we give the parent div the corresponding class and with the group-[.{flashtype}]:
tailwind class we are applying styles accordingly.
Finally we have added a little bit of interactivity to our toast component with an inline script that simply attaches an event listener to the close button and removed the toast from the dom, and adds a timeout of 4seconds and then removes it (if the ueser didnt close it already). Lastly if you can notice we have added an animate-drop
on the first div, this isn't a tailwind animation we will add it ourselves like so:
// file assets/css/app.css
@import 'tailwindcss';
@theme {
--animate-drop: drop 0.4s ease-in-out;
@keyframes drop {
0% {
transform: translate(0, -10px);
opacity: 0;
}
100% {
transform: translate(0, 0);
opacity: 1;
}
}
}
With this we are ready to put this new component on use in our view.Error component:
// file view/Error.templ
package view
import "github.com/antonisgkamitsios/simple-todo-app/view/layout"
import "github.com/antonisgkamitsios/simple-todo-app/view/components"
templ Error(title, msg string, replacePage bool) {
if replacePage {
@layout.Base(title) {
<div class="container mx-auto my-12">
<div class="py-12 px-4 border p-11 text-center text-2xl">
{ msg }
div>
div>
}
} else {
// replace the div with our new Toast component
@components.Toast("error", msg)
}
}
Now pressing the "Add" button on the create form will give us a nice flash message with the error we provided. This is very powerful, we can preserve the state and inform the user about various errors that might have happened during a request.
And with all that we are finished with the inline error handling.
Let's not forget to remove the || true
from our handler:
// file cmd/web/handler_todos.go
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
// remove the || true from the if statement
if err != nil {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again", false)
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
app.render(w, r, http.StatusUnprocessableEntity, view.TodoForm(input))
return
}
err = app.models.Todos.Insert(todo)
if err != nil {
app.serverError(w, r, err, false)
return
}
app.render(w, r, http.StatusCreated, view.TodoFormWithTodo(data.TodoForm{}, *todo))
}
Adding Success flash messages
Let's say we want when a todo is added to also display a nice flash message to the user informing them that the todo was added. In order to achieve that we will need to create a Flash component and somehow pass into the response that we also want the flash message to be displayed. Let's first work with the foundations: we will need to add on the initial reponse a div that will hold all the flash messages (similarly to what we did with the errors):
// file view/layout/Base.templ
package layout
import "github.com/antonisgkamitsios/simple-todo-app/view/components"
import "github.com/antonisgkamitsios/simple-todo-app/internal/htmx"
templ Base(title string) {
if htmx.ContextGetHtmxRequest(ctx) {
<title>Simple Todo App | { title }title>
<div id="contents">
{ children... }
div>
} else {
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="static/styles.css"/>
<meta name="htmx-config" content='{"responseHandling": [{"csde":".*", "swap": true}]}'/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous">script>
<title>Simple Todo App | { title }title>
head>
<body>
@components.Header()
<div id="errors">div>
// add a new div#flash
<div id="flash">div>
<div id="contents">
{ children... }
div>
body>
html>
}
}
Now let's think about how we can pass the flash info down to a component
we can a) pass it via props from the handler and then pass the flash information down to the Flash component or b) add the flash information in the context and then the Flash component will extract this information and render it no matter how nested the component is.
I like the second approach more + it gives us more flexibility if we want the flash information to be stored in a session for example.
Let's start by creating the package that will be responsible for flash messages
mkdir internal/flash
touch internal/flash/flash.go
and inside:
// file internal/flash/flash.go
package flash
import (
"context"
"net/http"
)
type contextKey string
const flashKey = contextKey("flash")
const (
FlashTypeSuccess string = "success"
FlashTypeError string = "error"
FlashTypeWarning string = "warning"
)
type Flash struct {
FlashType string
Message string
}
func ContextGetFlash(ctx context.Context) Flash {
flash, ok := ctx.Value(flashKey).(Flash)
if !ok {
return Flash{}
}
return flash
}
func ContextSetFlash(r *http.Request, flashType, message string) *http.Request {
flash := Flash{FlashType: flashType, Message: message}
ctx := context.WithValue(r.Context(), flashKey, flash)
return r.WithContext(ctx)
}
We are declaring our Flash structure which will hold what type this flash is and what is its message. We are also declaring 3 constants of the available types. And lastly similar to our htmx package we are adding methods to set and get from the context a Flash struct
With this out of the way we can add our Flash component like so:
touch view/components/Flash.templ
and inside:
// file view/components/Flash.templ
package components
import "github.com/antonisgkamitsios/simple-todo-app/internal/flash"
templ Flash() {
{{ f := flash.ContextGetFlash(ctx) }}
if f.Message != "" {
<div hx-swap-oob="true" id="flash">
@Toast(f.FlashType, f.Message)
div>
}
}
With this we are saying that if the context has a Flash stored in it and its message is not empty, then render it using our Toast component. We are also wrapping it in a div with hx-swap-oop="true" id="flash"
indicating that whenever htmx sees this component it will get the div and try to inject it inside the already existing div#flash. We are almsost there, what we need to do next is add a new flash message when a todo is created:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
if err != nil {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again", false)
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
app.render(w, r, http.StatusUnprocessableEntity, view.TodoForm(input))
return
}
err = app.models.Todos.Insert(todo)
if err != nil {
app.serverError(w, r, err, false)
return
}
// Add a new flash message into the request's context
r = flash.ContextSetFlash(r, flash.FlashTypeSuccess, "The todo was successfully added!")
app.render(w, r, http.StatusCreated, view.TodoFormWithTodo(data.TodoForm{}, *todo))
}
With this now the request has stored the information of the flash message we are trying to send the user. As a last step we also need to add the flash component inside the TodoFormWithTodo component like this:
// file view/Todos.templ
...
templ TodoFormWithTodo(f data.TodoForm, todo data.Todo) {
// add the new Flash component
@components.Flash()
@TodoForm(f)
<div hx-swap-oob="afterbegin" id="todos">
@TodoCard(todo)
div>
}
And we can check that everything works correctly by trying to add a new todo. If everything is set up fine we should see the new message appearing on our screen in a similar manner as our error.
Note: As a nice plus with the approach we went to store the flash info in the context, if for some reason we want a component with flash to be rendered without providing it a flash message, it will still play just fine without needing to pass empty structs or whatever, for example commenting out the line that adds the flash into the response:
// file cmd/web/handler_todos.go
...
func (app *application) handleTodosCreate(w http.ResponseWriter, r *http.Request) {
var input data.TodoForm
err := app.decodePostForm(r, &input)
if err != nil {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again", false)
return
}
todo := &data.Todo{Title: input.Title}
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
app.render(w, r, http.StatusUnprocessableEntity, view.TodoForm(input))
return
}
err = app.models.Todos.Insert(todo)
if err != nil {
app.serverError(w, r, err, false)
return
}
// r = flash.ContextSetFlash(r, flash.FlashTypeSuccess, "The todo was successfully added!")
app.render(w, r, http.StatusCreated, view.TodoFormWithTodo(data.TodoForm{}, *todo))
}
And then trying to add a todo will work out of the box, we just don't see a flash message. This is pretty good in my opinion. Let's uncomment the line now:
...
r = flash.ContextSetFlash(r, flash.FlashTypeSuccess, "The todo was successfully added!")
...
As a bonus you can try to add styles to the Toast component when the flashType is warning
and try to render a warning flash message as well!
We have written a lot of code so far but the end result is pretty amazing. We have a way to present errors in a way that can be easily used across the app, inline or by replacing the whole page and also we can display flash messages (success or warnings) very easily right now. For the last part of this article we will work on deleting and inline edditing a todo.
Deleting a todo
In order to delete a todo, we will take advantage of the fact that each todo has a unique id so firing a DELETE to /todo/{id}
, our server will be able to pick the todo matching the id (if any) and delete it. First let's add the button and its required htmx attributes:
// file view/Todos.templ
...
templ TodoCard(todo data.Todo) {
// store the css id that will be used by htmx
{{ id := fmt.Sprintf("todo-%d", todo.ID) }}
// add the id to the li so htmx knows what to target
// also on the button specify the endpoint that htmx will fire an AJAX request
// and the target and the swapping mechanism
<li id={ id } class="flex items-center justify-between bg-gray-200 p-3 rounded-lg [.htmx-added]:bg-green-500 transition duration-700 ease-in-out">
<span class="text-lg">{ todo.Title }span>
<button
hx-delete={ fmt.Sprintf("/todos/%d", todo.ID) }
hx-target={ fmt.Sprintf("#%s", id) }
hx-swap="outerHTML"
class="bg-red-200 px-2 py-1 rounded-lg cursor-pointer hover:bg-red-300"
>deletebutton>
li>
}
...
With this we are telling to htmx: when the button is clicked you are going to fire a DELETE on the /todos/{id}
and you will target the li that corresponds to the todo that is about to be deleted and also swap the entirety of this li with whatever comes from the server.
Then we should probabply implement the delete method in our dummy db:
// file internal/data/todos.go
...
// Add a new Delete method
func (m TodoModel) Delete(id int64) error {
todoIndexToDelete := -1
// find the index of the todo with the provided index
for index, todo := range m.DB.Todos {
if id == todo.ID {
todoIndexToDelete = index
break
}
}
// we didn't find a todo to delete, return an error
if todoIndexToDelete == -1 {
return errors.New("could not find todo to delete")
}
// delete the corresponding todo from the array and assign it to the db.Todos
m.DB.Todos = slices.Delete(m.DB.Todos, todoIndexToDelete, todoIndexToDelete+1)
return nil
}
with this code we are able to delete a todo with the provided id and in case the todo does not exist we simply return an error.
The next step is to add a handler to handle the todo delete and also respond with the appropriate html:
// file cmd/web/handler_todos.go
...
// add the new handleTodosDelete method
func (app *application) handleTodosDelete(w http.ResponseWriter, r *http.Request) {
// extract the id from the url params
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
// in case the id could not be extracted, simply return a not found with
// replacePage=false in order to preserve the page state and add a flash error message
if err != nil || id < 1 {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
// call our models Delete method
err = app.models.Todos.Delete(id)
// in case of an error (right now the only time an error occurs is when the id does not correspond to a todo)
// we return a not found error with replacePage=false in order to render a flash message
if err != nil {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
// All went good, so we want to add a success flash into the context and render it
r = flash.ContextSetFlash(r, flash.FlashTypeSuccess, "Todo was successfully deleted!")
// simply render the flash component
app.render(w, r, http.StatusOK, components.Flash())
}
What is happening in this handler is pretty straight forward thanks to all the work that we have done in the previous chapters.
We extract the id from the params, if that id for some reason cannot be extracted we return an error with replacePage=false, and because our renderError method, our server adds the the HX-Retarget: #errors header, so the response will simply ignore the hx-target from the button and simply will add the flash message to the errors div. Similarly when the Delete method could not find a todo to delete we do the same thing. Now when everything is successful we add a Flash into the context and render the components.Flash component. If you can recall the components.Flash component has in it an hx-swap-oob and what it does is whenever htmx finds it, it extracts it from the response and tries to put it in an element with the corresponding id, so again rendering the Flash component does not interfere with the response and as far htmx is concerned after stripping out the oob elements it receives an empty reponse so we will swap the card with nothing.
Now for the last part we need to register the handler into our routes:
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
router.HandlerFunc(http.MethodGet, "/todos", app.handleTodos)
router.HandlerFunc(http.MethodPost, "/todos", app.handleTodosCreate)
router.HandlerFunc(http.MethodGet, "/todos/new", app.handleTodosNew)
// Register the new delete route
router.HandlerFunc(http.MethodDelete, "/todos/:id", app.handleTodosDelete)
return app.disableCacheInDevMode(app.comesFromHTMX(router))
}
And with all the pieces in place we are ready to test it out. If you can see pressing the delete button fires a delete request to /todos/:id and then deletes the todo both in our database and also in the html. But there is a small issue, if we delete all of our todos, we simply don't see the empty todos message, only if we reload we can see it.
Handling empty state
The issue with that is that the code that checks wheather the todos are empty or not lives in the view.Todos component, and it only fires on a get request on the /todos. A similar issue can also happen when we delete all our todos, then reload or navigate away and back and then try to add a todo, you will see that the todo is not appended into a list because if the if
statement is true we simply don't render any list. So we have to fix that as well.
A quick way to mitigate that is by unconditionally render the list like this:
// file view/Todos.templ
// render the list unconditionally
templ Todos(todos []data.Todo) {
@layout.Base("Todos") {
<div class="max-w-xl mx-auto mt-8 bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">My Todosh1>
<button
hx-get="/todos/new"
hx-swap="outerHTML"
class="border-2 rounded-lg border-green-200 px-4 py-2 flex items-center gap-1.5 cursor-pointer hover:bg-green-50 transition-colors my-2 mx-auto min-w-[200px]"
>
<span class="text-xl font-bold leading-none relative top-[-1px]">+span> New todo
button>
if len(todos) == 0 {
<div>
<p class="text-gray-500 text-center">No todos yet. Add some tasks!p>
div>
}
<ul class="space-y-3" id="todos">
for _,todo := range todos {
@TodoCard(todo)
}
ul>
div>
}
}
...
Now if we repeat again the same process of deleting all the todos then reloading and then adding a todo at least the todo gets displayed on the screen, but the empty todo message stays. Now there are two ways we could approach this issue:
One approach is on every todo create to have something like an oob update on the empty message and delete it and on the delete fetch again all the todos or alter the Delete's method signature to return the remaining todos and if the todos after the delete are 0 then render the empty todos message as well.
But the second approach which I like better is to simply rely on taiwind's peerclass to do the trick, this way we only have to write some tailwind classes and don't care about altering responses or signatures etc. Let's give it a try:
// file view/Todos.templ
templ Todos(todos []data.Todo) {
@layout.Base("Todos") {
<div class="max-w-xl mx-auto mt-8 bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">My Todosh1>
<button
hx-get="/todos/new"
hx-swap="outerHTML"
class="border-2 rounded-lg border-green-200 px-4 py-2 flex items-center gap-1.5 cursor-pointer hover:bg-green-50 transition-colors my-2 mx-auto min-w-[200px]"
>
<span class="text-xl font-bold leading-none relative top-[-1px]">+span> New todo
button>
<ul class="space-y-3 peer" id="todos">
for _,todo := range todos {
@TodoCard(todo)
}
ul>
<div class="hidden peer-empty:block">
<p class="text-gray-500 text-center">No todos yet. Add some tasks!p>
div>
div>
}
}
...
If you can see we removed the if statement and we are always rendering the empty message. Then we swapped places between the ul and the message, so that we can utilize the peer class and the empty css pseudo class. So we give our ul the group class and afterwards on our empty message we say: you are always hidden but if the peer is empty (when there are no todos) you get to be displayed. So with these changes with only css we are managing empty state.
Inline editing todo
For the last part of our article we will implement an inline editing of our todos. The idea is that we will have an edit button on every todo and then on "click", the todo will be swapped with a form prefilled with the values of the todo with two buttons "Edit" and "Cancel".
First let's start by adding the edit button on the TodoCard like this:
// file view/Todos.templ
...
templ TodoCard(todo data.Todo) {
{{ id := fmt.Sprintf("todo-%d", todo.ID) }}
<li id={ id } class="flex items-center justify-between bg-gray-200 p-3 rounded-lg [.htmx-added]:bg-green-500 transition duration-700 ease-in-out">
<span class="text-lg">{ todo.Title }span>
<div class="flex flex-row gap-2">
<button
hx-get={ fmt.Sprintf("/todos/edit/%d", todo.ID) }
hx-target={ fmt.Sprintf("#%s", id) }
class="bg-blue-200 px-2 py-1 rounded-lg cursor-pointer hover:bg-blue-300"
>
edit
button>
<button
hx-delete={ fmt.Sprintf("/todos/%d", todo.ID) }
hx-target={ fmt.Sprintf("#%s", id) }
hx-swap="outerHTML"
class="bg-red-200 px-2 py-1 rounded-lg cursor-pointer hover:bg-red-300"
>
delete
button>
div>
li>
}
...
We wrapped both the buttons inside a flex container and on the edit button we are saying to fire a get request to the /todos/edit/:id
and target the card itself. Now if we save and click on the edit button we get the automatic 404 response from the router so we should register the /todos/edit/:id
endpoint. This endpoint will fetch a todo and then return a form prefilled with the values of the todo. In order to do it however we need to add in our TodoModel a way to fetch a todo by id so let's add it:
// file internal/data/todos.go
...
// Add the GetById method
func (m TodoModel) GetByID(id int64) (*Todo, error) {
var todo *Todo
for _, t := range m.DB.Todos {
if id == t.ID {
todo = &t
break
}
}
if todo == nil {
return nil, errors.New("could not find todo")
}
return todo, nil
}
This code is pretty straightforward, we loop over the todos and if we find a todo with id that matches the provided id we return it otherwise we return an error.
Now with the fetching mechanism in place we can start writing our handler to return the form:
// file cmd/web/handler_todos.go
...
// Add the handleTodosEdit handler
func (app *application) handleTodosEdit(w http.ResponseWriter, r *http.Request) {
// try to find the id from the context and if not found return 404
// NOTE: we should factor this code out into the cmd/web/helpers.go
// but i won't do it because the article is becoming a book at this point
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
// fetch the todo and if not found return 404
todo, err := app.models.Todos.GetByID(id)
if err != nil {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
// prefill the Form the the todo data
todoForm := data.TodoForm{
Title: todo.Title,
}
// render a form the the todoForm and the id of the todo
app.render(w, r, http.StatusOK, view.TodoFormEdit(todoForm, todo.ID))
}
This code again is very simple and idiomatic, we can very quickly understand everything that is happening. If we try to save our code however our code will not compile because we have not added the view.TodoFormEdit
component. Let's add it:
// file view/Todos.templ
templ TodoFormEdit(f data.TodoForm, todoID int64) {
{{ id := fmt.Sprintf("todo-%d", todoID) }}
<form
hx-patch={ fmt.Sprintf("/todos/%d", todoID) }
hx-indicator=".form-button"
hx-disabled-elt=".form-button"
class="bg-white p-6 rounded-lg max-w-xl mx-auto flex items-start justify-center gap-2"
>
<div class="has-[.error]:text-red-400">
<input
type="text"
name="title"
value={ f.Title }
id="title"
class="w-full border-2 border-gray-300 p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-green-400"
placeholder="Enter a todo..."
/>
if val, exists := f.FieldErrors["title"]; exists {
<div class="text-red-400 error">
{ val }
div>
}
div>
<button
type="submit"
class="form-button border-2 bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600 transition [.htmx-request]:bg-green-200 [.htmx-request]:cursor-not-allowed"
>
Edit
button>
<button
type="button"
class="form-button border-2 border-gray-300 px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition [.htmx-request]:text-gray-200 [.htmx-request]:border-gray-200 [.htmx-request]:cursor-not-allowed"
hx-get={ fmt.Sprintf("/todos/t/%d", todoID) }
hx-target={ fmt.Sprintf("#%s", id) }
hx-swap="outerHTML"
>
Cancel
button>
form>
}
This component looks awfully similar with our view.TodoForm
component, it's basically the same form and the same code that checks for errors and values, but the differences are that it needs the todo id in order to know where to send the patch to the edit, also the cancel button is different, we fire a get request into the /todos/t/:id
which will return a TodoCard and swap it with the current form. We could think of someways to refactor it into a more generic component but for the sake of our simple application we can ommit this part.
Now let's hook the the handler into our routes:
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
router.HandlerFunc(http.MethodGet, "/todos", app.handleTodos)
router.HandlerFunc(http.MethodPost, "/todos", app.handleTodosCreate)
router.HandlerFunc(http.MethodGet, "/todos/new", app.handleTodosNew)
router.HandlerFunc(http.MethodDelete, "/todos/:id", app.handleTodosDelete)
// add the new handler
router.HandlerFunc(http.MethodGet, "/todos/edit/:id", app.handleTodosEdit)
return app.disableCacheInDevMode(app.comesFromHTMX(router))
}
And we can give it a try. Pressing the edit button swaps the contents with a form and the title of the todo is already filled. Let's now start working on the cancel button. We should add the handler that will fetch a todo and render its TodoCard:
// file cmd/web/handler_todos.go
func (app *application) handleGetTodo(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
todo, err := app.models.Todos.GetByID(id)
if err != nil {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
app.render(w, r, http.StatusOK, view.TodoCard(*todo))
}
And then let's mount it on our routes like this:
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
router.HandlerFunc(http.MethodGet, "/todos", app.handleTodos)
router.HandlerFunc(http.MethodPost, "/todos", app.handleTodosCreate)
router.HandlerFunc(http.MethodGet, "/todos/new", app.handleTodosNew)
router.HandlerFunc(http.MethodDelete, "/todos/:id", app.handleTodosDelete)
// Add the new handler
router.HandlerFunc(http.MethodGet, "/todos/t/:id", app.handleGetTodo)
router.HandlerFunc(http.MethodGet, "/todos/edit/:id", app.handleTodosEdit)
return app.disableCacheInDevMode(app.comesFromHTMX(router))
}
And now pressing the cancel will swap it to its previous state. But you can notice that the animation we added back for when creating a new todo gets fired. This is happening because the htmx-added class is added to each new piece of content so our card will have this class when its swapped out. One way to deal with this is to have a way to distinguish between cards that should display animation and cards that should not, we can do it with an extra parameter on the TodoCard component and some inline scripting like this:
// file view/Todos.templ
...
// Add a "new" parameter to the function and if it's true:
// we add the "[.htmx-added]:bg-green-500" class into the list and
// render also the script that will remove the class on after settle to avoid
// the animation being fired if we add a new todo then edit it and press cancel since the class is present
templ TodoCard(todo data.Todo, new bool) {
{{ id := fmt.Sprintf("todo-%d", todo.ID) }}
<li
id={ id }
class={ "flex items-center justify-between bg-gray-200 p-3 rounded-lg d transition duration-700 ease-in-out",
templ.KV("[.htmx-added]:bg-green-500", new) }
>
<span class="text-lg">{ todo.Title }span>
<div class="flex flex-row gap-2">
<button
hx-get={ fmt.Sprintf("/todos/edit/%d", todo.ID) }
hx-target={ fmt.Sprintf("#%s", id) }
class="bg-blue-200 px-2 py-1 rounded-lg cursor-pointer hover:bg-blue-300"
>
edit
button>
<button
hx-delete={ fmt.Sprintf("/todos/%d", todo.ID) }
hx-target={ fmt.Sprintf("#%s", id) }
hx-swap="outerHTML"
class="bg-red-200 px-2 py-1 rounded-lg cursor-pointer hover:bg-red-300"
>
delete
button>
div>
if new {
<script>
(function() {
const card = document.currentScript.closest('li')
document.addEventListener('htmx:afterSettle', () => {
card.classList.remove('[.htmx-added]:bg-green-500')
},{ once: true })
}())
script>
}
li>
}
...
Notice the templ.KV
function that we added which will omit the key if the value is false if used inside a class you can learn more here. With that in place we must also update all the calls to that component, we only need the second argument to be true when we add a todo (in our TodoFormWithTodo
component)
// file view/Todos.templ
templ TodoFormWithTodo(f data.TodoForm, todo data.Todo) {
@components.Flash()
@TodoForm(f)
<div hx-swap-oob="afterbegin" id="todos">
// here we want the animation to fire
@TodoCard(todo, true)
div>
}
// file view/Todos.templ
templ Todos(todos []data.Todo) {
@layout.Base("Todos") {
<div class="max-w-xl mx-auto mt-8 bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">My Todosh1>
<button
hx-get="/todos/new"
hx-swap="outerHTML"
class="border-2 rounded-lg border-green-200 px-4 py-2 flex items-center gap-1.5 cursor-pointer hover:bg-green-50 transition-colors my-2 mx-auto min-w-[200px]"
>
<span class="text-xl font-bold leading-none relative top-[-1px]">+span> New todo
button>
<ul class="space-y-3 peer" id="todos">
for _,todo := range todos {
// here we want the animation to never fire
@TodoCard(todo, false)
}
ul>
<div class="hidden peer-empty:block">
<p class="text-gray-500 text-center">No todos yet. Add some tasks!p>
div>
div>
}
}
// file cmd/web/handler_todos.go
func (app *application) handleGetTodo(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
todo, err := app.models.Todos.GetByID(id)
if err != nil {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
// also here we dont want the animation to fire
app.render(w, r, http.StatusOK, view.TodoCard(*todo, false))
}
And with all the function calls updated and the code compiling we can test that pressing cancel on an edit form even if that todo is newly added works great. Now we should work towards the actual editing of the todo.
If we can recall it fires a patch request to /todos/:id
so our handler can extract the id, find the corresponding todo, then decode the form the user input and if valid: update the todo and send the new card otherwise render the validation errors. First let's start by adding the method to update a single todo in our models:
// file internal/data/todos.go
...
// Add the Update method
func (m TodoModel) Update(todo *Todo) error {
for i, dbTodo := range m.DB.Todos {
if dbTodo.ID == todo.ID {
m.DB.Todos[i].Title = todo.Title
m.DB.Todos[i].Done = todo.Done
return nil
}
}
return errors.New("could not find todo to update")
}
With this code we wil be able to update a specific todo, let's now proceed with the handler:
// file cmd/web/handler_todos.go
...
// Add the todo update handler
func (app *application) handleTodosUpdate(w http.ResponseWriter, r *http.Request) {
// fetch the id from the params and if cannot convert it to int return 404
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
// try to find the todo with the id provided in the params and if we cannot retrieve it return 404
todo, err := app.models.Todos.GetByID(id)
if err != nil {
app.clientError(w, r, http.StatusNotFound, "The todo could not be found", false)
return
}
// declare a TodoForm input that will store the decoded values plus any validation error
var input data.TodoForm
// decode the form into the input
err = app.decodePostForm(r, &input)
if err != nil {
app.clientError(w, r, http.StatusBadRequest, "Something went wrong with processing the form, check the data and try again", false)
return
}
// assign to the todo the inputs provided by the user
todo.Title = input.Title
// if it fails validation simply return the form with the validation errors in place
if data.ValidateTodo(todo, &input.Validator); !input.Validator.Valid() {
app.render(w, r, http.StatusUnprocessableEntity, view.TodoFormEdit(input, todo.ID))
return
}
// try to update the todo
err = app.models.Todos.Update(todo)
if err != nil {
app.serverError(w, r, err, false)
return
}
// when everything is successful simply return the the TodoCard
app.render(w, r, http.StatusOK, view.TodoCard(*todo, false))
}
...
This code does what we described above: fetch the id from the params => fetch the todo from the db => decode the user's input => validate it => assign the users input in the todo and store it in the db => render the new todo card. Finally we must register the handler:
// file cmd/web/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
router.HandlerFunc(http.MethodGet, "/", app.handleHome)
router.HandlerFunc(http.MethodGet, "/todos", app.handleTodos)
router.HandlerFunc(http.MethodPost, "/todos", app.handleTodosCreate)
router.HandlerFunc(http.MethodGet, "/todos/new", app.handleTodosNew)
router.HandlerFunc(http.MethodDelete, "/todos/:id", app.handleTodosDelete)
router.HandlerFunc(http.MethodGet, "/todos/t/:id", app.handleGetTodo)
router.HandlerFunc(http.MethodGet, "/todos/edit/:id", app.handleTodosEdit)
// register the update todo handler
router.HandlerFunc(http.MethodPatch, "/todos/:id", app.handleTodosUpdate)
return app.disableCacheInDevMode(app.comesFromHTMX(router))
}
And with that code in place we can start testing that our application works as expected. If we try to press "edit" and submit an invalid input (empty or less than 10 bytes) we can see that it works as expected, but submitting a valid form leads to a weird state in our html. Let's try to debug why this happens, if we can recall the TodoFormEdit fires a patch request and on success we swap its inner html with whatever the response is, so the todo card gets rendered inside the form and we dont want that. To fix this issue we must tell htmx to target the TodoCard and swap everything, so we have to add these attributes to the form:
templ TodoFormEdit(f data.TodoForm, todoID int64) {
{{ id := fmt.Sprintf("todo-%d", todoID) }}
<form
hx-patch={ fmt.Sprintf("/todos/%d", todoID) }
hx-target={ fmt.Sprintf("#%s", id) }
hx-swap="outerHTML"
hx-indicator=".form-button"
hx-disabled-elt=".form-button"
class="bg-white p-6 rounded-lg max-w-xl mx-auto flex items-start justify-center gap-2"
>
...
form>
}
And with this final touch we are oficially done. Now we can add we can delete and we can update a todo with only hypertext transfer and a little scripting to provide interactivity. This is pretty cool.
Final thoughts
This ended up to be a very extensive post, but I think it provides valuable information about how we can combine go + templ + htmx + tailwind to create a nice web page with interactivity and conventions that let us modify it and update it howerver we like. You can easily take the concepts that we learned here and apply them to all sorts of web applications.
This application is not production ready but the purpose of the post was to demonstrate how we can utilize these tools.
As some next steps I would advice you to learn more about the technologies we used and find out about their quirks and edge cases.
For htmx I suggest reading the documentation and also the hypermedia systems book which provides many useful information and use cases of htmx.
For templ I also suggest reading its documentation, it is pretty extensive and provides many information that you might find useful
For go there are many resources out there, but my personal favorites and the ones that inspired me to write this aricle are the books Let's Go and Let's Go Further. In these books you will learn how to implement webservers with go and how to interact with databases and how to struct your code in a way that makes development easy.
But most importantly build stuf!!