URLs for State Management

Using URLs to create shareable, bookmarkable application states

The Stateless Protocol Paradox

HTTP is stateless—each request is independent, with no memory of previous interactions. Yet web applications are full of state: search filters, pagination, selected tabs, form values, and navigation history.

URLs solve this paradox elegantly. By encoding state in the URL, we get:

  • Shareability: Users can share exact application states
  • Bookmarkability: Users can save and return to specific states
  • Navigation: Browser back/forward buttons work naturally
  • Refresh resilience: State survives page reloads

Query Parameters for State

Query parameters are the most common way to store state in URLs. They appear after the ? and use key=value pairs separated by &.

Common Use Cases

Use Case Example URL
Search /products?q=keyboard
Filters /products?category=electronics&brand=acme
Sorting /products?sort=price&order=asc
Pagination /products?page=3&limit=20
View mode /products?view=grid
Date range /reports?from=2024-01-01&to=2024-12-31

Reading Query Parameters

// Modern approach using URLSearchParams
const params = new URLSearchParams(window.location.search);

const search = params.get('q');           // 'keyboard' or null
const page = params.get('page') || '1';   // '3' or '1' (default)
const categories = params.getAll('cat');  // Array of all 'cat' values

// Check if parameter exists
if (params.has('debug')) {
  console.log('Debug mode enabled');
}

Modifying Query Parameters

const params = new URLSearchParams(window.location.search);

// Add or update parameters
params.set('page', '2');
params.set('sort', 'price');

// Remove a parameter
params.delete('debug');

// Create new URL with updated params
const newUrl = `${window.location.pathname}?${params.toString()}`;

// Navigate to new URL (with or without history entry)
history.pushState({}, '', newUrl);  // Adds history entry
history.replaceState({}, '', newUrl);  // Replaces current entry

The History API

The History API lets you modify the browser's history and URL without triggering a page reload. This is essential for single-page applications (SPAs) and enhanced multi-page apps.

pushState: Adding History Entries

pushState adds a new entry to the browser's history stack:

// Syntax: history.pushState(state, title, url)
history.pushState(
  { page: 2, filter: 'active' },  // State object (accessible via event.state)
  '',                               // Title (currently ignored by most browsers)
  '/products?page=2&filter=active' // New URL
);

After pushState:

  • The URL bar updates immediately
  • No page reload occurs
  • The back button now returns to the previous URL
  • The state object is saved with the history entry

replaceState: Updating Without History

replaceState modifies the current history entry without adding a new one:

// Replace current entry (back button still goes to previous page)
history.replaceState(
  { page: 2 },
  '',
  '/products?page=2'
);

Use replaceState when:

  • Updating filter values within the same logical "page"
  • Normalizing URLs (fixing typos, canonicalization)
  • You don't want every small change to create a history entry

Handling Browser Navigation

When users click back/forward or navigate via history, your app needs to respond. The popstate event fires on navigation:

window.addEventListener('popstate', (event) => {
  // event.state contains the state object from pushState/replaceState
  console.log('Navigated to:', window.location.href);
  console.log('State:', event.state);

  // Update your UI based on the new URL
  const params = new URLSearchParams(window.location.search);
  updateFilters(params);
  fetchData(params);
});

function updateFilters(params) {
  // Sync UI controls with URL parameters
  document.querySelector('#search').value = params.get('q') || '';
  document.querySelector('#sort').value = params.get('sort') || 'relevance';
}

function fetchData(params) {
  // Fetch data based on current URL state
  fetch(`/api/products?${params.toString()}`)
    .then(response => response.json())
    .then(renderProducts);
}

Fragment Identifiers (Hash)

The fragment (#) portion of the URL is handled entirely client-side and never sent to the server. This makes it useful for client-only state.

Traditional Use: Page Sections

<!-- Link to a section -->
<a href="#pricing">See Pricing</a>

<!-- Target section -->
<section id="pricing">
  <h2>Pricing</h2>
  ...
</section>

Hash-Based Routing (Legacy SPAs)

Before the History API, SPAs used hash-based routing:

// Hash-based routes (older pattern)
https://app.example.com/#/users
https://app.example.com/#/users/42
https://app.example.com/#/settings

The hashchange event detects changes:

window.addEventListener('hashchange', () => {
  const route = window.location.hash.slice(1); // Remove the #
  handleRoute(route);
});

Hash vs History API

Aspect Hash (#) History API
Sent to server No Yes
SEO friendly Limited Yes
Server rendering Not possible Possible
Browser support All browsers IE10+, all modern
Server config needed No Yes (catch-all route)

Complete Example: Filterable Product List

Here's a complete example of URL-based state management:

<!-- HTML Structure -->
<form id="filters">
  <input type="search" name="q" placeholder="Search...">
  <select name="sort">
    <option value="relevance">Relevance</option>
    <option value="price-asc">Price: Low to High</option>
    <option value="price-desc">Price: High to Low</option>
  </select>
  <select name="category">
    <option value="">All Categories</option>
    <option value="electronics">Electronics</option>
    <option value="clothing">Clothing</option>
  </select>
</form>

<div id="products"></div>
<nav id="pagination"></nav>
// JavaScript: URL State Management

class ProductFilters {
  constructor() {
    this.form = document.getElementById('filters');
    this.productsEl = document.getElementById('products');

    // Initialize from URL on page load
    this.syncFromUrl();

    // Listen for form changes
    this.form.addEventListener('input', () => this.handleFilterChange());

    // Listen for browser navigation
    window.addEventListener('popstate', () => this.syncFromUrl());
  }

  // Read current URL parameters
  getParams() {
    return new URLSearchParams(window.location.search);
  }

  // Sync form controls with URL
  syncFromUrl() {
    const params = this.getParams();

    // Update form inputs to match URL
    this.form.q.value = params.get('q') || '';
    this.form.sort.value = params.get('sort') || 'relevance';
    this.form.category.value = params.get('category') || '';

    // Fetch and display products
    this.fetchProducts();
  }

  // Handle filter changes
  handleFilterChange() {
    const formData = new FormData(this.form);
    const params = new URLSearchParams();

    // Only include non-empty values
    for (const [key, value] of formData) {
      if (value) params.set(key, value);
    }

    // Update URL (use replaceState for filter changes)
    const newUrl = params.toString()
      ? `${window.location.pathname}?${params.toString()}`
      : window.location.pathname;

    history.replaceState({ filters: true }, '', newUrl);

    // Fetch new data
    this.fetchProducts();
  }

  // Fetch products based on current URL
  async fetchProducts() {
    const params = this.getParams();
    // In real app: const response = await fetch(`/api/products?${params}`);
    console.log('Fetching products with:', params.toString());
  }
}

// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
  new ProductFilters();
});

URL State Best Practices

What to Store in URLs

  • Yes: Filters, search queries, pagination, sorting, selected tabs
  • Yes: View modes (grid/list), visible panels, date ranges
  • No: Sensitive data (passwords, tokens, personal info)
  • No: Temporary UI state (hover states, dropdown open/closed)
  • No: Large amounts of data (use localStorage or backend)

URL Design Guidelines

  • Use descriptive parameter names (sort not s)
  • Provide sensible defaults (don't require all parameters)
  • Keep URLs reasonably short (< 2000 characters is safe)
  • Use consistent naming (decide on snake_case vs camelCase)
  • Consider URL encoding for special characters

Handling Defaults

// Don't include default values in URL
const defaults = {
  sort: 'relevance',
  page: '1',
  limit: '20'
};

function updateUrl(filters) {
  const params = new URLSearchParams();

  for (const [key, value] of Object.entries(filters)) {
    // Only add non-default values
    if (value && value !== defaults[key]) {
      params.set(key, value);
    }
  }

  return params.toString();
}

// Clean URL: /products?category=electronics
// Instead of: /products?category=electronics&sort=relevance&page=1&limit=20