The second part of the series is about forms and everything built on them: pagination, filters and search. Navigation, morph and the back button are covered in the first part, modal forms and Alpine.js in the third.
Full page or fragment
Choose in this order:
- Full page via boost. Forms and links are boosted, the server returns the full page and morph merges it. No extra code on the server. Enough for most forms, listings, pagination and filters.
- Fragment with its own route. One URL, one representation. Suitable for lazy loading.
- Same URL, two representations. A form or listing that changes inside the page, where rendering the full page is needlessly expensive.
How the server tells what came in
- A boosted link or form sends both
HX-RequestandHX-Boostedand wants the full page. - An element with its own
hx-getorhx-postsends onlyHX-Requestand wants a fragment. - The back button sends only
HX-History-Restore-Requestand wants the full page.
If you check only HX-Request, you send a fragment to a boosted link and the page loses its layout. The HX-Request-Type header looks like a better distinction, but the hx-preload extension does not send it, so a prefetched link would get the wrong response. The reliable combination is:
private function isFragmentRequest(Request $request): bool
{
return $request->headers->has('HX-Request')
&& !$request->headers->has('HX-Boosted');
}
One template for the page and the fragment
A fragment does not need its own template. renderBlock() renders just one block of the page template:
{# contact/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>Contact</h1>
{% block form %}
{{ form_start(form, {attr: {id: 'contact-form'}}) }}
{{ form_widget(form) }}
<button>Send</button>
{{ form_end(form) }}
{% endblock %}
{% endblock %}
{% block sent %}<p id="contact-form">Thank you, we will get back to you.</p>{% endblock %}
For a fragment with its own route, the #[Template] attribute is shorter:
#[Route('/blog/picker', name: 'app_blog_picker')]
#[Template('blog/index.html.twig', block: 'articles')]
public function picker(ArticleRepository $articles): array
{
return ['articles' => $articles->latest()];
}
When the page and the fragment share a URL, set Vary so a cache does not return the fragment instead of the page:
$response->setVary(['HX-Request', 'HX-Boosted'], false);
The form
Controller
#[Route('/contact', name: 'app_contact', methods: ['GET', 'POST'])]
public function contact(Request $request): Response
{
$form = $this->createForm(ContactType::class);
$form->handleRequest($request);
$isFragment = $this->isFragmentRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// save and send
return $isFragment
? $this->renderBlock('contact/index.html.twig', 'sent')
: $this->redirectToRoute('app_contact');
}
return $isFragment
? $this->renderBlock('contact/index.html.twig', 'form', ['form' => $form])
: $this->render('contact/index.html.twig', ['form' => $form]);
}
Both render() and renderBlock() set status 422 when they get a submitted, invalid FormInterface. With $form->createView() it stays 200 and you have to set the status yourself.
Template
{{ form_start(form, {
action: path('app_contact'),
attr: {
id: 'contact-form',
novalidate: true,
'hx-post': path('app_contact'),
'hx-swap': 'outerHTML',
'hx-sync': 'this:drop',
},
}) }}
- Keep
actionandmethod, without JavaScript the form is submitted normally. hx-swap="outerHTML", not morph. Morph keeps field values, so the form would stay filled in after submission.hx-sync="this:drop"drops a second submission.hx-indicatorshows the state during submission, thehx-pendingextension puts custom content into the target.novalidatewhen the server checks the errors. Firefox for Android does not display the browser's messages, so an empty form would silently do nothing after the click. Keeprequiredon the fields for screen readers.
Error responses
422 is swapped without any configuration. For a fragment, though, you do not want a full error page inside the form. hx-status looks for the exact code first and only then for a wildcard, so 422 must be listed separately, otherwise validation errors silently disappear:
<form hx-post="…" hx-target="#order" hx-swap="innerHTML"
hx-status:422="swap:'innerHTML'"
hx-status:4xx="swap:'none'"
hx-status:5xx="swap:'none'">
swap:'none' is not enough. hx-head merges the response's head before hx-status is evaluated, and htmx sets the title even when nothing is swapped. An error page would thus overwrite the title and head of the current page. Therefore allow the head and title only for a full-page swap (with the #app variant, compare the target with that element):
const isFullPageSwap = (ctx) => ctx.target === document.body
document.addEventListener('htmx:head:before:merge', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.preventDefault()
})
document.addEventListener('htmx:before:swap', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.detail.ctx.title = null
})
A server error must not throw away the text. For a form where the user wrote something long, discard the 5xx response and put a phone number and e-mail below the button. The same when the network fails:
document.addEventListener('htmx:before:response', (event) => {
if (event.detail.ctx.response.status < 500) return
event.preventDefault()
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
document.addEventListener('htmx:error', (event) => {
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
Redirect after submission
- A boosted form handles a redirect and writes the final URL to history. When the target has a different layout, give the form
hx-boost="false". This applies mainly to login, where the authenticator redirects after success, and to logout. hx-postwith an ordinary redirect inserts the whole target page into the target element. A full page inside a small box is the typical symptom.HX-Locationin a 2xx response moves to another page without a full load and changes the URL and title. Useful after a fragment has been submitted successfully.HX-Redirectforces a full page load, for example to a payment gateway or a page with a different layout. htmx does not process headers on a 3xx response, andfetchfollows the redirect on its own.HX-Refresh: truereloads the current page, for example after login, when the whole header changes.
Accessibility
- After a swap, focus is often on
<body>. After a 422 move it to the first invalid field, after submission to the whole confirmation. Both inhtmx:after:settle. - During submission use
aria-disabledon the button, notdisabled, which would drop focus to the top of the page. - Announce the submission state in a pre-inserted element with
role="status".
Pagination and filters
With the full page
A filter as a GET form and pagination as links, both boosted. The server does not distinguish anything, the state is in the URL and the back button works on its own. We recommend this as the default.
With a fragment
<a href="{{ path('app_blog', {page: page + 1}) }}"
hx-get="{{ path('app_blog', {page: page + 1}) }}"
hx-target="#articles"
hx-swap="innerHTML"
hx-push-url="true">Next</a>
The link needs both href and hx-get. Without href a crawler will not find it and it does not work without JavaScript. The controller returns the block via renderBlock() and sets Vary. You can get the same result without distinguishing on the server: hx-select="#articles" picks part of the full page. The price is a larger response.
Infinite scroll
The last item of the list loads the next page when it enters the viewport:
<li hx-get="{{ path('app_blog', {page: page + 1}) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<a href="{{ path('app_blog', {page: page + 1}) }}">More articles</a>
</li>
Keep the link inside. A crawler does not scroll, so without it it would never reach the next pages. In a custom scroll container instead of the window, use intersect with root:.
Live search
<input type="search" name="q"
hx-get="{{ path('app_search') }}"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#results"
hx-sync="this:replace">
changed delay: sends the request only after a pause in typing and only when the value has changed, so a quickly typed word means one request. hx-sync="this:replace" cancels a pending request when a new one comes. htmx reports the cancelled request in the console as an error, which does not affect functionality. Keep the form around the field, without JavaScript the search is submitted normally.
SEO for pagination and filters
- Each page of a listing has its own
canonicalwith?page=and a title with the number. Google advises against using the first page as the canonical one. You do not need to put further pages in the sitemap, links lead to them. - Add
hreflangon further pages only when the language versions have the same items in the same order. Otherwise leave it out, a link to a non-existent page is worse than none. - Filtered and sorted variants that should not be landing pages are excluded with
noindex. Acanonicalpointing to the listing without parameters is only a hint for the search engine. - Form POST routes and search results do not belong in the sitemap.
Updates outside the target
One response can change several places on the page, for example the message about a discount code and the price summary.
hx-swap-oob in the response marks an element that is swapped by its id outside the main target:
{% block discount_feedback %}
{% if error %}<p>{{ error }}</p>{% endif %}
<div id="price-summary" hx-swap-oob="outerHTML">…</div>
{% endblock %}
<hx-partial> is more readable when several places change, each in a different way. Each partial carries its own target and swap:
<hx-partial hx-target="#cart-items" hx-swap="beforeend">…</hx-partial>
<hx-partial hx-target="#cart-count">3</hx-partial>
Watch out for:
- Empty main content is not swapped. When nothing remains in the response after the OOB elements are taken out, htmx skips the main swap. After a discount code was accepted, the error from the previously rejected code thus stayed on the page next to the new price.
hx-swap="innerHTML swapEmpty:true"helps. - Wrap table rows and list items in
<template>, otherwise the HTML parser discards them outside a table. - A list whose items are added and changed by
idis handled by thehx-upsertextension (hx-swap="upsert key:data-id sort"). The same content into several places at once is sent byhx-targetswith a selector.
Checklist
- Fragment only for
HX-Request && !HX-Boosted, not based onHX-Request-Type - Fragment via
renderBlock()or#[Template(block:)],Varyfor a shared URL FormInterfacepassed torender()andrenderBlock(), notcreateView()- Form with
action,outerHTML,hx-sync,novalidate hx-status:422next to the4xxwildcard, head and title only for a full-page swap- The form's text survives a 5xx and a network failure
- Login and logout without boost, after a fragment
HX-LocationorHX-Redirect, not 3xx - Focus after 422 and after submission,
aria-disabled,role="status" - Pagination with
href, filter state in the URL,canonicalon each page, filters withnoindex - A response with OOB and empty main content has
swapEmpty:true