Updating State (reactive toggle & edit)
Goal: Let users flip a Todo between active and completed and edit its text. You'll meet
update
, let
, stronger param
validation and conditional if
.
Estimated time: 20 minutes.
1 What we're building
- Before: you can list and add Todos (Parts 1-2).
- After: you can toggle completion, double-click to edit text, bulk-toggle all, and watch counts update live.
2 Extend templates/todos.pageql
2.1 Computed counts & flags
{%let active_count = COUNT(*) from todos WHERE completed = 0%} {%let completed_count = COUNT(*) from todos WHERE completed = 1%} {%let total_count = COUNT(*) from todos%} {%let all_complete = (:active_count == 0 AND :total_count > 0)%} {%param edit_id type=integer optional%}
{%let active_count = COUNT(*) from todos WHERE completed = 0%} {%let completed_count = COUNT(*) from todos WHERE completed = 1%} {%let total_count = COUNT(*) from todos%} {%let all_complete = (:active_count == 0 AND :total_count > 0)%} {%param edit_id type=integer optional%}
The let
directive calculates values that will be used throughout the template. Here we're computing counts of todos in various states (active, completed, total) and deriving a flag to indicate if all todos are complete. These variables are scoped to the current template or partial.
Note how the all_complete
flag uses the colon syntax :variable
to reference other variables in expressions. In PageQL, the colon prefix is required when using variables in SQL-like expressions to distinguish them from column names and prevent SQL injection. :active_count
refers to the previously set variable.
The param
directive declares and validates incoming request parameters. Here, edit_id
is declared as an optional integer parameter that will be used to track which todo is being edited.
2.2 Toggle checkbox inside the list
<li {{if completed}}class="completed"{{/if}}> <input hx-post="/todos/{{id}}/toggle" class="toggle" type="checkbox" {{if completed}}checked{{/if}}> <label hx-get="/?edit_id={{id}}">{{text}}</label> </li>
<li {%if completed%}class="completed"{%end if%}> <input hx-post="/todos/{{id}}/toggle" class="toggle" type="checkbox" {%if completed%}checked{%end if%}> <label hx-get="/?edit_id={{id}}">{{text}}</label> </li>
This markup creates a list item for each todo with a checkbox for toggling completion status. Note how PageQL conditional syntax if completed
is used twice:
- To conditionally add a CSS class when a todo is completed
- To check the checkbox for completed todos
In simple variable cases like {%if completed%}
, PageQL allows omitting the colon prefix. Inside the from
loop (not shown here), completed
, id
, and text
become available as variables from the query results.
The hx-get
attribute loads the same page with an edit_id
parameter, triggering edit mode.
2.3 Edit mode (conditional)
{{if :edit_id == :id}} <li class="editing"> <form hx-post="/todos/save" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {{else}} …view version from 2.2… {{/if}}
{%if :edit_id == :id%} <li class="editing"> <form hx-post="/todos/save" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {%else%} …view version from 2.2… {%end if%}
This conditional section demonstrates how PageQL handles more complex expressions. Notice that when comparing variables (:edit_id == :id
), the colon prefix is required on both sides because we're using a complex expression rather than a simple variable check.
When a todo's ID matches the edit_id
parameter, we show an edit form instead of the regular view. This creates an "inline editing" experience without requiring JavaScript for the basic functionality.
The else
directive provides alternative content when the condition isn't met - in this case, showing the normal view mode from section 2.2.
2.4 Toggle-all checkbox & footer counts
<form hx-post="/todos/toggle_all" id="toggle-all-form" style="display:block"> <input id="toggle-all" class="toggle-all" type="checkbox" {{if all_complete}}checked{{/if}} > <label for="toggle-all">Mark all as complete</label> </form> <span class="todo-count"> <strong>{{active_count}}</strong> item{{if :active_count != 1}}s{{/if}} left </span>
<form hx-post="/todos/toggle_all" id="toggle-all-form" style="display:block"> <input id="toggle-all" class="toggle-all" type="checkbox" {%if all_complete%}checked{%end if%}> <label for="toggle-all">Mark all as complete</label> </form> <span class="todo-count"> <strong>{{active_count}}</strong> item{%if :active_count != 1%}s{%end if%} left </span>
This section showcases how PageQL variables can be used to:
- Control UI state - the
all_complete
flag determines whether the toggle-all checkbox is checked - Display dynamic counts -
active_count
shows how many todos remain active - Control pluralization - adding "s" conditionally based on count (
if :active_count != 1
)
Notice that we need the colon syntax in :active_count != 1
because we're using a comparison operator, while the simple variable reference {{active_count}}
doesn't need it when just outputting the value.
2.5 New public partials
{{partial post :id/toggle}} {{param id required type=integer min=1}} {{update todos set completed = 1 - completed WHERE id = :id}} {{redirect '/todos'}} {{/partial}} {{partial public save}} {{param id required type=integer min=1}} {{param text required minlength=1}} {{update todos set text = :text WHERE id = :id}} {{redirect '/todos'}} {{/partial}} {%partial public toggle_all%} {%let active_count COUNT(*) from todos WHERE completed = 0%} {%update todos set completed = IIF(:active_count = 0, 0, 1)%} {%redirect '/todos'%} {%end partial%}
{%partial post :id/toggle%} {%param id required type=integer min=1%} {%update todos set completed = 1 - completed WHERE id = :id%} {%redirect '/todos'%} {%end partial%} {%partial public save%} {%param id required type=integer min=1%} {%param text required minlength=1%} {%update todos set text = :text WHERE id = :id%} {%redirect '/todos'%} {%end partial%} {%partial public toggle_all%} {%let active_count = COUNT(*) from todos WHERE completed = 0%} {%update todos set completed = IIF(:active_count = 0, 0, 1)%} {%redirect '/todos'%} {%end partial%}
These partials define server-side endpoints that handle data modifications. The public
keyword exposes them directly via HTTP POST requests, making them accessible at paths like /todos/toggle
.
Each partial follows a consistent pattern:
- Validate inputs: The
param
directive enforces type checking and validation rules - Modify data: Using
update
to change database records - Redirect: Send the user back to the main todo list
Note that toggle_all
needs its own active_count
variable inside its scope. In PageQL, each partial has its own variable scope if it is called as a page, so we must recalculate values needed within the partial rather than referencing those from the outer template.
The toggle
partial uses a clever SQL trick completed = 1 - completed
to flip between 0 and 1 without needing an if
statement, since 1-0=1
and 1-1=0
.
3 Walk-through
Piece | Purpose |
---|---|
update | Mutate an existing row (toggle, edit, bulk). |
let | Compute derived counts used in the UI. |
if … | Swaps markup between view and edit modes. |
1 - completed | A SQL trick to flip 0⇄1 with one statement. |
3.1 Request cycle in 2 steps
- Browser: hx-post
/todos/toggle
- Server:
toggle
partial updates row → list updates automatically.
POST /todos/toggle → UI updates automatically
4 Try it out
- Open http://localhost:8000/todos.
- Click a checkbox—row turns grey instantly without reloading.
- Double-click a label, edit text, hit Enter.
- Use the header checkbox to toggle all items on/off.
5 Recap
- update powers both inline edits and mass updates.
- let variables keep UI reactive without JavaScript.
- if renders alternate markup based on query params.