CRUD todos
The reference application: full CRUD with partial updates and zero hand-written htmx glue.
The reference CRUD application
The MVP acceptance demonstration (spec task 67): a TodoMVC-style todo
manager with create, read, update (inline title edit and done-toggle),
delete, active/completed filters, toggle-all, and clear-completed โ
every htmx interaction a partial update, with zero hand-written htmx
glue. TestZeroHandWrittenGlue enforces that claim mechanically.
The filter buttons reuse the bound /todos route with hx-vals; the
bulk operations are their own bound routes announcing themselves
through one merged todos-bulk-changed event. The store is capped
(100 todos, 200-character titles) because the app also runs as a
public live demo on the documentation site.
go run ./cmd # serves http://localhost:8080
go test . # end-to-end suite incl. the break-the-build proofs
Where each requirement lives
| Requirement | Demonstration |
|---|---|
| FR-020/021 โ route-aware bindings | crud.ghtmx: hx-post={ CreateTodo } / hx-get={ ListTodos } / hx-get={ TodoStats } (symbol bindings, paths folded statically) and ghtmxgen.EditTodo/ToggleTodo/RenameTodo/DeleteTodo(t.ID) (typed constructors with escaped parameters) |
| FR-031 โ compile-time fragments | todoList (page-embedded and standalone byte-identically โ asserted by TestListModeSelection), nested todoRow, handler-only todoEditRow, statsPanel |
| FR-035 โ adapter mode selection | ListTodos via nethttp.WithPage: browsers get the page, htmx gets the bare list |
| FR-037 โ event contract | event TodoCreated/TodoToggled/TodoDeleted/TodosBulkChanged declarations; handlers emit only ghtmxgen.Emit*; the stats panel listens with hx-trigger="todo-created from:body, โฆ" |
The break-the-build guarantee
TestRouteChangesBreakTheBuild copies the app into a scratch module and
proves the acceptance criterion three ways: deleting a bound route fails
generation with GHTMX-E0101 naming the binding; renaming a handler
(registration included) fails generation the same way; and renaming a
symbol-bound handler after generation breaks go build immediately
through the generated blank-identifier pin. Constructor bindings break
at the next ghtmx generate, which watch mode and CI keep in the loop.
Known-informational diagnostics
Generation reports one warning by design: GHTMX-W0104 for the
GET / page route (a full-page route is navigated to, not bound).
The handler-only todoEditRow fragment generates warning-free โ the
reachability check counts the handler's todoEditRowFragment(...)
call as rendering.
crud.ghtmx
package crud
import "github.com/go-monolith/ghtmx/ghtmxgen"
// The MVP acceptance application (task 67): full CRUD over todos with
// htmx partial updates through compile-time fragments, route-aware
// bindings, and the server-driven event contract. There is no
// hand-written htmx glue anywhere: every URL comes from a binding,
// every HX-Trigger from a generated emitter, and the htmx script tag
// from the pinned helper.
// The event contract: these declarations are the only source of the
// Emit* symbols the handlers use. The stats panel listens for them.
event TodoCreated(id string)
event TodoToggled(id string, done bool)
event TodoDeleted(id string)
event TodosBulkChanged(count int) // toggle-all and clear-completed announce themselves once
templ todoPage(todos []Todo, total int, done int) {
<!DOCTYPE html>
<html>
<head>
<title>ghtmx todos</title>
@ghtmxgen.HTMXScript()
<style>
:root { --primary: #008391; --bg: #f5f6f7; --surface: #ffffff; --text: #1c1e21; --muted: #525860; --border: #dadde1; color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
:root { --primary: #dbbc30; --bg: #1b1b1d; --surface: #242526; --text: #e3e3e3; --muted: #a5adba; --border: #444950; }
}
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif; margin: 0; min-height: 100vh;
display: flex; justify-content: center; align-items: flex-start;
background: var(--bg); color: var(--text);
}
.todoapp {
width: min(34rem, 92vw); margin-top: 3rem; border-radius: .6rem;
background: var(--surface); border: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0, 0, 0, .1); padding: 1.5rem 1.75rem 1rem;
}
h1 { margin: 0 0 1rem; font-weight: 650; letter-spacing: .01em; }
form.new-todo { display: flex; gap: .6rem; margin-bottom: .4rem; }
form.new-todo input {
flex: 1; font-size: 1rem; padding: .55rem .8rem; border-radius: .45rem;
border: 1px solid var(--border); background: var(--bg); color: inherit;
}
form.new-todo button {
font-size: .9rem; padding: .5rem .9rem; border-radius: .45rem; cursor: pointer;
border: 1px solid var(--primary); background: var(--primary); color: var(--surface);
}
.toolbar button {
font-size: .9rem; padding: .5rem .9rem; border-radius: .45rem; cursor: pointer;
border: 1px solid var(--border); background: var(--bg); color: inherit;
}
form.new-todo button:hover, .toolbar button:hover { border-color: var(--primary); }
.toolbar button:hover { color: var(--primary); }
table#todo-list { width: 100%; border-collapse: collapse; margin: .6rem 0; }
#todo-list td { padding: .45rem .3rem; border-bottom: 1px solid var(--border); }
#todo-list tr:last-child td { border-bottom: none; }
#todo-list td:nth-child(2) { width: 100%; font-size: 1.02rem; }
td.done { text-decoration: line-through; opacity: .45; }
.check {
width: 1.7rem; height: 1.7rem; border-radius: 50%; cursor: pointer;
border: 1.5px solid var(--border); background: none; color: var(--primary);
font-size: .95rem; line-height: 1;
}
.check:hover { border-color: var(--primary); }
.row-action {
border: none; background: none; cursor: pointer; color: inherit;
opacity: .4; font-size: .95rem; padding: .2rem .3rem;
}
.row-action:hover { opacity: 1; color: var(--primary); }
tr.empty td { text-align: center; opacity: .5; padding: 1.4rem 0; }
.toolbar {
display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;
padding-top: .6rem; border-top: 1px solid var(--border);
}
.toolbar .spacer { flex: 1; }
#todo-stats { font-size: .85rem; color: var(--muted); padding: .55rem 0; }
form.edit-inline { display: flex; gap: .5rem; }
form.edit-inline input { flex: 1; padding: .35rem .6rem; border-radius: .4rem; border: 1px solid var(--primary); background: inherit; color: inherit; }
</style>
</head>
<body>
<main class="todoapp">
<h1>Todos</h1>
@statsPanel(total, done)
<form
class="new-todo"
hx-post={ CreateTodo }
hx-target="#todo-list"
hx-swap="outerHTML"
>
<input type="text" name="title" placeholder="What needs doing?" maxlength="200" autofocus autocomplete="off" required/>
<button type="submit">Add</button>
</form>
@todoList(todos)
<div class="toolbar">
<button
hx-get={ ListTodos }
hx-target="#todo-list"
hx-swap="outerHTML"
>All</button>
<button
hx-get={ ListTodos }
hx-vals='{"filter": "active"}'
hx-target="#todo-list"
hx-swap="outerHTML"
>Active</button>
<button
hx-get={ ListTodos }
hx-vals='{"filter": "completed"}'
hx-target="#todo-list"
hx-swap="outerHTML"
>Completed</button>
<span class="spacer"></span>
<button
hx-put={ ToggleAllTodos }
hx-target="#todo-list"
hx-swap="outerHTML"
>Toggle all</button>
<button
hx-delete={ ClearCompleted }
hx-target="#todo-list"
hx-swap="outerHTML"
>Clear completed</button>
</div>
</main>
</body>
</html>
}
// statsPanel refreshes itself whenever any contract event fires
// anywhere on the page โ including the bulk operations' single
// merged announcement.
fragment statsPanel(total int, done int) {
<div
id="todo-stats"
hx-get={ TodoStats }
hx-trigger="todo-created from:body, todo-toggled from:body, todo-deleted from:body, todos-bulk-changed from:body"
hx-swap="outerHTML"
>
<span>{ itoa(done) } of { itoa(total) } done ยท { itoa(total - done) } left</span>
</div>
}
// The list is itself a fragment: the page embeds it inline, and the
// standalone entry point serves htmx refreshes byte-identically.
fragment todoList(todos []Todo) {
<table id="todo-list">
if len(todos) == 0 {
<tr class="empty"><td colspan="4">Nothing here โ add a todo above.</td></tr>
}
for _, t := range todos {
fragment todoRow(t Todo) {
<tr id={ "todo-" + t.ID }>
<td>
<button
class="check"
hx-put={ ghtmxgen.ToggleTodo(t.ID) }
hx-target={ "#todo-" + t.ID }
hx-swap="outerHTML"
title="toggle"
>
if t.Done {
โ
}
</button>
</td>
<td class={ doneClass(t.Done) }>{ t.Title }</td>
<td>
<button
class="row-action"
hx-get={ ghtmxgen.EditTodo(t.ID) }
hx-target={ "#todo-" + t.ID }
hx-swap="outerHTML"
>edit</button>
</td>
<td>
<button
class="row-action"
hx-delete={ ghtmxgen.DeleteTodo(t.ID) }
hx-target={ "#todo-" + t.ID }
hx-swap="outerHTML"
>โ</button>
</td>
</tr>
}
}
</table>
}
// The inline edit flow: the edit button swaps a row for this form, and
// submitting swaps back the refreshed row. The fragment is rendered
// only from the EditTodo handler (FR-034's handler-explicit path);
// the reachability check sees the handler's todoEditRowFragment(...)
// call, so a handler-only fragment generates warning-free.
fragment todoEditRow(t Todo) {
<tr id={ "todo-" + t.ID }>
<td colspan="4">
<form
class="edit-inline"
hx-put={ ghtmxgen.RenameTodo(t.ID) }
hx-target={ "#todo-" + t.ID }
hx-swap="outerHTML"
>
<input type="text" name="title" value={ t.Title } maxlength="200" autofocus required/>
<button type="submit">Save</button>
</form>
</td>
</tr>
}
crud.go
// The task-67 reference CRUD application: the MVP acceptance
// demonstration. Handlers render fragments through the nethttp adapter
// (FR-035 opt-in mode selection), emit contract events through the
// generated ghtmxgen emitters (FR-037), and the templates bind every
// URL through route-aware bindings (FR-020/FR-021) โ zero hand-written
// htmx glue.
//
// Renaming or deleting any route breaks the build at every binding
// site; main_test.go proves it. Symbol bindings pin the handler name in
// the committed generated code, so a rename breaks `go build`
// immediately; constructor bindings break at the next `ghtmx generate`
// (which watch and CI keep in the loop).
package crud
import (
"log"
"net/http"
"sort"
"strconv"
"sync"
"github.com/go-monolith/ghtmx/adapters/nethttp"
"github.com/go-monolith/ghtmx/ghtmxgen"
)
// Todo is the row model.
type Todo struct {
ID string
Title string
Done bool
}
// Store is an in-memory todo store with deterministic IDs.
type Store struct {
mu sync.Mutex
next int
todos map[string]Todo
}
// NewStore builds an empty todo store. Embedders that need isolated
// instances (the docs site scopes one per visitor) construct their
// own and route to them through StoreSelector.
func NewStore() *Store {
return &Store{todos: map[string]Todo{}}
}
// maxTodos and maxTitleLen bound the in-memory store: the app also
// runs as a public live demo on the docs site, where an unbounded
// store would be a memory-growth surface.
const (
maxTodos = 100
maxTitleLen = 200
)
func (s *Store) add(title string) (Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.todos) >= maxTodos {
return Todo{}, false
}
s.next++
t := Todo{ID: strconv.Itoa(s.next), Title: title}
s.todos[t.ID] = t
return t, true
}
func (s *Store) toggle(id string) (Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id]
if !ok {
return Todo{}, false
}
t.Done = !t.Done
s.todos[id] = t
return t, true
}
func (s *Store) remove(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.todos[id]; !ok {
return false
}
delete(s.todos, id)
return true
}
// toggleAll implements the TodoMVC rule: if any todo is still
// active, everything becomes done; if all are done, everything
// reopens. Returns how many rows changed.
func (s *Store) toggleAll() int {
s.mu.Lock()
defer s.mu.Unlock()
allDone := true
for _, t := range s.todos {
if !t.Done {
allDone = false
break
}
}
changed := 0
for id, t := range s.todos {
if t.Done == !allDone {
continue
}
t.Done = !allDone
s.todos[id] = t
changed++
}
return changed
}
// clearCompleted removes every done todo, returning how many.
func (s *Store) clearCompleted() int {
s.mu.Lock()
defer s.mu.Unlock()
removed := 0
for id, t := range s.todos {
if t.Done {
delete(s.todos, id)
removed++
}
}
return removed
}
func (s *Store) list() []Todo {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Todo, 0, len(s.todos))
for _, t := range s.todos {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool {
a, _ := strconv.Atoi(out[i].ID)
b, _ := strconv.Atoi(out[j].ID)
return a < b
})
return out
}
func (s *Store) rename(id, title string) (Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id]
if !ok {
return Todo{}, false
}
t.Title = title
s.todos[id] = t
return t, true
}
func (s *Store) get(id string) (Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id]
return t, ok
}
func (s *Store) stats() (total, done int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.statsLocked()
}
func (s *Store) statsLocked() (total, done int) {
for _, t := range s.todos {
total++
if t.Done {
done++
}
}
return total, done
}
// snapshot returns the list and stats under one lock, so a page render
// never shows a list inconsistent with its stats line.
func (s *Store) snapshot() (list []Todo, total, done int) {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.todos {
list = append(list, t)
}
sort.Slice(list, func(i, j int) bool {
a, _ := strconv.Atoi(list[i].ID)
b, _ := strconv.Atoi(list[j].ID)
return a < b
})
total, done = s.statsLocked()
return list, total, done
}
var todos = NewStore()
// StoreSelector lets an embedder scope the demo's state per request โ
// the docs site keys it by a visitor cookie so no two visitors share
// a list. Nil (the default, and the reference wiring) means the
// single shared package store. Install it before serving; handlers
// read it on every request.
var StoreSelector func(*http.Request) *Store
func currentStore(r *http.Request) *Store {
if StoreSelector != nil {
return StoreSelector(r)
}
return todos
}
// Template helpers.
func itoa(n int) string { return strconv.Itoa(n) }
func doneClass(done bool) string {
if done {
return "done"
}
return "pending"
}
// Index serves the full page.
func Index(w http.ResponseWriter, r *http.Request) {
list, total, done := currentStore(r).snapshot()
if err := todoPage(list, total, done).Render(r.Context(), w); err != nil {
log.Printf("render index: %v", err)
}
}
// filterTodos narrows a list to the ?filter= view; anything but
// "active" or "completed" means everything.
func filterTodos(list []Todo, filter string) []Todo {
if filter != "active" && filter != "completed" {
return list
}
wantDone := filter == "completed"
out := make([]Todo, 0, len(list))
for _, t := range list {
if t.Done == wantDone {
out = append(out, t)
}
}
return out
}
// ListTodos is the FR-035 demonstration: a browser navigating to
// /todos gets the full page, an htmx refresh gets the bare list โ the
// adapter selects the mode from the request. The filter buttons call
// the same bound route with hx-vals, so the view narrows without any
// hand-written URL.
func ListTodos(w http.ResponseWriter, r *http.Request) {
list, total, done := currentStore(r).snapshot()
list = filterTodos(list, r.FormValue("filter"))
err := nethttp.Render(w, r,
nethttp.WithPage(todoPage(list, total, done), todoListFragment(list)))
if err != nil {
log.Printf("render list: %v", err)
}
}
// ToggleAllTodos flips the whole list at once and announces the bulk
// change through one merged event.
func ToggleAllTodos(w http.ResponseWriter, r *http.Request) {
changed := currentStore(r).toggleAll()
if err := ghtmxgen.EmitTodosBulkChanged(w, ghtmxgen.TodosBulkChangedPayload{Count: changed}); err != nil {
log.Printf("emit todos-bulk-changed: %v", err)
}
list, _, _ := currentStore(r).snapshot()
if err := nethttp.Render(w, r, todoListFragment(list)); err != nil {
log.Printf("render list: %v", err)
}
}
// ClearCompleted deletes every done todo and returns the refreshed
// list.
func ClearCompleted(w http.ResponseWriter, r *http.Request) {
removed := currentStore(r).clearCompleted()
if err := ghtmxgen.EmitTodosBulkChanged(w, ghtmxgen.TodosBulkChangedPayload{Count: removed}); err != nil {
log.Printf("emit todos-bulk-changed: %v", err)
}
list, _, _ := currentStore(r).snapshot()
if err := nethttp.Render(w, r, todoListFragment(list)); err != nil {
log.Printf("render list: %v", err)
}
}
// CreateTodo adds a todo, announces it through the event contract, and
// returns the refreshed list fragment.
func CreateTodo(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title")
if title == "" {
http.Error(w, "title is required", http.StatusUnprocessableEntity)
return
}
if len(title) > maxTitleLen {
http.Error(w, "title is too long", http.StatusUnprocessableEntity)
return
}
t, ok := currentStore(r).add(title)
if !ok {
http.Error(w, "the demo store is full โ delete a todo first", http.StatusUnprocessableEntity)
return
}
if err := ghtmxgen.EmitTodoCreated(w, ghtmxgen.TodoCreatedPayload{Id: t.ID}); err != nil {
log.Printf("emit todo-created: %v", err)
}
list, _, _ := currentStore(r).snapshot()
err := nethttp.Render(w, r, todoListFragment(list), nethttp.Status(http.StatusCreated))
if err != nil {
log.Printf("render list: %v", err)
}
}
// ToggleTodo flips a todo's done state and returns its refreshed row.
func ToggleTodo(w http.ResponseWriter, r *http.Request) {
t, ok := currentStore(r).toggle(r.PathValue("id"))
if !ok {
http.NotFound(w, r)
return
}
if err := ghtmxgen.EmitTodoToggled(w, ghtmxgen.TodoToggledPayload{Id: t.ID, Done: t.Done}); err != nil {
log.Printf("emit todo-toggled: %v", err)
}
if err := nethttp.Render(w, r, todoRowFragment(t)); err != nil {
log.Printf("render row: %v", err)
}
}
// EditTodo swaps a row for its inline edit form.
func EditTodo(w http.ResponseWriter, r *http.Request) {
t, ok := currentStore(r).get(r.PathValue("id"))
if !ok {
http.NotFound(w, r)
return
}
if err := nethttp.Render(w, r, todoEditRowFragment(t)); err != nil {
log.Printf("render edit row: %v", err)
}
}
// RenameTodo saves an edited title and swaps the row back.
func RenameTodo(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title")
if title == "" {
http.Error(w, "title is required", http.StatusUnprocessableEntity)
return
}
if len(title) > maxTitleLen {
http.Error(w, "title is too long", http.StatusUnprocessableEntity)
return
}
t, ok := currentStore(r).rename(r.PathValue("id"), title)
if !ok {
http.NotFound(w, r)
return
}
if err := nethttp.Render(w, r, todoRowFragment(t)); err != nil {
log.Printf("render row: %v", err)
}
}
// DeleteTodo removes a todo; the empty response with an outerHTML swap
// removes the row client-side.
func DeleteTodo(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !currentStore(r).remove(id) {
http.NotFound(w, r)
return
}
if err := ghtmxgen.EmitTodoDeleted(w, ghtmxgen.TodoDeletedPayload{Id: id}); err != nil {
log.Printf("emit todo-deleted: %v", err)
}
w.WriteHeader(http.StatusOK)
}
// TodoStats renders the stats panel fragment.
func TodoStats(w http.ResponseWriter, r *http.Request) {
total, done := currentStore(r).stats()
if err := nethttp.Render(w, r, statsPanelFragment(total, done)); err != nil {
log.Printf("render stats: %v", err)
}
}
// Routes builds the example's router; the official docs site mounts
// it as a live demo.
func Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", Index)
mux.HandleFunc("GET /todos", ListTodos)
mux.HandleFunc("POST /todos", CreateTodo)
mux.HandleFunc("GET /todos/{id}/edit", EditTodo)
mux.HandleFunc("PUT /todos/{id}/title", RenameTodo)
mux.HandleFunc("PUT /todos/{id}", ToggleTodo)
mux.HandleFunc("PUT /todos/toggle-all", ToggleAllTodos)
mux.HandleFunc("DELETE /todos/{id}", DeleteTodo)
mux.HandleFunc("DELETE /todos/completed", ClearCompleted)
mux.HandleFunc("GET /todos/stats", TodoStats)
return mux
}
cmd/main.go
// Command crud serves the crud example standalone.
package main
import (
"fmt"
"net/http"
"os"
example "github.com/go-monolith/ghtmx/examples/crud"
)
func main() {
addr := "127.0.0.1:8080"
if v := os.Getenv("GHTMX_EXAMPLE_ADDR"); v != "" {
addr = v
}
fmt.Printf("Listening on http://%s/todos\n", addr)
if err := http.ListenAndServe(addr, example.Routes()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}