#
Lab 2: Partial Updates with hx-get + hx-post
#
Overview
Welcome to Lab 2! In this lab, you will transform your traditional server-rendered application into an interactive experience using htmx. You'll learn the primary htmx workflow: request a server-rendered fragment and swap it into a target.
By the end of this lab, your Tasks page will:
- Submit forms without full page reloads
- Update only the parts of the page that changed
- Show loading indicators during requests
- Handle validation errors gracefully
#
The Core htmx Philosophy
htmx follows a simple but powerful pattern:
- Trigger: User action (click, submit, keyup, etc.)
- Request: htmx sends an AJAX request to the server
- Response: Server returns an HTML fragment (not JSON, not a full page)
- Swap: htmx replaces a target element with the returned HTML
This is "HTML over the wire"—the server remains the source of truth for your UI, and htmx handles the transport and DOM manipulation.
#
Why This Approach?
#
Lab Outcomes
By the end of Lab 2, you will be able to:
#
Prerequisites
Before starting this lab, ensure you have:
- Completed Lab 1 with all verifications passing
- htmx loaded in your
_Layout.cshtml(we'll verify this in Step 0) - Three partials with stable IDs:
#messages,#task-form,#task-list - Working OnPostCreate handler that creates tasks
#
Step 0: Verify Prerequisites (1–2 minutes)
Before adding htmx attributes, let's confirm everything is in place.
#
0.1 Verify htmx is Loaded
Open Pages/Shared/_Layout.cshtml and ensure htmx is included. You should see this near the bottom of the file (before the closing </body> tag):
File: Pages/Shared/_Layout.cshtml
<!-- htmx (Local) -->
<script src="~/lib/htmx/htmx.min.js"></script>
Note: This workshop uses htmx from local files (located in
wwwroot/lib/htmx/) for offline workshop environments. In production, you can use a CDN likehttps://unpkg.com/htmx.org@2.0.0or install via npm.
#
0.2 Verify Fragment Structure
Open your browser's Developer Tools and confirm these elements exist in the DOM:
<div id="task-list">...</div>
<div id="task-form">...</div>
<div id="messages">...</div>
The id attributes are essential—htmx will target these elements.
#
0.3 Verify htmx is Working
Open the browser console and type:
htmx
You should see the htmx object. If you see undefined, htmx is not loaded correctly.
#
Step 1: Add hx-post to the Existing Form (5–7 minutes)
Our first htmx enhancement is the simplest possible change: make the create form submit via AJAX instead of a full page reload.
#
1.1 Understanding the Change
Currently, when you submit the form:
- Browser sends a POST request
- Server processes and redirects
- Browser loads the entire new page
With htmx:
- htmx intercepts the submit
- htmx sends an AJAX POST request
- Server returns just the updated fragment
- htmx swaps that fragment into the target
#
1.2 Update the Form Partial
Edit Pages/Tasks/Partials/_TaskForm.cshtml to add htmx attributes:
File: Pages/Tasks/Partials/_TaskForm.cshtml
@model RazorPagesHtmxWorkshop.Pages.Tasks.IndexModel
@*
Task Form Fragment (htmx-enhanced)
===================================
Target ID: #task-form
Swap: outerHTML
Returned by: OnGetEmptyForm, OnPostCreate (on validation error)
htmx attributes:
- hx-post: Send POST request to this URL
- hx-target: Where to swap the response (#task-list on success)
- hx-swap: How to swap (outerHTML replaces the entire target element)
- hx-indicator: Show loading spinner during request
On successful submit:
- Server returns _TaskList partial
- htmx swaps it into #task-list
- Server sends HX-Trigger: clearForm
- Listener fetches empty form and swaps into #task-form
On validation error:
- Server returns _TaskForm partial with errors (this file)
- Server sends HX-Retarget: #task-form
- htmx swaps form with validation errors displayed
*@
<div id="task-form">
<form method="post" asp-page-handler="Create"
hx-post="?handler=Create"
hx-target="#task-list"
hx-swap="outerHTML"
hx-indicator="#task-loading"
class="vstack gap-3">
<div>
<label class="form-label" for="title">Task title</label>
<input id="title"
class="form-control form-control-lg"
asp-for="Input.Title"
placeholder="e.g., Add htmx to Razor Pages" />
<div class="form-text">Keep it short; we're optimizing for fast feedback loops.</div>
<span class="text-danger" asp-validation-for="Input.Title"></span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-lg" type="submit">Add task</button>
<a class="btn btn-outline-secondary btn-lg" asp-page="/Labs">Back to labs</a>
</div>
</form>
</div>
#
1.3 Understanding the htmx Attributes
Why keep method="post" and asp-page-handler?
These provide progressive enhancement. If JavaScript is disabled or htmx fails to load, the form still works as a traditional form. The htmx attributes layer behavior on top without breaking the fallback.
#
1.4 Why hx-swap="outerHTML"?
There are several swap strategies:
We use outerHTML because our partials return the complete wrapper element:
<!-- Server returns this: -->
<div id="task-list">
<ul class="list-group">...</ul>
</div>
<!-- htmx replaces the entire #task-list element -->
If we used innerHTML, htmx would try to put <div id="task-list"> inside the existing <div id="task-list">, creating nested duplicates.
#
Step 2: Convert OnPostCreate to Return Fragment for htmx (10–12 minutes)
Now we need to update the server to return just the list fragment (instead of redirecting) when htmx makes the request.
#
2.1 The Strategy: Detect htmx Requests
htmx sends a header with every request:
HX-Request: true
We'll check for this header to decide how to respond:
- htmx request: Return the partial fragment
- Normal request: Redirect (traditional PRG pattern)
#
2.2 Add Helper Methods to the PageModel
Update Pages/Tasks/Index.cshtml.cs with these additions:
File: Pages/Tasks/Index.cshtml.cs
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; }
// ═══════════════════════════════════════════════════════════
// Helper Methods
// ═══════════════════════════════════════════════════════════
/// <summary>
/// Checks if the current request was made by htmx.
/// htmx sends "HX-Request: true" header with every request.
/// </summary>
private bool IsHtmx() =>
Request.Headers.TryGetValue("HX-Request", out var value) && value == "true";
/// <summary>
/// Returns a partial view result for fragment responses.
/// This helper creates a PartialViewResult with the correct ViewData context.
/// </summary>
/// <param name="partialName">Path to the partial view</param>
/// <param name="model">Model to pass to the partial</param>
private PartialViewResult Fragment(string partialName, object model) =>
new()
{
ViewName = partialName,
ViewData = new ViewDataDictionary(MetadataProvider, ModelState) { Model = model }
};
// ═══════════════════════════════════════════════════════════
// Page Lifecycle
// ═══════════════════════════════════════════════════════════
public void OnGet()
{
Tasks = InMemoryTaskStore.All();
}
// ═══════════════════════════════════════════════════════════
// List Fragment Handlers
// ═══════════════════════════════════════════════════════════
/// <summary>
/// Handles GET requests to /Tasks?handler=List.
/// Returns just the task list fragment for htmx to swap.
///
/// Optional parameter 'take' limits the number of tasks returned.
/// </summary>
/// <param name="take">Optional: limit results to this many tasks</param>
public IActionResult OnGetList(int? take)
{
var tasks = InMemoryTaskStore.All();
if (take is > 0)
{
tasks = tasks.Take(take.Value).ToList();
}
return Fragment("Partials/_TaskList", tasks);
}
// ═══════════════════════════════════════════════════════════
// Form Fragment Handlers
// ═══════════════════════════════════════════════════════════
/// <summary>
/// Returns an empty form fragment.
/// Called via htmx trigger after successful task creation.
/// </summary>
public IActionResult OnGetEmptyForm()
{
Input = new NewTaskInput();
ModelState.Clear();
return Fragment("Partials/_TaskForm", this);
}
// ═══════════════════════════════════════════════════════════
// CRUD Handlers
// ═══════════════════════════════════════════════════════════
public IActionResult OnPostCreate()
{
// Validation
if (string.IsNullOrWhiteSpace(Input.Title))
{
ModelState.AddModelError(nameof(Input.Title), "Title is required.");
}
if (!ModelState.IsValid)
{
Tasks = InMemoryTaskStore.All();
if (IsHtmx())
{
// For htmx: return the form fragment with validation errors
// Use response headers to retarget the swap to the form
Response.Headers["HX-Retarget"] = "#task-form";
Response.Headers["HX-Reswap"] = "outerHTML";
return Fragment("Partials/_TaskForm", this);
}
FlashMessage = "Please correct the errors and try again.";
return Page();
}
// Simulated error condition for demonstration
// Type "boom" as the title to trigger this error
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 (anything except 'boom').");
}
throw new InvalidOperationException("Simulated server error.");
}
// Create the task
InMemoryTaskStore.Add(Input.Title);
Tasks = InMemoryTaskStore.All();
if (IsHtmx())
{
// Trigger form clear after successful creation
Response.Headers["HX-Trigger"] = "clearForm";
return Fragment("Partials/_TaskList", Tasks);
}
// For traditional requests: redirect (PRG pattern)
FlashMessage = "Task added.";
return RedirectToPage();
}
public IActionResult OnPostReset()
{
InMemoryTaskStore.Reset();
FlashMessage = "Tasks reset.";
return RedirectToPage();
}
public class NewTaskInput
{
public string Title { get; set; } = "";
}
}
#
2.3 Understanding the Changes
#
The IsHtmx() Helper
private bool IsHtmx() =>
Request.Headers.TryGetValue("HX-Request", out var value) && value == "true";
This checks for the HX-Request header that htmx sends with every request. This is the standard way to detect htmx requests.
#
The Fragment() Helper
private PartialViewResult Fragment(string partialName, object model) =>
new()
{
ViewName = partialName,
ViewData = new ViewDataDictionary(MetadataProvider, ModelState) { Model = model }
};
This creates a PartialViewResult that renders just the partial view, not the full page. The ViewDataDictionary constructor uses MetadataProvider and ModelState to preserve validation context needed for showing error messages.
#
The Success Path
if (IsHtmx())
{
Response.Headers["HX-Trigger"] = "clearForm";
return Fragment("Partials/_TaskList", Tasks);
}
When htmx submits successfully, we return just the _TaskList partial. htmx swaps this into #task-list as specified by hx-target. We also send the HX-Trigger header to fire a custom event that will clear the form.
#
The Validation Error Path
if (IsHtmx())
{
Response.Headers["HX-Retarget"] = "#task-form";
Response.Headers["HX-Reswap"] = "outerHTML";
return Fragment("Partials/_TaskForm", this);
}
When validation fails:
- HX-Retarget: Overrides the original
hx-target; swap into#task-forminstead - HX-Reswap: Specifies the swap strategy for this response
- Return form: The form fragment includes validation error messages
#
2.4 Why Retargeting?
The form's hx-target is #task-list (for successful creates). But on validation failure, we want to update the form instead (to show errors). Response headers let us override the target per-response:
#
2.5 Test the Implementation
- Build and run the application
- Navigate to
/Tasks - Open Network tab in Developer Tools
- Submit a task with a valid title
- Observe:
- Request includes
HX-Request: trueheader - Response is just the list HTML (not a full page)
- Only
#task-listupdates; no page flash
- Request includes
- Submit empty form
- Observe:
- Form updates with validation error
- List remains unchanged
#
Step 3: Add a "Refresh List" Button Using hx-get (6–8 minutes)
Now let's add buttons that fetch fresh data without submitting a form. This demonstrates hx-get for read operations.
#
3.1 Update the Page with Refresh Buttons
Update Pages/Tasks/Index.cshtml to include the loading indicator and refresh buttons:
File: Pages/Tasks/Index.cshtml (relevant section)
<div class="d-flex justify-content-between align-items-center mb-2">
<h2 class="h5 mb-0">List</h2>
<div class="d-flex align-items-center gap-2">
@* Loading indicator - hidden by default, shown during htmx requests *@
<div id="task-loading"
class="htmx-indicator spinner-border spinner-border-sm text-secondary"
role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="btn-group btn-group-sm">
@* Refresh all tasks *@
<button type="button"
class="btn btn-outline-secondary"
hx-get="?handler=List"
hx-target="#task-list"
hx-swap="outerHTML"
hx-indicator="#task-loading">
Refresh All
</button>
@* Refresh with limit using hx-vals *@
<button type="button"
class="btn btn-outline-secondary"
hx-get="?handler=List"
hx-vals='{"take": 5}'
hx-target="#task-list"
hx-swap="outerHTML"
hx-indicator="#task-loading">
Top 5
</button>
</div>
</div>
</div>
#
3.2 Understanding hx-get
This is the canonical "fetch and swap" pattern:
- User clicks the button
- htmx sends
GET /Tasks?handler=List - Server returns
_TaskListpartial - htmx replaces
#task-listwith the response
#
3.3 Understanding hx-vals
hx-vals='{"take": 5}'
The OnGetList(int? take) handler already accepts this parameter, so the server will limit results to 5 tasks.
#
3.4 Test the Refresh Buttons
- Add a few tasks using the form
- Click "Refresh All"
- Observe in Network tab:
- GET request to
?handler=List - Response is just the list HTML
- List updates without page reload
- GET request to
- Click "Top 5"
- Check Network tab: Request URL should include
?handler=List&take=5
#
Step 4: Add Loading Indicator Styling (5–7 minutes)
The loading indicator element is already in place, but we need CSS to show/hide it properly.
#
4.1 Add CSS for the Indicator
The htmx indicator styles should already be in wwwroot/css/site.css:
File: wwwroot/css/site.css (relevant section)
/* ═══════════════════════════════════════════════════════════════
htmx Loading Indicator Styles
═══════════════════════════════════════════════════════════════ */
/* Hide indicator by default */
.htmx-indicator {
display: none;
}
/* Show indicator when htmx request is in progress */
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-block;
}
/* Optional: Add a subtle opacity transition to the target during loading */
.htmx-request #task-list {
opacity: 0.5;
transition: opacity 200ms ease-in-out;
}
/* Disable form elements during submission */
.htmx-request input,
.htmx-request button,
.htmx-request select,
.htmx-request textarea {
pointer-events: none;
opacity: 0.7;
}
#
4.2 Understanding hx-indicator
htmx's built-in behavior:
- Request starts → adds
htmx-requestclass to the element withhx-*attributes - CSS rule
.htmx-request .htmx-indicatorshows the indicator - Request ends → removes
htmx-requestclass - Indicator hides again
#
4.3 Test Everything
Test the loading indicator:
- Open Network tab, enable throttling (Slow 3G)
- Click refresh or submit a task
- Observe the spinner appears during the request
- Notice the task list becomes slightly transparent
Test validation errors:
- Submit an empty form
- Observe form updates with error message
- List remains unchanged
Test server error:
- Type "boom" as the task title
- Submit the form
- Observe error message appears in the messages area
#
Step 5: Clear the Form After Success (6–8 minutes)
Currently, after successfully adding a task, the form retains the entered value. Let's add a mechanism to clear it using htmx events.
#
5.1 The Strategy: Use HX-Trigger
htmx can fire custom events that other elements listen to. We'll:
- Server sends
HX-Trigger: clearFormheader on success - An invisible listener element catches this and refreshes the form
#
5.2 The Handler is Already There
The OnGetEmptyForm() handler is already implemented in the PageModel:
public IActionResult OnGetEmptyForm()
{
Input = new NewTaskInput();
ModelState.Clear();
return Fragment("Partials/_TaskForm", this);
}
#
5.3 Add a Listener Element to the Page
The listener should already be in Pages/Tasks/Index.cshtml (near the bottom, before @section Scripts):
File: Pages/Tasks/Index.cshtml (listener section)
@*
Invisible listeners for htmx events
These elements respond to HX-Trigger headers from the server
*@
<div hx-get="?handler=EmptyForm"
hx-trigger="clearForm from:body"
hx-target="#task-form"
hx-swap="outerHTML">
</div>
#
5.4 The Event is Already Triggered
The success path in OnPostCreate already includes the trigger:
if (IsHtmx())
{
Response.Headers["HX-Trigger"] = "clearForm";
return Fragment("Partials/_TaskList", Tasks);
}
#
5.5 Understanding HX-Trigger
This pattern keeps your markup clean—the form itself doesn't need to know about clearing. The server controls the behavior through headers.
#
Complete File Reference
#
Index.cshtml (Complete)
File: Pages/Tasks/Index.cshtml
@page
@model RazorPagesHtmxWorkshop.Pages.Tasks.IndexModel
@{
ViewData["Title"] = "Tasks • htmx Razor Pages Workshop";
}
<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-friendly page with clear fragment boundaries.</p>
</div>
<form method="post" asp-page-handler="Reset" class="m-0">
<button class="btn btn-sm btn-outline-secondary" type="submit">Reset</button>
</form>
</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>
<div class="d-flex justify-content-between align-items-center mb-2">
<h2 class="h5 mb-0">List</h2>
<div class="d-flex align-items-center gap-2">
@* Loading indicator - hidden by default, shown during htmx requests *@
<div id="task-loading"
class="htmx-indicator spinner-border spinner-border-sm text-secondary"
role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="btn-group btn-group-sm">
@* Refresh all tasks *@
<button type="button"
class="btn btn-outline-secondary"
hx-get="?handler=List"
hx-target="#task-list"
hx-swap="outerHTML"
hx-indicator="#task-loading">
Refresh All
</button>
@* Refresh with limit using hx-vals *@
<button type="button"
class="btn btn-outline-secondary"
hx-get="?handler=List"
hx-vals='{"take": 5}'
hx-target="#task-list"
hx-swap="outerHTML"
hx-indicator="#task-loading">
Top 5
</button>
</div>
</div>
</div>
<partial name="Partials/_TaskList" model="Model.Tasks" />
</div>
</div>
</div>
</div>
@*
Invisible listeners for htmx events
These elements respond to HX-Trigger headers from the server
*@
<div hx-get="?handler=EmptyForm"
hx-trigger="clearForm from:body"
hx-target="#task-form"
hx-swap="outerHTML">
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
#
Partials Reference
File: Pages/Tasks/Partials/_TaskList.cshtml
@using RazorPagesHtmxWorkshop.Models
@model IReadOnlyList<TaskItem>
@*
Task List Fragment
==================
Target ID: #task-list
Swap: outerHTML
Returned by: OnGetList, OnPostCreate (on success)
This fragment displays the list of tasks.
The wrapper div with id="task-list" is essential for htmx targeting.
Using outerHTML swap means the entire div is replaced, not just its contents.
*@
<div id="task-list">
@if (Model.Count == 0)
{
<div class="text-muted">
No tasks yet. Add one to establish a baseline.
</div>
}
else
{
<ul class="list-group list-group-flush">
@foreach (var task in Model)
{
<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>
@if (task.IsDone)
{
<span class="badge text-bg-success">Done</span>
}
else
{
<span class="badge text-bg-secondary">Open</span>
}
</li>
}
</ul>
}
</div>
File: Pages/Tasks/Partials/_Messages.cshtml
@model string?
@*
Messages Fragment
=================
Target ID: #messages
Swap: innerHTML (when used as error target)
This fragment displays flash messages and can also receive
error content via HX-Retarget from server error responses.
*@
<div id="messages">
@if (!string.IsNullOrWhiteSpace(Model))
{
<div class="alert alert-info workshop-alert" role="alert">
@Model
</div>
}
</div>
File: Pages/Tasks/Partials/_Error.cshtml
@model string
@*
Error Fragment
==============
Purpose:
- Displays error messages in a consistent format
- Used for server errors, not found conditions, etc.
- Typically swapped into #messages via HX-Retarget
Model:
- string message - The error message to display
*@
<div class="alert alert-danger workshop-alert" role="alert">
<strong>Error:</strong> @Model
</div>
#
Verification Checklist
Before moving to Lab 3, verify these behaviors:
#
Form Submission
- Submit with valid title updates only
#task-list(no page reload) - Submit with empty title shows error in
#task-form(retargeted) - Submit with "boom" shows error in
#messages(retargeted) - Form clears after successful submit
- Loading spinner appears during submission
#
Refresh Buttons
- "Refresh All" fetches and displays all tasks
- "Top 5" fetches with
take=5parameter - Both buttons show loading indicator during request
#
Network Verification
- Requests include
HX-Request: trueheader - Responses are HTML fragments (not full pages)
- Success responses include
HX-Trigger: clearFormheader - Error responses include
HX-RetargetandHX-Reswapheaders
#
DOM Verification
-
#task-listexists and updates correctly -
#task-formexists and shows validation errors -
#messagesexists and shows server errors - Loading indicator shows/hides correctly
#
Key Takeaways
#
The htmx Mental Model
- HTML is the response format: Server returns fragments, not JSON
- Targets are CSS selectors:
hx-target="#task-list"finds the element by ID - Swap strategies matter:
outerHTMLreplaces,innerHTMLfills - Headers control behavior: Response headers can override client attributes
#
Patterns You've Learned
#
Important Implementation Details
- Namespace: Use
RazorPagesHtmxWorkshop(notRazorHtmxWorkshop) - Fragment helper: Uses
MetadataProviderandModelStatefor proper validation context - Progressive enhancement: Keep traditional attributes (
method,asp-page-handler) as fallbacks - Workshop styling: Custom CSS provides dark theme with gradient backgrounds
#
What Comes Next
In Lab 3, you'll implement:
- Real-time validation ("validate as you type")
- Data annotations for validation rules
- Field-level error fragments
- Antiforgery token handling with htmx
#
Troubleshooting
#
Common Issues and Solutions
#
Debug Tips
- Network Tab: Check request headers for
HX-Request: true - Network Tab: Check response headers for
HX-Retarget,HX-Reswap,HX-Trigger - Console: Look for htmx errors or warnings
- Elements Tab: Watch DOM changes during swaps
- Response Preview: Verify server returns HTML fragment, not full page
#
Summary
You have successfully completed Lab 2! Your application now:
- ✅ Submits forms via htmx without page reloads
- ✅ Updates only the affected regions of the page
- ✅ Shows loading indicators during requests
- ✅ Handles validation errors gracefully with retargeting
- ✅ Handles server errors with appropriate feedback
- ✅ Supports parameterized queries with
hx-vals - ✅ Clears the form after successful submission
This is the core htmx workflow that you'll use throughout your applications. In Lab 3, you'll build on this foundation to add real-time validation and more sophisticated form handling.
Proceed to Lab 3: Real-Time Validation and Form UX →