Minimal React Router

A zero-dependency client-side router built with React Context and the History API. ~100 lines of code.

TypeScriptReact

I wanted to understand how client-side routing actually works. What happens when you click a link and the page changes without a reload? How does React Router know which component to render?

So I built one. Three files, ~100 lines, zero dependencies.

The Router

The entire routing system is a React context that holds the current path and a navigate function. The popstate event listener handles browser back/forward.

export function Router({ children }: RouterProps) {
  const [currentPath, setCurrentPath] = useState(
    window.location.pathname
  );
 
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };
    window.addEventListener("popstate", handlePopState);
    return () => window.removeEventListener("popstate", handlePopState);
  }, []);
 
  const navigate = (path: string) => {
    window.history.pushState({}, "", path);
    setCurrentPath(path);
  };
 
  return (
    <RouterContext.Provider value={{ currentPath, navigate }}>
      {children}
    </RouterContext.Provider>
  );
}

That's it. pushState changes the URL without a page reload, and setCurrentPath triggers a re-render so the right components show up.

Route matching

The Route component consumes the context and checks if the current path matches. It supports exact matching and prefix matching.

export function Route({ path, component: Component, exact = false }: RouteProps) {
  const { currentPath } = useRouter();
 
  const isMatch = exact
    ? currentPath === path
    : currentPath.startsWith(path);
 
  return isMatch ? <Component /> : null;
}

Prefix matching means /about also matches /about/team. Exact matching is opt-in for routes like / where you don't want everything to match.

Wildcard routes (path="*") act as catch-all for 404 pages.

The Link component prevents the default browser navigation and uses the router's navigate function instead.

export function Link({ to, children, ...props }: LinkProps) {
  const { navigate } = useRouter();
 
  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault();
    navigate(to);
  };
 
  return (
    <a href={to} onClick={handleClick} {...props}>
      {children}
    </a>
  );
}

It still renders a real <a> tag with a real href. This means right-click "open in new tab" still works, and crawlers can still follow the links.

What I learned

  • Client-side routing is just preventing the default navigation, updating the URL with pushState, and re-rendering based on the new path.
  • The popstate event only fires on back/forward, not on pushState calls. That is why you need to manually update state when navigating programmatically.
  • Rendering a real anchor tag with href is important for accessibility and SEO. A button that changes the URL would break right-click behavior.
  • Route matching in its simplest form is just a string comparison. React Router's path matching is more complex because it handles dynamic segments, nested routes, and ranking.

Three files to understand the core of client-side routing.