Dynamic Routing And Popstate

Product detail pages need URLs like /product/12. popstate keeps Back and Forward in sync. Here is how dynamic routes and history navigation fit together.

May 1, 20264 min read4 / 4

I had forward navigation working. Then I pressed Back. The URL changed correctly. The DOM did not move. That gap -- URL updates without view updates on Back/Forward -- is the popstate problem. And product pages introduced a second one: a route that changes based on data, not just a fixed path.

The Essentials

  1. Dynamic routes with string matching: For patterns like /product/12, check whether the route starts with /product/ using startsWith. Extract the ID from the remainder of the string.
  2. Passing data to page elements via dataset: Use data-* attributes to attach the product ID to an element. Read it back from element.dataset inside the page's create function.
  3. popstate closes the loop: Back and Forward buttons fire popstate on window. The handler reads the route from event.state and calls router.go with addToHistory = false to re-render without creating a new history entry.
  4. event.state vs window.location.pathname: Reading the route from event.state is more reliable because the state object travels with the history entry regardless of where the user navigates.

Matching Dynamic Routes

The switch statement for static routes does not extend naturally to dynamic paths. A case '/product/12' would need one case per product. The fix is to check the prefix and parse the rest:

JavaScript
export function renderRoute(route) { let pageElement; if (route === '/') { pageElement = MenuPage.create(); } else if (route === '/cart') { pageElement = CartPage.create(); } else if (route.startsWith('/product/')) { const id = route.substring('/product/'.length); pageElement = DetailsPage.create(); pageElement.dataset.id = id; } else { pageElement = MenuPage.create(); } const main = document.querySelector('main'); if (main.children[0]) main.children[0].remove(); main.appendChild(pageElement); window.scrollY = 0; }

startsWith('/product/') matches any product URL. substring pulls everything after the prefix - the numeric ID. That ID gets attached to the element before inserting it into the DOM.

The dataset API

dataset is the JavaScript API for reading and writing data-* attributes on DOM elements.

JavaScript
// Set a data attribute element.dataset.id = '12'; // Reads as: element.setAttribute('data-id', '12') // Read it back inside the page module const id = element.dataset.id;

The naming converts automatically: dataset.productId maps to data-product-id, dataset.id maps to data-id. This gives the DetailsPage a way to receive its product ID without needing a function argument or a module-level variable.

Inside the page module:

JavaScript
// pages/DetailsPage.js const DetailsPage = { create() { const section = document.createElement('section'); section.id = 'details'; // Retrieve the id set by the router // (this.element isn't wired up yet - the pattern // passes the id after create() returns) return section; }, render(element) { const id = element.dataset.id; const product = Store.menu.find(item => item.id == id); // ... populate element with product data } };

The router calls create() to get the element, sets dataset.id, then appends the element. A MutationObserver or a direct render call can then read dataset.id and populate the content.

The popstate Handler

When the user presses Back or Forward, the URL changes and popstate fires. Without a handler, the URL updates but the DOM stays on the previous page.

JavaScript
window.addEventListener('popstate', event => { const route = event.state?.route ?? '/'; Router.go(route, false); });

event.state is the object passed as the first argument to pushState when this history entry was created. Reading route from it avoids having to parse window.location.pathname.

Router.go(route, false) re-renders the view without calling pushState again. Calling pushState inside a popstate handler would create a new history entry on top of the one the user just navigated to - breaking the history stack.

Storing the Route in pushState

For event.state.route to be readable in the popstate handler, the route string must be stored when navigating:

JavaScript
// In Router.go history.pushState({ route }, '', route);

The first argument { route } is the state object. It is serialized and stored alongside the history entry. When the user navigates back to this entry, the browser restores it as event.state.

Further Reading and Watching

Video: