I made a sweet TODO app with Duso and HTMX

I made a sweet TODO app with Duso and HTMX

The “TODO MVP” is a rite of passage for programming languages and frameworks, it’s simple enough to understand quickly but complex enough to show how a framework handles routing, state, persistence, and UI.

TL;DR

This example is well featured and is only 164 lines of Duso code and HTML, which is really small. Even a fraction of some languages and frameworks (comparison below). This example shows Duso’s simple approach backed by Go’s legendary core libs.

Features

A quick brief about Duso

Duso is a free, open source batteries-included runtime with its own scripting language built in Go. It features everything most server applications would need built into its single 10MB binary. The binary also includes all its docs, script module libs, and code examples. Plus it has its own LSP server and linter with markdown support included inside. Disclosure: I made Duso and I love it.

The source code

Starting this project is basically download, write some code, look up a couple docs, and run it from the command line. It can also go production by adding SSL cert info to the HTTP server config, and bundled into its own single ~10MB binary with app scripts inside. We’ll set deployment aside for this example, but you can read more about it in the docs.

server.du

Our main script:

  1. configures a datastore named “todo” for persistence
  2. sets up an http server (defaults to port 8080)
  3. sets up dynamic routes that work with HTMX
  4. sets up a default static route for css/svg files
  5. starts the server which stays running until the process is killed
datastore("todos", {
  persist = "/CWD/todo-data/todos.gob",
  wal = "/CWD/todo-data/todos.wal"
})

server = http_server()

server.route("GET", "/", "index.du")
server.route("GET", "/:session", "index.du")
server.route("POST", "/todo/:session", "create.du")
server.route("GET", "/todo/:session/:id", "edit.du")
server.route("PATCH", "/todo/:session/:id", "update.du")
server.route("DELETE", "/todo/:session/:id", "delete.du")
server.route("POST", "/toggle/:session/:id", "toggle.du")

server.static("/*", ".")

print("Server listening on http://localhost:8080")
server.start()

index.du

  1. assigns a UUID for the session if there isn’t one
  2. serves up the app web page
  3. loads in css and HTMX
  4. renders any existing todos
render = require("render.du")
ctx = context()
res = ctx.response()
req = ctx.request()

session_id = req.params.session
if not session_id then
  session_id = uuid()
  res.redirect("/" + session_id)
end

function render_items(items, session_id)
  html = ""
  for i = 0, len(items) - 1 do
    html = html + render.todo(session_id, i)
  end
  return html
end

items = datastore("todos").get(session_id) or []

res.html("""
  <!DOCTYPE html>
  <html>
  <head>
    <title>to-duso htmx example</title>
    <script src="https://unpkg.com/htmx.org"></script>
    <link rel="stylesheet" href="/css/style.css">
  </head>
  <body>
    <main class="container">
      <div id="help-btn" class="help-btn" onclick="document.querySelector('small').classList.toggle('show')"></div>
      <h1>to-duso</h1>
      <small>Multi-session todo mvp with <a href="https://duso.rocks">duso</a> and <a href="https://htmx.org">htmx</a>. New sessions get a sharable URL. Data is persisted for all sessions.</small>
      <form hx-post="/todo/{{session_id}}" hx-target="ul" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
        <fieldset role="group">
          <input type="text" name="title" placeholder="Add a new todo..." required>
          <button type="submit">Add</button>
        </fieldset>
      </form>
      <ul>
        {{render_items(items, session_id)}}
      </ul>
    </main>
  </body>
  </html>
""")

render.du

This module renders individual todos and is used by most of the scripts.

  1. connects to the “todos” datastore (in-memory, fast)
  2. fetches todo by session and todo index
  3. returns the rendered html for HTMX to display
  4. modules return their API with a return statement, similar to an export in other languages
store = datastore("todos")

function render_todo(session_id, idx)
  items = store.get(session_id) or []
  item = items[idx]
  if not item then return "" end
  checked = item.completed ? "checked" : ""
  completed_class = item.completed ? "completed" : ""
  trash_icon = "<img src=\"icons/bold/trash-bold.svg\" class=\"icon-trash\">"
  return """
    <li class="{{completed_class}}">
      <input type="checkbox" {{checked}} hx-post="/toggle/{{session_id}}/{{idx}}" hx-swap="none">
      <span hx-get="/todo/{{session_id}}/{{idx}}" hx-target="this" hx-swap="outerHTML">{{item.title}}</span>
      <button class="delete" hx-delete="/todo/{{session_id}}/{{idx}}" hx-target="closest li" hx-swap="outerHTML">{{trash_icon}}</button>
    </li>
  """
end

return { todo = render_todo }

create.du

  1. adds a new todo to the array of todos stored for this session id
  2. returns the rendered todo html to HTMX
render = require("render.du")
ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")

session_id = req.params.session
items = store.get(session_id) or []
idx = len(items)
push(items, {title = req.form.title, completed = false})
store.set(session_id, items)

res.html(render.todo(session_id, idx))

toggle.du

  1. fetches a todo based on session and id
  2. changes its completed value
  3. returns a success status to HTMX
ctx = context()
res = ctx.response()
req = ctx.request()
session_id = req.params.session
idx = tonumber(req.params.id)

store = datastore("todos")
items = store.get(session_id) or []

items[idx].completed = not items[idx].completed
store.set(session_id, items)
res.error(204)

edit.du

  1. fetch a todo’s data by session and id
  2. return an edit form to HTMX
ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")

session_id = req.params.session
idx = tonumber(req.params.id)
items = store.get(session_id) or []
item = items[idx]

res.html("""
  <form hx-patch="/todo/{{session_id}}/{{idx}}" hx-target="closest li" hx-swap="outerHTML">
    <fieldset role="group">
      <input type="text" name="title" value="{{item.title}}" required autofocus>
      <button type="submit">Save</button>
    </fieldset>
  </form>
""")

update.du

  1. fetches todo data by session and id
  2. save new data to it in the datastore
  3. return rendered todo with new data to HTMX
render = require("./render.du")
ctx = context()
res = ctx.response()
req = ctx.request()
session_id = req.params.session
idx = tonumber(req.params.id)

store = datastore("todos")
items = store.get(session_id) or []
items[idx].title = req.form.title
store.set(session_id, items)

res.html(render.todo(session_id, idx))

delete.du

  1. set the value for a todo to nil (render.du will not render it, this keeps sequential ids stable at a small data cost, also it’s an MVP)
  2. returns an empty string to HTMX if successful
ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")

session_id = req.params.session
idx = tonumber(req.params.id)
items = store.get(session_id) or []

items[idx] = nil
store.set(session_id, items)

res.html("")

About the boilerplate lines

You’ll notice every route handler starts with the same 4 lines:

ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")

They look a bit repetitive. I could hide them away in an include() file and shave off a few lines per handler. But I feel it’s a better practice to leave things explicit rather than hide information to save a few lines of code.

How this compares

Want to see how this stacks up? Here are the same TODO app features built in 7 different languages and frameworks, all with similar feature sets:

Check out the full TODO App Framework Comparison for detailed breakdowns including learning curve, stack thickness, and other observations.