Optimistic delete
The row disappears instantly on click. If the server rejects the delete, it swaps back in with an error.
#When to use
Common, reversible operations where the user shouldn't wait on the network — deleting a row, archiving a message, toggling a favorite. For destructive operations with no undo, use a confirm dialog first.
#Markup
<tr id="row_@Model.Id">
<td>@Model.Name</td>
<td>
<form hx-post="/products/@Model.Id?handler=Delete"
hx-target="#row_@Model.Id"
hx-swap="outerHTML"
hx-on::before-request="this.closest('tr').style.display = 'none'">
<button type="submit">Delete</button>
</form>
</td>
</tr>
hx-on::before-requestfires synchronously, hides the row before the request completes.hx-target+hx-swap="outerHTML"mean the server response replaces the same— letting the handler either (a) return an empty body to confirm the delete, or (b) return the row markup with an error class to reverse the optimism. #Handler
csharp public async Task<IActionResult> OnPostDeleteAsync(int id) { var ok = await _products.DeleteAsync(id); if (!ok) { var row = await _products.GetAsync(id); Response.Headers["HX-Trigger"] = "{\"toast\":{\"level\":\"error\",\"message\":\"Could not delete — try again.\"}}"; return this.PageOrPartial("_ProductRow", row); } // Success — return empty content; htmx replaces the (already hidden) row with nothing. return Content(string.Empty, "text/html"); }#Gotchas
- Empty success response: return
Content(string.Empty, "text/html")rather thanEmptyResultso htmx receives a 200 with empty body and replaces the target with nothing.EmptyResultproduces a 204, which htmx ignores by default — leaving the hidden row in place (which still works, but is less explicit). - Confirm guard: pair with
hx-confirm="Delete this product?"to require confirmation before the request fires. - Accessibility: announce the delete with
aria-live="polite"on a sibling element so screen readers hear about it.
#Related
- Toast after action — confirm the delete with a toast.
- Empty success response: return