GitHub

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:

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.

« Part 5: Adding Filters

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>

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:

This creates a seamless edit experience without any page reloads.

5 Check it live

6 What we gained

Next experiments: try deleting and toggling an item or using HTMX. Enjoy your streamlined, reactive PageQL app! 🚀

« Back: Part 5: Adding Filters Next: Part 7: Integration & Extensibility »