# Minimal React Router

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

Source: https://www.harxit.com/experiments/minimal-react-router

- Author: [Harshit Sharma](https://www.harxit.com/about)
- Published: April 1, 2025
- Technologies: TypeScript, React
- Repository: [GitHub](https://github.com/C-W-D-Harshit/Minimal-React-Router)

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.

```tsx
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.

```tsx
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

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

```tsx
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.
