Enhancing Interactivity with HTMX without repeating yourself
Goal: So far every change (add, edit, toggle) sends the browser on a full round-trip: POST → 302 → GET. That is fine, but a modern UI can feel snappier. Instead of pulling in a heavy front-end framework we'll sprinkle one tiny script—HTMX—that turns our existing PageQL endpoints into AJAX fragment providers.
We still want:
- One source of truth for list-item HTML — no copy-pasting the same <li> in five places.
- A graceful fallback for visitors without JavaScript.
We'll achieve that by refactoring the current view / edit blocks into partials first, then letting HTMX ask those same partials for tiny bits of HTML.
Hands-on time: about 15 minutes.
1 Add HTMX once
At the end of the <head> section in templates/todos.pageql (or in your shared layout) drop in:
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
That's the only JavaScript we'll write today.
2 Refactor list rows into partials (no behaviour change yet)
Before we touch HTMX attributes, move the two <li> variants into local partials. Add them near the bottom of the same file:
{{#partial row_view}} <li {{#if completed}}class="completed"{{/if}}> <form method="POST" action="/todos/toggle" style="display:inline"> <input type="hidden" name="id" value="{{id}}"> <input class="toggle" type="checkbox" {{#if completed}}checked{{/if}} onchange="this.form.submit();"> </form> <label ondblclick="window.location='/todos?edit_id={{id}}'">{{text}}</label> <form method="POST" action="/todos/delete" style="display:inline"> <input type="hidden" name="id" value="{{id}}"> <button class="destroy" style="cursor:pointer; background:none; border:none; color:#ac4a1a;">✕</button> </form> </li> {{/partial}} {{#partial row_edit}} <li class="editing"> <form method="POST" action="/todos/save" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {{/partial}}
{{#partial row_view}} <li {{#if completed}}class="completed"{{/if}}> <form method="POST" action="/todos/toggle" style="display:inline"> <input type="hidden" name="id" value="{{id}}"> <input class="toggle" type="checkbox" {{#if completed}}checked{{/if}} onchange="this.form.submit();"> </form> <label ondblclick="window.location='/todos?edit_id={{id}}'">{{text}}</label> <form method="POST" action="/todos/delete" style="display:inline"> <input type="hidden" name="id" value="{{id}}"> <button class="destroy" style="cursor:pointer; background:none; border:none; color:#ac4a1a;">✕</button> </form> </li> {{/partial}} {{#partial row_edit}} <li class="editing"> <form method="POST" action="/todos/save" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {{/partial}}
Now update the original #from loop to render those partials instead of hard-coding <li> markup:
{{#from todos WHERE (:filter == 'all') OR (:filter == 'active' AND completed = 0) OR (:filter == 'completed' AND completed = 1) ORDER BY id}} {{#if :edit_id == :id}} {{#render row_edit}} {{#else}} {{#render row_view}} {{/if}} {{/from}}
{{#from todos WHERE (:filter == 'all') OR (:filter == 'active' AND completed = 0) OR (:filter == 'completed' AND completed = 1) ORDER BY id}} {{#if :edit_id == :id}} {{#render row_edit}} {{#else}} {{#render row_view}} {{/if}} {{/from}}
Reload the page—nothing should look different. We've simply centralised the HTML.
3 Make Add Todo feel instant
3.1 Add HTMX attributes to the header form
Replace the old <form> with:
<form hx-post="/todos/add" hx-target="ul" hx-swap="beforeend" hx-include="this"> <input name="text" placeholder="What needs to be done?" autofocus> </form>
<form hx-post="/todos/add" hx-target="ul" hx-swap="beforeend" hx-include="this"> <input name="text" placeholder="What needs to be done?" autofocus> </form>
- hx-post turns the submission into an AJAX call.
- hx-target tells HTMX where to inject the server's reply.
- hx-swap="beforeend" appends the fragment as the last child.
3.2 Teach the existing add endpoint to answer HTMX nicely
Modify the same add partial—not a new one:
{{#partial public add}} {{#param text required minlength=1}} {{#insert into todos(text, completed) values (:text, 0)}} {{#ifdef :headers.hx_request}} {{#from todos WHERE id = last_insert_rowid()}} {{#render row_view}} {{/from}} {{#else}} {{#redirect '/todos'}} {{/if}} {{/partial}}
{{#partial public add}} {{#param text required minlength=1}} {{#insert into todos(text, completed) values (:text, 0)}} {{#ifdef :headers.hx_request}} {{#from todos WHERE id = last_insert_rowid()}} {{#render row_view}} {{/from}} {{#else}} {{#redirect '/todos'}} {{/if}} {{/partial}}
HX-Request is automatically sent by htmx.js; PageQL exposes it here as HX_Request (underscore instead of hyphen).
Result: HTMX callers get a fresh <li> back, non-JS browsers still follow a normal redirect.
3.3 Clear the input field after submission
One final enhancement: we should clear the input box after a todo is added. Modify the add form:
<form hx-post="/todos/add" hx-target="ul" hx-swap="beforeend" hx-include="this" hx-on="htmx:afterOnLoad: this.reset()"> <input name="text" placeholder="What needs to be done?" autofocus autocomplete="off"> </form>
<form hx-post="/todos/add" hx-target="ul" hx-swap="beforeend" hx-include="this" hx-on="htmx:afterOnLoad: this.reset()" autocomplete="off"> <input name="text" placeholder="What needs to be done?" autofocus> </form>
The hx-on="htmx:afterOnLoad: this.reset()"
attribute tells HTMX to reset the form after the AJAX request completes successfully. This clears the input field, allowing the user to immediately add another todo without having to manually delete the previous text.
Now your app feels truly interactive: Add a todo, the item appears at the bottom of the list, and the input box clears automatically!
4 Inline Edit Todo without duplicate HTML
4.1 First, let a label request the edit form
Edit the label inside row_view so it becomes the trigger:
<label hx-get="/todos/edit_form?id={{id}}" hx-target="closest li" hx-swap="outerHTML" ondblclick="return false;">{{text}}</label>
<label hx-get="/todos/edit_form?id={{id}}" hx-target="closest li" hx-swap="outerHTML" ondblclick="return false;">{{text}}</label>
4.2 Create the tiny edit_form partial
{{#partial public edit_form}} {{#param id required type=integer min=1}} {{#from todos WHERE id = :id}} {{#render row_edit}} {{/from}} {{/partial}}
{{#partial public edit_form}} {{#param id required type=integer min=1}} {{#from todos WHERE id = :id}} {{#render row_edit}} {{/from}} {{/partial}}
4.3 Enhance the existing save endpoint
Append an HTMX branch so it returns a view row fragment on success:
{{#partial public save}} {{#param id required type=integer min=1}} {{#param text required minlength=1}} {{#update todos set text = :text WHERE id = :id}} {{#ifdef :headers.hx_request}} {{#from todos WHERE id = :id}} {{#render row_view}} {{/from}} {{#else}} {{#redirect '/todos'}} {{/if}} {{/partial}}
{{#partial public save}} {{#param id required type=integer min=1}} {{#param text required minlength=1}} {{#update todos set text = :text WHERE id = :id}} {{#ifdef :headers.hx_request}} {{#from todos WHERE id = :id}} {{#render row_view}} {{/from}} {{#else}} {{#redirect '/todos'}} {{/if}} {{/partial}}
The row HTML lives only once—in row_view—yet serves classic and HTMX flows.
4.4 Enhance the edit form with HTMX
Now let's also modify the row_edit
partial to use HTMX for submitting edits:
{{#partial row_edit}} <li class="editing"> <form hx-post="/todos/save" hx-target="this" hx-swap="outerHTML" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {{/partial}}
{{#partial row_edit}} <li class="editing"> <form hx-post="/todos/save" hx-target="this" hx-swap="outerHTML" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {{/partial}}
Here we've replaced the regular method="POST" action="/todos/save"
with HTMX attributes. When the form is submitted:
hx-post
sends an AJAX POST to the same endpoint we used beforehx-target="this"
means "target this form element"hx-swap="outerHTML"
replaces the form with the row_view returned by the save endpoint
This creates a seamless edit experience without any page reloads.
5 Check it live
- Add a Todo ➜ new item appears instantly, URL never changes.
- Double-click a Todo ➜ swaps into edit mode; submit ➜ swaps back.
- Disable JS ➜ every action still works via page reloads—progressive enhancement.
6 What we gained
- Interactivity: inline adds and edits with ~6 HTMX attributes total.
- Zero duplication: one partial for view, one for edit; endpoints choose what to render.
- Graceful fallback: the redirect branch stays for non-HTMX clients (or search-engine bots).
Next experiments: try deleting and toggling an item or using HTMX. Enjoy your streamlined, reactive PageQL app! 🚀