[JavaScript Series 16] Routing and Lightweight Navigation

한국어 버전

Routing is the job of reading the URL and deciding which view to render. In this lesson we implement both a hash router and a History API router with nothing but JavaScript. Start with the core hash router, then treat History API and navigation guards as optional extensions once the basics feel solid.

Key terms

  1. Route: An object that pairs a URL with the component that should render for it.
  2. Hash router: A simple router that reads the hash fragment such as #home to swap views.
  3. History API: Browser APIs like pushState, replaceState, and popstate that let you control the address bar.
  4. Navigation guard: A function that runs before entering a route to allow or block access.
  5. popstate event: Fires when users press Back/Forward so you can re-render the current URL.

Core ideas

  • Route (URL-to-view mapping): An object that binds a URL to a component or render function.
  • Hash router (#-based navigation): Switches views using the value after #. Requires no server config, great for static hosting.
  • History API (address bar control): Uses pushState and the popstate event to change URLs without reloading, keeping clean paths.
  • Navigation guard (entry condition): Runs checks such as authentication or unsaved changes before letting the user in.

Visualizing the routing flow

nav[User click]
router[Router]
guard[Navigation guard]
view[Component renderer]
history[History/hash state]

nav -> router: "Link click"
router -> guard: "Guard check"
guard -> router: "Allow or block"
router -> history: "pushState/hash"
router -> view: "renderRoute()"
history -> router: "popstate/hashchange"

Read the diagram as “user clicks → router inspects the URL → ask the guard if needed → update the URL and render → use popstate/hashchange to render again when going back.” Every router follows this order.

Building the router: code examples

Start with the hash router that swaps the view based on the hash fragment. Before writing real code, quickly see when location.hash changes.

window.addEventListener("hashchange", () => console.log(location.hash));
location.hash = "#tasks";

➡️ Remember that changing the hash triggers an event without a full reload, and the implementation below will feel natural.

const routes = {
  home: () => `<section><h2>Home</h2><p>Welcome!</p></section>`,
  tasks: () => `<section><h2>Tasks</h2><div id="tasks-root"></div></section>`,
  stats: () => `<section><h2>Stats</h2><div id="stats-root"></div></section>`,
};

const outlet = document.querySelector("#app");
if (!outlet) {
  throw new Error("Cannot find the #app container.");
}

function renderRoute(name) {
  const view = routes[name];
  outlet.innerHTML = view ? view() : `<p>That page does not exist.</p>`;
}

function handleHashChange() {
  const hash = location.hash.replace("#", "") || "home";
  renderRoute(hash);
}

window.addEventListener("hashchange", handleHashChange);
handleHashChange();

Hash routers take only a few lines. Each time the URL changes, hashchange fires. In other words, “a route equals a hash string plus a template function.”

Next up is the History API router, which keeps the address bar in sync without reloading the entire page.

const historyRoutes = [
  { path: "/", render: () => `<h2>Home</h2>` },
  { path: "/tasks", render: () => `<h2>Tasks</h2>` },
  {
    path: "/settings",
    guard: () => confirm("Move to the settings page?"),
    render: () => `<h2>Settings</h2>`,
  },
];

function matchRoute(pathname) {
  return historyRoutes.find((route) => route.path === pathname);
}

function navigate(pathname) {
  const route = matchRoute(pathname);
  if (!route) {
    outlet.innerHTML = `<p>404 Not Found</p>`;
    return;
  }
  if (route.guard && !route.guard()) return;
  history.pushState({}, "", pathname);
  outlet.innerHTML = route.render();
}

window.addEventListener("popstate", () => {
  const route = matchRoute(location.pathname);
  outlet.innerHTML = route ? route.render() : `<p>404 Not Found</p>`;
});

document.querySelectorAll("[data-link]").forEach((link) => {
  link.addEventListener("click", (event) => {
    event.preventDefault();
    const target = event.currentTarget;
    if (!(target instanceof HTMLAnchorElement)) return;
    navigate(target.getAttribute("href") ?? "/");
  });
});

The History API router updates the address bar without a full refresh. The popstate event fires when the user presses Back/Forward.

Add loading feedback to show progress between route changes.

const loadingBar = document.querySelector(".router-progress");

async function navigateWithLoading(pathname) {
  loadingBar?.classList.add("is-active");
  await navigate(pathname);
  loadingBar?.classList.remove("is-active");
}

Loading indicators help users stay patient during transitions. Treat this as an optional enhancement once the base navigation is stable.

Why it matters

  • Thoughtful URLs make bookmarking, social sharing, and search indexing easier.
  • Framework routers abstract hash or History routers; understanding the baseline helps you adapt quickly elsewhere.
  • Writing your own navigation guards unlocks login flows, gated dashboards, and similar access control features.

Practice

  • Core: Implement a hash router and connect #home, #tasks, and #stats views.
  • Extension: Build the History API version and bind navigation links via the data-link attribute.
  • Debugging: Ensure invalid paths render a 404 view and log when popstate fires.
  • Done: Both routers switch at least three views correctly, and guards block navigation based on your condition.

Wrap-up

Routing knowledge lets you design page structures freely. Next time we revisit performance fundamentals so even large screens stay fast through measurement and tuning.

💬 댓글

이 글에 대한 의견을 남겨주세요