#
Lab 4: Core UX Patterns (Modal, Confirm, History, Pagination)
#
Overview
In this lab, you'll implement the core UX patterns that make htmx-powered applications feel polished and professional. These patterns are the building blocks of real-world interactive applications:
- Details View: Load content into a Bootstrap modal without page reload
- Delete with Confirmation: Safe destructive actions with user confirmation
- Filtering and Pagination: URL-driven state that supports browser back/forward
- Smooth Transitions: Visual polish with CSS transitions
By the end of this lab, your Tasks application will feel like a modern single-page application—but with the simplicity of server-rendered HTML.
#
The Key Insight
These patterns share a common theme: the server remains in control. Instead of building client-side state machines, you let htmx handle the transport while the server decides what HTML to return.
#
Lab Outcomes
By the end of Lab 4, you will be able to:
#
Prerequisites
Before starting this lab, ensure you have:
- Completed Lab 3 with all verifications passing
- Working validation (both real-time and full-form)
- Success messages and form reset working via
HX-Trigger - Fragment helpers (
IsHtmx()andFragment()) in place
#
Step 1: Create the Task List View Model (5–7 minutes)
To support filtering and pagination, we need a richer view model than just a list of tasks.
#
1.1 Create the TaskListVm Class
File: Models/TaskListVm.cs (create new file)
namespace RazorPagesHtmxWorkshop.Models;
/// <summary>
/// View model for the task list with filtering and pagination support.
/// </summary>
public class TaskListVm
{
public required IReadOnlyList<TaskItem> Items { get; init; }
public required int Page { get; init; }
public required int PageSize { get; init; }
public required int Total { get; init; }
public string? Query { get; init; }
/// <summary>
/// Total number of pages based on items and page size.
/// </summary>
public int TotalPages => (int)Math.Ceiling(Total / (double)PageSize);
/// <summary>
/// Whether there's a previous page.
/// </summary>
public bool HasPreviousPage => Page > 1;
/// <summary>
/// Whether there's a next page.
/// </summary>
public bool HasNextPage => Page < TotalPages;
}
#
1.2 Understanding the View Model
This view model encapsulates all the information needed to render a paginated, filtered list with navigation controls.
#
Step 2: Add Data Store Methods for Details and Delete (5 minutes)
#
2.1 Add Find and Delete Methods
File: Data/InMemoryTaskStore.cs (add these methods)
/// <summary>
/// Finds a task by ID.
/// </summary>
public static TaskItem? Find(int id) =>
_tasks.FirstOrDefault(t => t.Id == id);
/// <summary>
/// Deletes a task by ID.
/// Returns true if found and deleted, false if not found.
/// </summary>
public static bool Delete(int id)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task is null) return false;
_tasks.Remove(task);
return true;
}
These methods enable the Details and Delete features we'll implement.
#
Step 3: Update the PageModel for Filtering and Pagination (12–15 minutes)
We'll update the PageModel to support URL-based filtering and pagination using model binding.
#
3.1 Add Query Parameter Properties
File: Pages/Tasks/Index.cshtml.cs (add these properties at the top of the class)
[BindProperty(SupportsGet = true)]
public string? Q { get; set; }
[BindProperty(SupportsGet = true)]
public int PageNum { get; set; } = 1;
[BindProperty(SupportsGet = true)]
public int Size { get; set; } = 5;
public string? Query { get; set; }
public int CurrentPage { get; set; } = 1;
public int PageSize { get; set; } = 5;
public int TotalTasks { get; set; }
#
3.2 Understanding BindProperty(SupportsGet = true)
[BindProperty(SupportsGet = true)]
public string? Q { get; set; }
This means:
- URL:
/Tasks?Q=test&PageNum=2&Size=10 - Automatically binds to:
Q = "test",PageNum = 2,Size = 10
#
3.3 Update OnGet Handler
File: Pages/Tasks/Index.cshtml.cs (replace OnGet method)
public IActionResult OnGet()
{
Query = Q;
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
TotalTasks = all.Count;
Tasks = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
if (IsHtmx())
{
var vm = new TaskListVm
{
Items = Tasks,
Page = CurrentPage,
PageSize = PageSize,
Total = TotalTasks,
Query = Query
};
return Fragment("Partials/_TaskList", vm);
}
return Page();
}
Key Changes:
- Filtering: Applies
Qparameter to filter tasks - Pagination: Uses
PageNumandSizeto paginate results - htmx support: Returns fragment for htmx requests, full page otherwise
- Validation: Ensures
PageNumis at least 1 andSizeis between 1-50
#
3.4 Add OnGetList Handler
File: Pages/Tasks/Index.cshtml.cs (add this handler)
/// <summary>
/// Returns the task list fragment with optional filtering and pagination.
/// Supports query parameter (Q) for filtering and PageNum/Size for pagination.
/// </summary>
public IActionResult OnGetList()
{
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var total = all.Count;
var items = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
var vm = new TaskListVm
{
Items = items,
Page = CurrentPage,
PageSize = PageSize,
Total = total,
Query = Q
};
return Fragment("Partials/_TaskList", vm);
}
This handler provides a dedicated endpoint for fetching paginated/filtered lists via htmx.
#
Step 4: Add Details Handler (5 minutes)
#
4.1 Create OnGetDetails Handler
File: Pages/Tasks/Index.cshtml.cs (add this handler)
/// <summary>
/// Returns the details fragment for a specific task.
/// Called via hx-get from the Details button in each row.
/// </summary>
public IActionResult OnGetDetails(int id)
{
var task = InMemoryTaskStore.Find(id);
return Fragment("Partials/_TaskDetails", task);
}
This handler fetches a single task and returns a modal fragment.
#
Step 5: Add Delete Handler (8–10 minutes)
#
5.1 Create OnPostDelete Handler
File: Pages/Tasks/Index.cshtml.cs (add this handler)
/// <summary>
/// Deletes a task and returns the updated list fragment.
/// Uses hx-confirm on the client for confirmation.
///
/// Response behavior:
/// - Success: Returns updated _TaskList + triggers showMessage
/// - Not found: Returns error message to #messages
/// </summary>
public IActionResult OnPostDelete(int id)
{
var removed = InMemoryTaskStore.Delete(id);
Tasks = InMemoryTaskStore.All();
if (IsHtmx())
{
if (!removed)
{
Response.Headers["HX-Retarget"] = "#messages";
Response.Headers["HX-Reswap"] = "outerHTML";
return Fragment("Partials/_Messages", "Task not found (already deleted?).");
}
FlashMessage = "Task deleted.";
Response.Headers["HX-Trigger"] = "showMessage";
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var total = all.Count;
// Adjust page number if it becomes invalid after deletion
var totalPages = (int)Math.Ceiling(total / (double)PageSize);
if (CurrentPage > totalPages && totalPages > 0)
{
CurrentPage = totalPages;
}
var items = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
var vm = new TaskListVm
{
Items = items,
Page = CurrentPage,
PageSize = PageSize,
Total = total,
Query = Q
};
return Fragment("Partials/_TaskList", vm);
}
FlashMessage = removed ? "Task deleted." : "Task not found.";
return RedirectToPage();
}
Key Features:
- State preservation: Applies the same filter/pagination as before delete
- Page adjustment: If deleting the last item on a page, moves back to previous page
- Error handling: Returns error message if task not found
- Success messaging: Triggers
showMessageevent
#
Step 6: Update OnPostCreate to Return View Model (5 minutes)
The Create handler needs to return the same view model structure as the list.
File: Pages/Tasks/Index.cshtml.cs (update the success path in OnPostCreate)
// Success path in OnPostCreate
InMemoryTaskStore.Add(Input.Title);
if (IsHtmx())
{
FlashMessage = "Task added successfully!";
Response.Headers["HX-Trigger"] = "showMessage,clearForm";
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var total = all.Count;
var items = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
var vm = new TaskListVm
{
Items = items,
Page = CurrentPage,
PageSize = PageSize,
Total = total,
Query = Q
};
return Fragment("Partials/_TaskList", vm);
}
This ensures the list updates correctly after adding a task, preserving filter/pagination state.
#
Step 7: Create the Task Details Modal Fragment (8–10 minutes)
#
7.1 Create the Details Partial
File: Pages/Tasks/Partials/_TaskDetails.cshtml (create new file)
@using RazorPagesHtmxWorkshop.Models
@model TaskItem?
@*
Task Details Modal
==================
Target ID: #task-modal-container
Purpose: Display detailed information about a single task in a modal
Model: TaskItem? (null if not found)
*@
<div class="modal fade" id="task-modal" tabindex="-1" aria-labelledby="taskModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content workshop-card">
<div class="modal-header border-bottom border-light">
<h5 class="modal-title" id="taskModalLabel">Task Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@if (Model is null)
{
<div class="text-muted text-center py-4">Task not found.</div>
}
else
{
<div class="vstack gap-4 py-2">
<div>
<label class="form-label text-muted small mb-1">Title</label>
<div class="fw-semibold fs-5">@Model.Title</div>
</div>
<div class="row">
<div class="col-6">
<label class="form-label text-muted small mb-1">Status</label>
<div>
@if (Model.IsDone)
{
<span class="badge text-bg-success">Done</span>
}
else
{
<span class="badge text-bg-secondary">Open</span>
}
</div>
</div>
<div class="col-6">
<label class="form-label text-muted small mb-1">ID</label>
<div class="text-muted">@Model.Id</div>
</div>
</div>
<div>
<label class="form-label text-muted small mb-1">Created</label>
<div>@Model.CreatedUtc.ToLocalTime().ToString("F")</div>
</div>
</div>
}
</div>
<div class="modal-footer border-top border-light">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
#
7.2 Understanding the Modal Pattern
Key Aspects:
- Complete modal structure: The fragment includes the entire Bootstrap modal (not just the body)
- Swapped into container: Gets inserted into
#task-modal-container - JavaScript trigger: A script shows the modal after it's loaded
- Null handling: Gracefully displays a message if task not found
#
Step 8: Update the Task List Fragment (15–18 minutes)
Now we'll update the task list to use the view model and include pagination controls.
#
8.1 Rewrite _TaskList.cshtml
File: Pages/Tasks/Partials/_TaskList.cshtml (complete rewrite)
@using RazorPagesHtmxWorkshop.Models
@model TaskListVm
@*
Task List Fragment with Pagination
===================================
Target ID: #task-list
Swap: outerHTML
Returned by: OnGet (htmx), OnGetList, OnPostCreate (on success), OnPostDelete (on success)
Features:
- Displays filtered/paginated task list
- Pagination controls with hx-push-url
- Delete and Details buttons per row
Model: TaskListVm (includes items, page info, filter query)
*@
<div id="task-list">
@if (Model.Items.Count == 0)
{
<div class="text-muted py-4 text-center">
@if (!string.IsNullOrWhiteSpace(Model.Query))
{
<p class="mb-2">No tasks match "<strong>@Model.Query</strong>"</p>
<a href="?"
hx-get="?"
hx-target="#task-list"
hx-swap="outerHTML"
hx-push-url="true"
class="btn btn-sm btn-outline-secondary">
Clear filter
</a>
}
else
{
<p class="mb-0">No tasks yet. Add one above!</p>
}
</div>
}
else
{
<ul class="list-group list-group-flush">
@foreach (var task in Model.Items)
{
<li class="list-group-item d-flex justify-content-between align-items-center py-3">
<div class="d-flex flex-column">
<strong>@task.Title</strong>
<span class="small text-muted">
Created @task.CreatedUtc.ToLocalTime().ToString("g")
</span>
</div>
<div class="d-flex align-items-center gap-2">
@if (task.IsDone)
{
<span class="badge text-bg-success">Done</span>
}
else
{
<span class="badge text-bg-secondary">Open</span>
}
<div class="btn-group btn-group-sm">
@* Details button - loads into modal *@
<button type="button"
class="btn btn-outline-secondary"
hx-get="?handler=Details&id=@task.Id"
hx-target="#task-modal-container"
hx-indicator="#task-loading">
Details
</button>
@* Delete button with confirmation *@
<button type="button"
class="btn btn-outline-danger"
hx-post="?handler=Delete"
hx-vals='{"id": @task.Id, "Q": "@(Model.Query ?? "")", "PageNum": @Model.Page, "Size": @Model.PageSize}'
hx-confirm="Delete this task? This cannot be undone."
hx-target="#task-list"
hx-swap="outerHTML"
hx-indicator="#task-loading">
Delete
</button>
</div>
</div>
</li>
}
</ul>
@* Pagination Controls *@
@if (Model.TotalPages > 1)
{
<div class="card-footer d-flex justify-content-between align-items-center bg-transparent border-top-0 pt-4">
<div class="small text-muted">
Showing <strong>@((Model.Page - 1) * Model.PageSize + 1)</strong> to
<strong>@(Math.Min(Model.Page * Model.PageSize, Model.Total))</strong> of
<strong>@Model.Total</strong> tasks
</div>
<nav aria-label="Task list pagination">
<ul class="pagination pagination-sm mb-0">
@* Previous Page *@
<li class="page-item @(!Model.HasPreviousPage ? "disabled" : "")">
<a hx-get="?Q=@(Model.Query ?? "")&PageNum=@(Model.Page - 1)&Size=@Model.PageSize"
hx-target="#task-list"
hx-swap="outerHTML"
hx-push-url="true"
class="page-link"
style="cursor: pointer;"
aria-label="Previous">
<span aria-hidden="true">«</span>
</a>
</li>
@* Page Numbers *@
@for (int i = 1; i <= Model.TotalPages; i++)
{
<li class="page-item @(i == Model.Page ? "active" : "")" @(i == Model.Page ? "aria-current='page'" : "")>
<a class="page-link"
style="cursor: pointer;"
hx-get="?Q=@(Model.Query ?? "")&PageNum=@i&Size=@Model.PageSize"
hx-target="#task-list"
hx-swap="outerHTML"
hx-push-url="true">
@i
</a>
</li>
}
@* Next Page *@
<li class="page-item @(!Model.HasNextPage ? "disabled" : "")">
<a class="page-link"
style="cursor: pointer;"
hx-get="?Q=@(Model.Query ?? "")&PageNum=@(Model.Page + 1)&Size=@Model.PageSize"
hx-target="#task-list"
hx-swap="outerHTML"
hx-push-url="true"
aria-label="Next">
<span aria-hidden="true">»</span>
</a>
</li>
</ul>
</nav>
</div>
}
}
</div>
#
8.2 Understanding Key Patterns
#
Details Button
<button hx-get="?handler=Details&id=@task.Id"
hx-target="#task-modal-container"
hx-indicator="#task-loading">
#
Delete Button
<button type="button"
class="btn btn-outline-danger"
hx-post="?handler=Delete"
hx-vals='{"id": @task.Id, "Q": "@(Model.Query ?? "")", "PageNum": @Model.Page, "Size": @Model.PageSize}'
hx-confirm="Delete this task? This cannot be undone."
hx-target="#task-list"
hx-swap="outerHTML"
hx-indicator="#task-loading">
Delete
</button>
Why hx-vals includes Q, PageNum, Size:
These ensure the delete handler can return a list with the same filter/pagination state. Without them, the list would reset to page 1 with no filter.
#
Pagination Links
<a hx-get="?Q=@(Model.Query ?? "")&PageNum=@i&Size=@Model.PageSize"
hx-target="#task-list"
hx-swap="outerHTML"
hx-push-url="true">
URL State Preservation:
Q=@(Model.Query ?? "")preserves the current filterPageNum=@isets the new pageSize=@Model.PageSizemaintains page size
#
Step 9: Update the Main Page for Filtering and Modal (12–15 minutes)
#
9.1 Update Index.cshtml
File: Pages/Tasks/Index.cshtml (update the list section)
Find the section where the task list is rendered and update it to:
@page
@model RazorPagesHtmxWorkshop.Pages.Tasks.IndexModel
@using RazorPagesHtmxWorkshop.Models
@{
ViewData["Title"] = "Tasks • htmx Razor Pages Workshop";
// Build the initial view model for the task list
var initialVm = new TaskListVm
{
Items = Model.Tasks,
Page = Model.CurrentPage,
PageSize = Model.PageSize,
Total = Model.TotalTasks,
Query = Model.Query
};
}
<div class="d-flex flex-column flex-md-row align-items-md-end justify-content-between gap-2 mb-3">
<div>
<h1 class="mb-1">Tasks</h1>
<p class="text-muted mb-0">Lab 4: Modals, confirm, history, and pagination.</p>
</div>
<div class="d-flex align-items-center gap-2">
@* Loading indicator - shown during htmx requests *@
<span id="task-loading" class="htmx-indicator">
<span class="spinner-border spinner-border-sm text-secondary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</span>
<form method="post" asp-page-handler="Reset" class="m-0">
<button class="btn btn-sm btn-outline-secondary" type="submit">Reset</button>
</form>
</div>
</div>
<partial name="Partials/_Messages" model="Model.FlashMessage" />
<div class="row g-3 g-md-4">
<div class="col-lg-5">
<div class="card workshop-card h-100">
<div class="card-body">
<div class="workshop-kicker">Fragment boundary</div>
<h2 class="h5">Create</h2>
<partial name="Partials/_TaskForm" model="Model" />
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card workshop-card h-100">
<div class="card-body">
<div class="workshop-kicker">Fragment boundary</div>
<h2 class="h5 mb-3">List</h2>
@*
Filter Input
============
Filters the task list as user types (debounced).
Updates URL via hx-push-url for bookmarkability.
*@
<div class="mb-3">
<div class="input-group">
<span class="input-group-text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
</span>
<input type="text"
class="form-control"
name="Q"
placeholder="Filter tasks..."
value="@Model.Query"
hx-get="?"
hx-trigger="keyup changed delay:400ms"
hx-target="#task-list"
hx-swap="outerHTML"
hx-push-url="true"
hx-indicator="#task-loading" />
</div>
</div>
<partial name="Partials/_TaskList" model="initialVm" />
</div>
</div>
</div>
</div>
@*
Details Modal Placeholder
=========================
Target ID: #task-modal-container
Purpose: Hosts the dynamic modal content
*@
<div id="task-modal-container"></div>
@* Listener: Refresh messages when showMessage event fires *@
<div hx-get="?handler=Messages"
hx-trigger="showMessage from:body"
hx-target="#messages"
hx-swap="outerHTML">
</div>
@* Listener: Reset form when clearForm event fires *@
<div hx-get="?handler=EmptyForm"
hx-trigger="clearForm from:body"
hx-target="#task-form"
hx-swap="outerHTML">
</div>
<script>
document.addEventListener('htmx:afterOnLoad', function (evt) {
if (evt.detail.target.id === 'task-modal-container') {
const modalEl = document.getElementById('task-modal');
if (modalEl) {
const modal = new bootstrap.Modal(modalEl);
modal.show();
}
}
});
</script>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
#
9.2 Understanding Key Elements
#
Filter Input
<input name="Q"
hx-get="?"
hx-trigger="keyup changed delay:400ms"
hx-push-url="true" />
Why hx-get="?" instead of ?handler=List:
Using ? calls the default OnGet handler, which checks IsHtmx() and returns a fragment. This is simpler and more consistent.
#
Modal Container
<div id="task-modal-container"></div>
This empty div serves as the injection point for the modal. The modal is dynamically loaded and removed.
#
Modal Show Script
document.addEventListener('htmx:afterOnLoad', function (evt) {
if (evt.detail.target.id === 'task-modal-container') {
const modalEl = document.getElementById('task-modal');
if (modalEl) {
const modal = new bootstrap.Modal(modalEl);
modal.show();
}
}
});
How it works:
- htmx fires
htmx:afterOnLoadafter swapping content - Script checks if the target was
#task-modal-container - If yes, finds the
#task-modalelement (now in the DOM) - Creates a Bootstrap Modal instance and shows it
#
Step 10: Add CSS Transitions (Optional, 5–7 minutes)
Add visual polish with CSS transitions.
#
10.1 Add Transition Styles
File: wwwroot/css/site.css (add these styles)
/* ==========================================================================
htmx Transitions
========================================================================== */
/*
* Loading indicator visibility
* Show/hide elements with .htmx-indicator class during requests
*/
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-block;
}
/*
* Fade transition for task list
* Smoothly fade out old content and fade in new content
*/
#task-list {
transition: opacity 150ms ease-in-out;
}
#task-list.htmx-swapping {
opacity: 0;
}
#task-list.htmx-settling {
opacity: 1;
}
/*
* Subtle highlight for newly added items
*/
.list-group-item.htmx-added {
animation: highlight-fade 1s ease-out;
}
@keyframes highlight-fade {
from {
background-color: rgba(var(--bs-success-rgb), 0.2);
}
to {
background-color: transparent;
}
}
/*
* Disabled state during requests
* Prevent double-clicks by visually disabling buttons
*/
button.htmx-request {
opacity: 0.6;
pointer-events: none;
}
#
10.2 Understanding htmx CSS Classes
htmx adds these classes during the swap lifecycle:
#
Complete Code Reference
#
Index.cshtml.cs (Complete PageModel)
File: Pages/Tasks/Index.cshtml.cs
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using RazorPagesHtmxWorkshop.Data;
using RazorPagesHtmxWorkshop.Models;
namespace RazorPagesHtmxWorkshop.Pages.Tasks;
public class IndexModel : PageModel
{
public IReadOnlyList<TaskItem> Tasks { get; private set; } = Array.Empty<TaskItem>();
[BindProperty]
public NewTaskInput Input { get; set; } = new();
[TempData]
public string? FlashMessage { get; set; }
[BindProperty(SupportsGet = true)]
public string? Q { get; set; }
[BindProperty(SupportsGet = true)]
public int PageNum { get; set; } = 1;
[BindProperty(SupportsGet = true)]
public int Size { get; set; } = 5;
public string? Query { get; set; }
public int CurrentPage { get; set; } = 1;
public int PageSize { get; set; } = 5;
public int TotalTasks { get; set; }
#region Helper Methods
private bool IsHtmx() =>
Request.Headers.TryGetValue("HX-Request", out var value) && value == "true";
private PartialViewResult Fragment(string partialName, object model) =>
new()
{
ViewName = partialName,
ViewData = new ViewDataDictionary(MetadataProvider, ModelState) { Model = model }
};
#endregion
#region Page Handlers
public IActionResult OnGet()
{
Query = Q;
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
TotalTasks = all.Count;
Tasks = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
if (IsHtmx())
{
var vm = new TaskListVm
{
Items = Tasks,
Page = CurrentPage,
PageSize = PageSize,
Total = TotalTasks,
Query = Query
};
return Fragment("Partials/_TaskList", vm);
}
return Page();
}
public IActionResult OnGetList()
{
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var total = all.Count;
var items = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
var vm = new TaskListVm
{
Items = items,
Page = CurrentPage,
PageSize = PageSize,
Total = total,
Query = Q
};
return Fragment("Partials/_TaskList", vm);
}
public IActionResult OnGetDetails(int id)
{
var task = InMemoryTaskStore.Find(id);
return Fragment("Partials/_TaskDetails", task);
}
public IActionResult OnGetMessages()
{
return Fragment("Partials/_Messages", FlashMessage);
}
public IActionResult OnGetEmptyForm()
{
Input = new NewTaskInput();
ModelState.Clear();
return Fragment("Partials/_TaskForm", this);
}
#endregion
#region Validation Handlers
public IActionResult OnPostValidateTitle()
{
var title = Input.Title?.Trim() ?? "";
string? error = null;
if (string.IsNullOrWhiteSpace(title))
error = "Title is required.";
else if (title.Length < 3)
error = "Title must be at least 3 characters.";
else if (title.Length > 60)
error = "Title must be 60 characters or fewer.";
return Fragment("Partials/_TitleValidation", error);
}
#endregion
#region Action Handlers
public IActionResult OnPostCreate()
{
if (!TryValidateModel(Input, nameof(Input)))
{
Tasks = InMemoryTaskStore.All();
if (IsHtmx())
{
Response.Headers["HX-Retarget"] = "#task-form";
Response.Headers["HX-Reswap"] = "outerHTML";
return Fragment("Partials/_TaskForm", this);
}
return Page();
}
if (Input.Title.Trim().Equals("boom", StringComparison.OrdinalIgnoreCase))
{
if (IsHtmx())
{
Response.Headers["HX-Retarget"] = "#messages";
Response.Headers["HX-Reswap"] = "innerHTML";
return Fragment("Partials/_Error",
"Simulated server error. Try a different title.");
}
throw new InvalidOperationException("Simulated server error.");
}
InMemoryTaskStore.Add(Input.Title);
if (IsHtmx())
{
FlashMessage = "Task added successfully!";
Response.Headers["HX-Trigger"] = "showMessage,clearForm";
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var total = all.Count;
var items = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
var vm = new TaskListVm
{
Items = items,
Page = CurrentPage,
PageSize = PageSize,
Total = total,
Query = Q
};
return Fragment("Partials/_TaskList", vm);
}
FlashMessage = "Task added.";
return RedirectToPage();
}
public IActionResult OnPostDelete(int id)
{
var removed = InMemoryTaskStore.Delete(id);
Tasks = InMemoryTaskStore.All();
if (IsHtmx())
{
if (!removed)
{
Response.Headers["HX-Retarget"] = "#messages";
Response.Headers["HX-Reswap"] = "outerHTML";
return Fragment("Partials/_Messages", "Task not found (already deleted?).");
}
FlashMessage = "Task deleted.";
Response.Headers["HX-Trigger"] = "showMessage";
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var total = all.Count;
// Adjust page number if it becomes invalid after deletion
var totalPages = (int)Math.Ceiling(total / (double)PageSize);
if (CurrentPage > totalPages && totalPages > 0)
{
CurrentPage = totalPages;
}
var items = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
var vm = new TaskListVm
{
Items = items,
Page = CurrentPage,
PageSize = PageSize,
Total = total,
Query = Q
};
return Fragment("Partials/_TaskList", vm);
}
FlashMessage = removed ? "Task deleted." : "Task not found.";
return RedirectToPage();
}
public IActionResult OnPostReset()
{
InMemoryTaskStore.Reset();
FlashMessage = "Tasks reset.";
return RedirectToPage();
}
#endregion
#region Input Models
public class NewTaskInput
{
[Required(ErrorMessage = "Title is required.")]
[StringLength(60, MinimumLength = 3, ErrorMessage = "Title must be 3–60 characters.")]
public string Title { get; set; } = "";
}
#endregion
}
#
InMemoryTaskStore.cs (Updated)
File: Data/InMemoryTaskStore.cs
using RazorPagesHtmxWorkshop.Models;
namespace RazorPagesHtmxWorkshop.Data;
public static class InMemoryTaskStore
{
private static int _nextId = 1;
private static readonly List<TaskItem> _tasks = new();
public static IReadOnlyList<TaskItem> All() =>
_tasks.OrderByDescending(t => t.CreatedUtc).ToList();
/// <summary>
/// Finds a task by ID.
/// </summary>
public static TaskItem? Find(int id) =>
_tasks.FirstOrDefault(t => t.Id == id);
public static TaskItem Add(string title)
{
var item = new TaskItem
{
Id = _nextId++,
Title = title.Trim(),
IsDone = false,
CreatedUtc = DateTime.UtcNow
};
_tasks.Add(item);
return item;
}
/// <summary>
/// Deletes a task by ID.
/// Returns true if found and deleted, false if not found.
/// </summary>
public static bool Delete(int id)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task is null) return false;
_tasks.Remove(task);
return true;
}
/// <summary>
/// Useful for workshops / resetting between labs
/// </summary>
public static void Reset()
{
_tasks.Clear();
_nextId = 1;
}
}
#
Verification Checklist
Before moving to Lab 5, verify these behaviors:
#
Details Pattern
- Clicking "Details" opens a Bootstrap modal
- Modal displays task information correctly
- Closing modal works (X button or backdrop click)
- Modal shows "Task not found" for invalid IDs
#
Delete Pattern
- Clicking "Delete" shows browser confirmation dialog
- Confirming deletes task and updates list
- Canceling keeps task in list
- Success message appears after delete
- List refreshes with correct pagination after delete
#
Filtering
- Typing in filter updates list after 400ms
- Filter is applied (shows matching tasks only)
- URL updates with Q parameter (
?Q=test) - "Clear filter" link removes filter and resets URL
- Filter persists when navigating pages
#
Pagination
- Pagination controls appear when tasks > page size (5)
- Clicking page number updates list
- URL updates with PageNum parameter
- Browser back/forward navigates through history
- Filter is preserved when changing pages
#
State Preservation
- Deleting a task preserves filter and pagination
- Adding a task preserves filter and pagination
- Refreshing the page loads with correct filter/pagination
#
Transitions
- List fades during swap (if CSS added)
- Loading indicator appears during requests
- Buttons are disabled during their request
#
Key Takeaways
#
Patterns Summary
#
URL State Philosophy
The key insight of this lab is that URLs should reflect application state:
- Filter query →
?Q=test - Current page →
?PageNum=2 - Page size →
?Size=10
This enables:
- Bookmarking: Users save filtered/paginated views
- Sharing: Send a link to a specific view
- History: Back/forward buttons work correctly
- Progressive enhancement: Non-JS users get the same functionality
#
Model Binding with BindProperty
[BindProperty(SupportsGet = true)]
public string? Q { get; set; }
This is a cleaner alternative to manually reading query parameters:
#
State Preservation Pattern
When an action might change the list (create, delete), preserve the current state:
hx-vals='{"id": @task.Id, "Q": "@(Model.Query ?? "")", "PageNum": @Model.Page, "Size": @Model.PageSize}'
This ensures users don't lose their place when performing actions.
#
Troubleshooting
#
Common Issues
#
Debug Tips
- Check Network tab: Verify request URL includes all parameters
- Check Response: Confirm fragment structure is correct
- Check URL bar: Verify
hx-push-urlis updating the URL - Check Console: Look for htmx or Bootstrap errors
- Test back/forward: Ensure page loads with correct state
- Inspect htmx:afterOnLoad: Verify modal script is firing
#
What Comes Next
In Lab 5, you'll implement:
- Dynamic form rows (Add/Remove tag inputs)
- Dependent dropdowns (Category → Subcategory)
- Long-running operations with polling
- Out-of-band swaps for global updates
Proceed to Lab 5: Dynamic Forms + Long-Running UX (Polling) →