Single-page apps need to change the view without hitting the server. The oldest, most bulletproof way is the URL hash: everything after # is client-only — the browser never sends it and never reloads — yet changing it updates the address bar and the back button. A hash router listens for hashchange, matches the current hash against your route patterns, pulls out params like :id, and calls the right handler.
Implement hashRouter() with on, subscribe, start, navigate, and stop. See MDN: hashchange.
function hashRouter() {
return { on, subscribe, start, navigate, stop };
}
const router = hashRouter()
.on('/users/:id', (params) => render(User, params)) // params.id
.on('/', () => render(Home));
router.subscribe(({ path, matched }) => console.log(path, matched));
router.start(); // resolves the current hash now
router.navigate('/users/42'); // sets location.hash = '#/users/42' -> User handler
# — #/users/42 → /users/42; an empty hash means /.:name segments are params — /users/:id matched against /users/42 yields { id: '42' }; decode the values.{ path, params, matched }. stop() must remove the hashchange listener.We'll compile each route pattern to a regex once, then on every hash change find the first pattern that matches, extract its params, and fire the handler plus any subscribers.
The hash (#/users/42) is a client-only slice of the URL — editing it never reloads the page but does fire a hashchange event and update history. A router turns that string into an action: strip the #, compare /users/42 against patterns like /users/:id, and when one fits, hand the extracted { id: '42' } to its handler. The two sub-problems are matching with params and reacting to change.
Turn each pattern into a regex where :name becomes a capture group, and keep the list of names alongside it. To match, run the regex against the path; the capture groups line up with the names, so zipping them gives the params object.
The naive router compares whole strings, so it can't do params:
function routerNaive() {
const routes = {};
return {
on(path, fn) { routes[path] = fn; },
start() {
window.addEventListener('hashchange', () => {
const fn = routes[location.hash.slice(1)];
if (fn) fn();
});
},
};
}
routes['/users/:id'] only ever fires for the literal hash #/users/:id, never for #/users/42. String-keying can't express "a segment that varies". You need a per-route matcher that captures the variable parts, which means compiling to a regex.
function hashRouter() {
const routes = [];
const listeners = new Set();
let started = false;
function compile(pattern) {
const keys = [];
const source = pattern.replace(/\/:([^/]+)/g, (_, key) => {
keys.push(key);
return '/([^/]+)';
});
return { keys, regex: new RegExp('^' + source + '$') };
}
function currentPath() {
return window.location.hash.slice(1) || '/';
}
function resolve() {
const path = currentPath();
for (const route of routes) {
const match = route.regex.exec(path);
if (match) {
const params = {};
route.keys.forEach((k, i) => {
params[k] = decodeURIComponent(match[i + 1]);
});
route.handler(params, path);
emit({ path, params, matched: true });
return;
}
}
emit({ path, params: {}, matched: false }); // no route — still notify
}
function emit(change) {
for (const listener of [...listeners]) listener(change);
}
const api = {
on(pattern, handler) {
routes.push({ ...compile(pattern), handler });
return api; // chainable
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
start() {
if (!started) {
started = true;
window.addEventListener('hashchange', resolve);
}
resolve(); // resolve the initial hash immediately
return api;
},
navigate(path) {
window.location.hash = path; // fires hashchange in the browser
resolve(); // and resolve now (deduped browsers re-run harmlessly)
},
stop() {
started = false;
window.removeEventListener('hashchange', resolve);
},
};
return api;
}
module.exports = { hashRouter };
The core moves: compile turns :name into ([^/]+) and records the names; resolve runs each regex in order and, on the first hit, zips captures with names into params, calls the handler, and notifies subscribers. Registration order gives first-match-wins. start both attaches the listener and resolves the current hash so the initial view renders without waiting for a change.
on('/users/:id', h).start() with the hash empty, then navigate('/users/42'):
compile('/users/:id') → { regex: /^\/users\/([^/]+)$/, keys: ['id'] }.start() attaches hashchange and resolve()s the current path / — no route matches, subscribers get { path:'/', matched:false }.navigate('/users/42') sets location.hash = '/users/42' and calls resolve():
path = '/users/42'; the regex matches with capture ['42'].params = { id: '42' } (decoded); call h({ id:'42' }, '/users/42'); emit { path, params, matched:true }.start — if you only listen for hashchange, the very first render (page load with a hash already set) never fires. Resolve once immediately.#/search/a%20b should give { q: 'a b' }. Run each param through decodeURIComponent.stop() must removeEventListener with the same function reference used to add it, or the router keeps reacting after teardown.compile for * splats and :name? optionals, plus a notFound route.?a=1; parse it into a query object separate from path params.history-router swaps location.hash for pushState/popstate to get clean URLs without the #.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Single-page apps need to change the view without hitting the server. The oldest, most bulletproof way is the URL hash: everything after # is client-only — the browser never sends it and never reloads — yet changing it updates the address bar and the back button. A hash router listens for hashchange, matches the current hash against your route patterns, pulls out params like :id, and calls the right handler.
Implement hashRouter() with on, subscribe, start, navigate, and stop. See MDN: hashchange.
function hashRouter() {
return { on, subscribe, start, navigate, stop };
}
const router = hashRouter()
.on('/users/:id', (params) => render(User, params)) // params.id
.on('/', () => render(Home));
router.subscribe(({ path, matched }) => console.log(path, matched));
router.start(); // resolves the current hash now
router.navigate('/users/42'); // sets location.hash = '#/users/42' -> User handler
# — #/users/42 → /users/42; an empty hash means /.:name segments are params — /users/:id matched against /users/42 yields { id: '42' }; decode the values.{ path, params, matched }. stop() must remove the hashchange listener.We'll compile each route pattern to a regex once, then on every hash change find the first pattern that matches, extract its params, and fire the handler plus any subscribers.
The hash (#/users/42) is a client-only slice of the URL — editing it never reloads the page but does fire a hashchange event and update history. A router turns that string into an action: strip the #, compare /users/42 against patterns like /users/:id, and when one fits, hand the extracted { id: '42' } to its handler. The two sub-problems are matching with params and reacting to change.
Turn each pattern into a regex where :name becomes a capture group, and keep the list of names alongside it. To match, run the regex against the path; the capture groups line up with the names, so zipping them gives the params object.
The naive router compares whole strings, so it can't do params:
function routerNaive() {
const routes = {};
return {
on(path, fn) { routes[path] = fn; },
start() {
window.addEventListener('hashchange', () => {
const fn = routes[location.hash.slice(1)];
if (fn) fn();
});
},
};
}
routes['/users/:id'] only ever fires for the literal hash #/users/:id, never for #/users/42. String-keying can't express "a segment that varies". You need a per-route matcher that captures the variable parts, which means compiling to a regex.
function hashRouter() {
const routes = [];
const listeners = new Set();
let started = false;
function compile(pattern) {
const keys = [];
const source = pattern.replace(/\/:([^/]+)/g, (_, key) => {
keys.push(key);
return '/([^/]+)';
});
return { keys, regex: new RegExp('^' + source + '$') };
}
function currentPath() {
return window.location.hash.slice(1) || '/';
}
function resolve() {
const path = currentPath();
for (const route of routes) {
const match = route.regex.exec(path);
if (match) {
const params = {};
route.keys.forEach((k, i) => {
params[k] = decodeURIComponent(match[i + 1]);
});
route.handler(params, path);
emit({ path, params, matched: true });
return;
}
}
emit({ path, params: {}, matched: false }); // no route — still notify
}
function emit(change) {
for (const listener of [...listeners]) listener(change);
}
const api = {
on(pattern, handler) {
routes.push({ ...compile(pattern), handler });
return api; // chainable
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
start() {
if (!started) {
started = true;
window.addEventListener('hashchange', resolve);
}
resolve(); // resolve the initial hash immediately
return api;
},
navigate(path) {
window.location.hash = path; // fires hashchange in the browser
resolve(); // and resolve now (deduped browsers re-run harmlessly)
},
stop() {
started = false;
window.removeEventListener('hashchange', resolve);
},
};
return api;
}
module.exports = { hashRouter };
The core moves: compile turns :name into ([^/]+) and records the names; resolve runs each regex in order and, on the first hit, zips captures with names into params, calls the handler, and notifies subscribers. Registration order gives first-match-wins. start both attaches the listener and resolves the current hash so the initial view renders without waiting for a change.
on('/users/:id', h).start() with the hash empty, then navigate('/users/42'):
compile('/users/:id') → { regex: /^\/users\/([^/]+)$/, keys: ['id'] }.start() attaches hashchange and resolve()s the current path / — no route matches, subscribers get { path:'/', matched:false }.navigate('/users/42') sets location.hash = '/users/42' and calls resolve():
path = '/users/42'; the regex matches with capture ['42'].params = { id: '42' } (decoded); call h({ id:'42' }, '/users/42'); emit { path, params, matched:true }.start — if you only listen for hashchange, the very first render (page load with a hash already set) never fires. Resolve once immediately.#/search/a%20b should give { q: 'a b' }. Run each param through decodeURIComponent.stop() must removeEventListener with the same function reference used to add it, or the router keeps reacting after teardown.compile for * splats and :name? optionals, plus a notFound route.?a=1; parse it into a query object separate from path params.history-router swaps location.hash for pushState/popstate to get clean URLs without the #.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.