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 (
sortnots) - 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