State Management in HTMX Applications: A Guide to URL-First State
Simple, powerful state management: how URL parameters can replace complex client-side state libraries in HTMX applications
State Management in HTMX Applications: A Guide to URL-First State
In the world of Single-Page Applications, state management is a well-understood (if often complex) problem, with established libraries like Redux, Pinia, Zustand, or Svelte Runes. But what happens when you’re building with HTMX? You’ve intentionally stepped away from heavy client-side frameworks, but you still need to manage state—table sorting, filters, pagination, search queries, and so on.
The challenge is to maintain this state in a way that feels fast and interactive, but also robust, shareable, and bookmarkable. If a user filters a list and sorts it by price, they should be able to refresh the page or send the URL to a colleague and see the exact same view.
The answer doesn’t lie in a complex client-side library. It lies in embracing the original state machine of the web itself: the URL. By treating the URL as your single source of truth, you can build a powerful and resilient state management system that perfectly complements the HTMX philosophy.
The Core Idea: The URL is Your State
Instead of storing application state in a JavaScript object on the client, we store it directly in the URL’s query parameters.
A URL like this isn’t just an address; it’s a complete representation of the UI’s state:
/?status=Active&sortField=ListPrice&sortDir=desc&page=2
This tells us everything we need to know:
- Filter by
status=Active
- Sort by
ListPrice
indesc
order - Show the
2nd
page of results
This URL-first approach has immediate benefits. It’s inherently shareable, bookmarkable, and the browser’s back and forward buttons work exactly as a user would expect, for free.
Step 1: The Server Reads the State
The first part of the puzzle is making sure the server can read this state on a full page load. This handles the case where a user navigates directly to a URL with parameters, or hits refresh.
In our NestJS backend, the controller for the homepage simply accepts these query parameters.
// Simplified controller for the homepage (NestJS)
@Get("/")
async homepage(
@Query("sortField") sortField: string,
@Query("sortDir") sortDir: "asc" | "desc",
@Query("status") status: string,
@Query("page") page: string,
) {
// Pass the state to the service to fetch the correct data
const listings = await this.listingsService.getAll({ sortField, sortDir, status, page });
// Render the page with the fetched data
return { listings, ... };
}
When a request for /?status=Active
comes in, the server renders the page with the “Active” filter already applied. No client-side JavaScript is needed for the initial render.
Step 2: HTMX and Client-Side Helpers Update the State
For interactions that happen after the initial load (like clicking a sort button), we use HTMX. But how do we tell HTMX to include all the other existing state parameters in its request?
We use a simple JavaScript helper function that is called from our hx-vals
attribute. This function gathers the current state from the URL, merges it with the new state from the user’s action, and provides it to HTMX.
<!-- Button to sort by Price -->
<button
hx-get="/"
hx-target="#results"
hx-vals="js:{...createPayload({ sortField: 'ListPrice', currentSortDir: 'asc' })}"
>
Price
</button>
<!-- Status filter dropdown -->
<select
id="status-filter"
hx-get="/"
hx-target="#results"
hx-vals="js:{...createPayload({ status: this.value, page: 1 })}"
>
<!-- ... options ... -->
</select>
The createPayload
function is the key. It reads the current query string, parses it into an object, and merges the new values. This ensures that when you change the sort order, you don’t lose your existing status filter.
Step 3: Updating the URL without a Page Reload
After HTMX gets a response and swaps the content, the final step is to update the browser’s URL to reflect the new state. We do this with another small helper function that uses the History API.
// A helper to update the URL's query parameters
function handleQueryParams(newParams) {
const url = new URL(window.location.href);
// Set new params, and delete any that are now empty
for (const key in newParams) {
if (newParams[key]) {
url.searchParams.set(key, newParams[key]);
} else {
url.searchParams.delete(key);
}
}
// Update the URL in the browser bar without a refresh
window.history.replaceState({}, "", url);
}
After our createPayload
function runs and the HTMX request is prepared, this handleQueryParams
function is called to sync the browser bar. The user sees an instant content update and a seamless URL change.
The Final Piece: Preserving State Across Navigations
This system is great, but there’s one more common use case to solve. What if a user filters a list, clicks to view an item’s detail page, and then wants to go back to their filtered list? The browser’s back button will work, but what if they use a breadcrumb link like “Back to All Listings”? That link would normally take them to the unfiltered, default view.
For this, we use localStorage
as a temporary holding spot for our URL state.
-
Save the state on departure: We add a simple event listener to all the links in our table. When a user clicks one, we grab the current URL’s query parameters and save them to
localStorage
.// In our table-scripts.js const tableLinks = document.querySelectorAll("td a"); tableLinks.forEach((link) => { link.addEventListener("click", () => { const params = new URLSearchParams(window.location.search); if (params.toString()) { localStorage.setItem("tableParams", params.toString()); } }); });
-
Restore the state on return: On other pages, our “Back to Listings” breadcrumb link has a script that checks for this
localStorage
item and appends the parameters to itshref
if they exist.// In our breadcrumbs component const backLink = document.querySelector("a[data-needs-params]"); const savedParams = localStorage.getItem("tableParams"); if (backLink && savedParams) { backLink.href = `${backLink.href}?${savedParams}`; }
The Lesson: A Simple, Robust System
By combining these two techniques, you get a complete state management solution:
- The URL is the primary source of truth for view state, making it resilient, shareable, and SEO-friendly.
localStorage
is a secondary, temporary cache to preserve that state across page navigations.
This approach provides all the benefits of a modern, stateful UI without the heavy abstractions of a client-side framework. It embraces the principles of the web and HTMX, resulting in a system that is simple, powerful, and easy to reason about.