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.
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
- Dynamic routes with string matching: For patterns like
/product/12, check whether the route starts with/product/usingstartsWith. Extract the ID from the remainder of the string. - Passing data to page elements via
dataset: Usedata-*attributes to attach the product ID to an element. Read it back fromelement.datasetinside the page's create function. popstatecloses the loop: Back and Forward buttons firepopstateonwindow. The handler reads the route fromevent.stateand callsrouter.gowithaddToHistory = falseto re-render without creating a new history entry.event.statevswindow.location.pathname: Reading the route fromevent.stateis 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:
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.
// 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:
// 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.
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:
// 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
- MDN: HTMLElement.dataset - Full reference for reading and writing
data-*attributes from JavaScript. - MDN: Window: popstate event - When popstate fires, what event.state contains, and edge cases.
Video:
- History API - JavaScript Tutorial by dcode. Covers pushState, popstate, and reading state - directly applicable to this pattern.
Keep reading