Skip to content

Inline edit

inline-edit is the simplest “click to edit” pattern. The display state and the edit state are two server-rendered HTML fragments at the same URL, and htmx swaps between them with outerHTML.

Activate Edit, change the name, and save — or save it blank to see the 422 edit fragment with the error state, then Cancel back to the original value. Every state is a server-rendered fragment from a demo implementation of the contract, namespaced under api/recipes/inline-edit/ (stateless: the current value rides along as a query parameter); your app would use its own URL such as /items/42/name.

The default rendering of a row cell — the value plus a small, real Edit <button>:

<span id="item-42-name">
Acme widgets
<button class="hc-button" data-size="sm" type="button"
data-hx-get="/items/42/name/edit"
data-hx-target="closest span"
data-hx-swap="outerHTML">
Edit
</button>
</span>

The button is the affordance: it is keyboard reachable (Tab, then Enter or Space), announced by assistive tech, and needs no extra ARIA. It targets closest span with outerHTML, so activating it replaces the whole display node with the edit form.

<!-- GET /items/42/name/edit -->
<form
id="item-42-name"
data-hx-put="/items/42/name"
data-hx-target="this"
data-hx-swap="outerHTML"
style="display: inline-flex; gap: .25rem;">
<input
name="name"
class="hc-input"
data-size="sm"
value="Acme widgets"
autofocus>
<button class="hc-button" data-size="sm" data-variant="primary" type="submit">Save</button>
<button class="hc-button" data-size="sm" type="button"
data-hx-get="/items/42/name"
data-hx-target="closest form"
data-hx-swap="outerHTML">Cancel</button>
</form>

Cancel targets closest form, so it swaps the whole edit form back to the display fragment.

The user types, presses Enter (or clicks Save). htmx submits the form to PUT /items/42/name and the server returns the updated display state:

<!-- PUT /items/42/name -->
<span id="item-42-name">
Acme widgets v2
<button class="hc-button" data-size="sm" type="button"
data-hx-get="/items/42/name/edit"
data-hx-target="closest span"
data-hx-swap="outerHTML">
Edit
</button>
</span>

The outerHTML swap replaces the entire <form> with the display <span> — the same node id, just a different element type. htmx re-processes the new attributes automatically.

For a server-side validation failure, return 422 with the edit form re-rendered and the error message inline. Keep the same id so the outerHTML swap targets the same node:

<!-- PUT /items/42/name → 422 -->
<form id="item-42-name" data-hx-put="/items/42/name" data-hx-target="this" data-hx-swap="outerHTML">
<div class="hc-field" data-invalid="true">
<input class="hc-input" data-size="sm" name="name" value=""
aria-invalid="true"
aria-describedby="item-42-name-error">
<p id="item-42-name-error" class="hc-field__message">Name is required.</p>
</div>
<button class="hc-button" data-size="sm" data-variant="primary" type="submit">Save</button>
<button class="hc-button" data-size="sm" type="button"
data-hx-get="/items/42/name"
data-hx-target="closest form"
data-hx-swap="outerHTML">Cancel</button>
</form>

htmx ≥ 2 does not swap non-2xx responses by default. Allow the 422 swap once, globally — the same allowance the field-errors recipe documents:

document.body.addEventListener('htmx:beforeSwap', (event) => {
if (event.detail.xhr.status === 422) {
event.detail.shouldSwap = true;
event.detail.isError = false;
}
});

(Alternatives: answer 200 with the error fragment, or send HX-Reswap / HX-Retarget headers. data-hx-target-422="this" also works, but requires htmx’s response-targets extension.)

  • The Edit trigger is a real <button> — keyboard reachable, announced by assistive tech, no extra ARIA needed. (See the note above before swapping it for a clickable <span>.)
  • Use autofocus on the input so keyboard users land on the editable field immediately.
  • For Escape-to-cancel, add a small keydown listener in your own bundle that clicks the Cancel button (no inline onkeydown handlers — they break under a CSP) — or skip Escape support and rely on the visible Cancel button.
RequestResponse
GET /items/:id/nameDisplay fragment (<span id="item-:id-name"> with the Edit button).
GET /items/:id/name/editEdit fragment (<form id="item-:id-name">…</form>).
PUT /items/:id/name (200)Display fragment with the new value.
PUT /items/:id/name (422)Edit fragment with data-invalid="true" and a .hc-field__message — needs the htmx:beforeSwap allowance above.

All four responses share the same DOM id — that is what makes the swap reversible.

  • Optimistic UI? Skip it for inline edits. The round trip is short, the server is authoritative, and the bookkeeping of rolling back a failed optimistic update outweighs the perceived speed-up.
  • Keyboard parity. The Edit button keeps the display state Tab-reachable and Enter-activatable; if you swap it for a custom trigger, preserve that.
  • Avoid mixing inline edit and remote-dialog for the same field. Pick one. Inline edit is right when the change is one value and the user is already looking at it. A remote dialog wins for multi-field updates.