URL-Driven State in HTMX

Jul 30, 2025 - 02:30
 0  0
URL-Driven State in HTMX

Bookmarkable by Design: URL-Driven State in HTMX

Forget complex state libraries. Use the URL as your single source of truth for filters, sorting, and pagination in HTMX applications


Bookmarkable by Design: URL-Driven State in HTMX

When you move from React to HTMX, you trade complex state management for server-side simplicity. But you still need to handle filters, sorting, pagination, and search. Where does that state live now?

The answer is surprisingly elegant: in the URL itself. By treating URL parameters as your single source of truth, you get bookmarkable, shareable application state without needing to install another dependency.

The Pattern in Action

A URL like /?status=active&sortField=price&sortDir=desc&page=2 tells you everything about the current view. It’s not just an address—it’s a complete state representation that users can bookmark, share, or refresh without losing context.

Quick Start: Three Essential Steps

The entire pattern revolves around three synchronized steps:

  1. Server reads URL parameters and renders the appropriate view
  2. Client preserves all state when making HTMX requests
  3. Browser URL updates without page reload after each interaction

Let’s build this step by step.

Step 1: Server Reads URL State

Your server endpoint reads query parameters and uses them to render the initial view:

@Get("/")
@Render("data-table.eta")
async homepage(
  @Query("sortField") sortField: string,
  @Query("sortDir") sortDir: "asc" | "desc",
  @Query("status") status: string,
  @Query("page") page: string,
) {
  // Parse with defaults
  const pageNum = parseInt(page) || 1;
  
  // Apply state to data query
  const result = await this.dataService.getItems({
    sortField: sortField || "name",
    sortDir: sortDir || "asc",
    status: status || "",
    page: pageNum,
  });

  // Return both data and state for template
  return {
    items: result.items,
    totalItems: result.total,
    sortField: sortField || "name",
    sortDir: sortDir || "asc",
    status: status || "",
    page: pageNum,
  };
}

The template embeds this state directly in the DOM (using ETA templates in this case):

<div id="data-table"
     data-status="<%= it.status %>"
     data-sortfield="<%= it.sortField %>"
     data-sortdir="<%= it.sortDir %>"
     data-page="<%= it.page %>">
  
  
  <select hx-get="/api/data"
          hx-target="#data-table"
          hx-vals="js:{...createPayload({status: this.value, page: 1})}">
    <option value="" <%= it.status === '' ? 'selected' : '' %>>Alloption>
    <option value="active" <%= it.status === 'active' ? 'selected' : '' %>>Activeoption>
  select>

  
  <th class="sortable <%= it.sortField === 'price' ? 'sorted' : '' %>"
      hx-get="/api/data"
      hx-target="#data-table"
      hx-vals="js:{...createPayload({sortField: 'price'})}">
    Price
    <% if (it.sortField === 'price') { %>
      <%= it.sortDir === 'asc' ? '↑' : '↓' %>
    <% } %>
  th>
div>

Key insights:

  • State flows from URL → Server → DOM
  • The data-* attributes make current state accessible to JavaScript
  • Server-side rendering means the page works before any JavaScript loads

Step 2: Client Coordination with createPayload()

The magic happens in createPayload(), which coordinates state across user interactions:

function createPayload(newState = {}) {
  // Get current state from URL and DOM
  const url = new URL(window.location.href);
  const params = new URLSearchParams(url.search);
  const table = document.querySelector("#data-table");
  
  // Build current state from both sources
  const currentState = {
    sortField: params.get("sortField") || table.dataset.sortfield || "name",
    sortDir: params.get("sortDir") || table.dataset.sortdir || "asc",
    status: params.get("status") || table.dataset.status || "",
    page: parseInt(params.get("page")) || parseInt(table.dataset.page) || 1,
  };

  // Handle sort direction toggling
  let sortDir = currentState.sortDir;
  if (newState.sortField && newState.sortField === currentState.sortField) {
    // Same field: toggle direction
    sortDir = currentState.sortDir === "asc" ? "desc" : "asc";
  } else if (newState.sortField) {
    // New field: use smart defaults
    sortDir = ["price", "date"].includes(newState.sortField) ? "desc" : "asc";
  }

  // Merge states
  const payload = {
    ...currentState,
    ...newState,
    sortDir: sortDir,
  };

  // Update URL immediately
  updateURLParams(payload);
  
  return payload;
}

This function is deceptively simple but handles complex interactions:

  • Preserves existing filters when sorting
  • Resets to page 1 when filters change
  • Toggles sort direction intelligently
  • Updates the URL before HTMX makes its request

Step 3: Syncing the Browser URL

After each interaction, we update the URL without a page reload:

function updateURLParams(payload) {
  const url = new URL(window.location.href);
  const params = new URLSearchParams();

  // Only include non-empty values
  Object.entries(payload).forEach(([key, value]) => {
    if (value && String(value).trim()) {
      params.set(key, String(value));
    }
  });

  // Update URL and create history entry for back button navigation
  url.search = params.toString();
  window.history.pushState({}, "", url.toString());
}

Using pushState creates a history entry for each state change, allowing users to navigate through their filter and sort history with the browser’s back and forward buttons.

Preserving State Across Navigation

What happens when users navigate away and come back? We use localStorage to preserve their context:

// Save state when navigating to detail pages
document.addEventListener("click", (e) => {
  if (e.target.matches("td a")) {
    const params = window.location.search;
    if (params) {
      localStorage.setItem("tableParams", params);
      localStorage.setItem("tableParamsExpiry", Date.now() + 1800000); // 30 min
    }
  }
});

// Restore state on breadcrumb links
document.addEventListener("DOMContentLoaded", () => {
  const saved = localStorage.getItem("tableParams");
  const expiry = localStorage.getItem("tableParamsExpiry");
  
  if (saved && expiry && Date.now() < expiry) {
    document.querySelectorAll("a[data-restore-params]").forEach(link => {
      link.href = link.getAttribute("href") + saved;
    });
  } else {
    // Clean up expired state
    localStorage.removeItem("tableParams");
    localStorage.removeItem("tableParamsExpiry");
  }
});

Now a “Back to Listings” breadcrumb returns users to their exact filtered view.

Production Considerations

URL Length Limits: Browsers support URLs up to ~2000 characters. For complex filters, consider using abbreviated parameter names or moving some state server-side.

Parameter Validation: Always validate and sanitize URL parameters on the server. Treat them as untrusted user input.

State Expiration: The localStorage approach includes expiration to prevent stale state from persisting too long.

Testing: The pattern is highly testable since state is explicit:

// Test with mocked window.location
const result = createPayload({ page: 2 }, {
  location: { href: "http://test.com?status=active" }
});
expect(result.status).toBe("active");
expect(result.page).toBe(2);

The Architecture Payoff

This URL-first approach delivers multiple benefits without the complexity of client-side state management libraries. Every view is inherently shareable, so you can send a colleague a link and they see will exactly what you see. The browser’s back button works as expected by returning to previous filter states. SEO is built-in since search engines can crawl every state combination. And the debugging experience is transparent because the current state is always visible in the address bar.

By embracing the URL as your state store, you’re not working around the web platform, you’re working with it. This pattern scales from simple sorting to complex multi-filter interfaces while maintaining the simplicity that drew you to HTMX in the first place.

The next time you reach for a state management library, consider whether the humble URL might be all you need. In many cases, it’s not just sufficient, it’s superior.

What's Your Reaction?

Like Like 0
Dislike Dislike 0
Love Love 0
Funny Funny 0
Angry Angry 0
Sad Sad 0
Wow Wow 0