view agnostic design - greydragon888/real-router GitHub Wiki

View-Agnostic Design

Routing has nothing to do with UI. A router answers the question "where is the user right now?" — not "what should I render." Real Router makes this separation explicit: it translates a URL into data ({ name, params }), not into a component. Everything else — rendering, reactivity, component trees — is the view layer's job.

This page explains why Real Router is view-agnostic, what that means in practice, and why we believe the opposite approach is wrong.


The Inverted Question

The natural question is "why make the router universal?" But the real question is the opposite: why are all existing routing solutions NOT universal?

Think about it. A router:

  1. Receives a URL
  2. Matches it against a route tree
  3. Runs guards to decide if the transition is allowed
  4. Produces a new state — an immutable { name, params, path } object

None of these steps require React, Vue, Solid, Svelte, or any UI framework. None of them require a browser. The router doesn't know what a component is, what a hook is, or what a signal is. It works with data.

Yet most routers are tightly coupled to a specific framework. React Router can't be used without React. Vue Router can't be used without Vue. Not because routing requires these frameworks — but because the router was built inside the framework instead of beside it.

Real Router starts from the other end. The core is pure logic: route tree traversal, guard execution, state transitions. Framework integration is an optional layer on top — thin adapter packages that bridge router state to each framework's reactivity model.


How It Works

The architecture has three layers:

@real-router/core          — router engine, guards, plugins, FSM
        ↓
@real-router/sources       — framework-agnostic subscription layer (RouterSource<T>)
        ↓
@real-router/react         — React hooks via useSyncExternalStore
@real-router/preact        — Preact hooks via useSyncExternalStore polyphile
@real-router/solid         — Solid signals via createSignal
@real-router/vue           — Vue refs via shallowRef
@real-router/svelte        — Svelte reactive objects via $state

The key abstraction is RouterSource<T> — a minimal interface with three methods:

interface RouterSource<T> {
  subscribe(listener: () => void): () => void;
  getSnapshot(): T;
  destroy(): void;
}

Every major UI framework has a mechanism for subscribing to external stores. RouterSource<T> maps directly onto those mechanisms. Each adapter translates it into the framework's native reactive primitive — and that's all the adapter does.

The result: the core contains all routing logic, and each adapter is a thin translation layer. More than 90% of the code is shared; adapters are measured in hundreds of lines, not thousands.


What You Get

Cross-Platform

One config, every platform. The same route definitions, guards, and plugins work across web (React), mobile (React Native), desktop (Electron), and server (SSR). Only the set of plugins differs — browser-plugin for browsers, nothing for SSR, a future in-memory-plugin for environments with no URL API at all.

SSR without special modes. The core has zero browser dependencies — no window, no history, no DOM. Server-side rendering isn't a special mode to enable. You call start(url) with a string, and the router resolves it. Same guards, same plugins, same behavior. See Server-Side Rendering.

Any platform API. Browser integration is a plugin, not a core concern. There are plugins for History API, History API with hash (legacy browser support), Navigation API (for WebView or Chrome-specific apps). Because platform concerns are isolated in plugins, adding a new one doesn't touch the core.

Performance

Engine-level optimization. Because the core has no framework or browser dependencies, it can be optimized at the JavaScript engine level — not at the level of a UI library's abstraction. The hot path uses optimistic sync execution, pre-allocated error rejections, explicit emit parameters (no rest-param array allocation), and single-pass object freezing.

Extensibility

Plugins for any logic. Write plugins that hook into the router lifecycle — logging, analytics, scroll restoration, data loading — and they work with every framework automatically. A plugin written for a React app works unchanged in a Vue app.

Interceptors and custom methods. Plugins can wrap core methods via interceptors (start, buildPath, forwardState) and register new methods via extendRouter(). This is how browser-plugin makes the path argument in start() optional — it intercepts the method and injects window.location.

Modular by design. You assemble exactly the functionality you need, with no overhead for what you don't use. The core is the only required package. Everything else — adapters, browser integration, logging — is optional. Compare this with routers that have evolved into meta-frameworks, bundling concerns you may never need.

Runtime route management. Full CRUD for routes at runtime — add, remove, update, replace, clear. Hot module replacement and feature flags work the same way in every framework: call replace() with the new route set, and the router revalidates state atomically.

Developer Experience

Fast adoption of framework features. When a framework ships a new capability, integrating it into the adapter is straightforward because the adapter is small and focused. For example, integrating React's Activity API took 2 hours. No React-specific router supports it yet.

Testable in isolation. A router instance contains all logic and state. You can create one, run navigations, check state — all without rendering, without JSDOM, without a browser:

const router = createRouter(routes);
await router.start("/users/42");

expect(router.getState().name).toBe("users.profile");
expect(router.getState().params.id).toBe("42");

Comparison

Aspect Framework-specific routers Real Router
Core dependency Tied to one framework (React Context, Vue reactivity, etc.) Zero framework dependencies
URL → ? URL → Component URL → Data ({ name, params })
SSR Requires special mode, mock browser, or framework-specific adapter Works naturally — start(url) with a string
Plugin reuse Plugins work with one framework only Plugins work with all frameworks
Testing Requires rendering or framework test utilities Plain function calls, no rendering needed
Migration Rewrite routing on framework switch Keep routes, guards, plugins — swap the thin adapter
Browser integration Baked into core Optional plugin

Why framework-specific routers exist

It's not that other teams made a mistake. Framework-specific routers evolved from a different mental model: URL → Component. The router decides what to render. This is convenient when you have one framework and want tight integration.

The cost appears later. Routing logic leaks into components (useParams() + useEffect() + fetch() chains). Testing requires rendering. SSR requires escape hatches. Migration means rewriting everything. And the router itself becomes harder to optimize — because it's tangled with the framework's rendering cycle.

Real Router pays the design cost upfront: a clean separation between routing (data) and rendering (view). The result is a router that is simpler to reason about, simpler to test, and works everywhere.

The compensation tax of URL → Component

When the router owns rendering, it must solve a cascade of problems that don't exist in URL → Data. Each solution adds runtime code, memory overhead, and API surface — a "compensation tax."

Consider how TanStack Router (the most complete URL → Component implementation) addresses these:

Problem: how to pass data from the router to a component? The router decides what to render, but the component needs params, search state, loader data, and context. The router can't pass props (it doesn't control the JSX). Solution: a per-route mini-store with four separate slots (params, search, loaderData, context) and four hooks to subscribe to them (useParams, useSearch, useLoaderData, useRouteContext).

Problem: how to avoid unnecessary re-renders with four data slots? Each slot changes independently. A component that reads params shouldn't re-render when search changes. Solution: each hook accepts a select function for granular subscriptions, plus structural comparison to stabilize object references.

Problem: how to inject dependencies into loaders (outside the React tree)? Loaders run before rendering — they have no access to React Context. Solution: Route Context — a two-level DI system where createRouter({ context }) seeds global services and beforeLoad() adds per-route computed values, inherited by children.

Problem: how to notify a component that params changed but the route is the same? The router decided the component stays mounted, but the data changed. Solution: an onStay lifecycle hook — a callback that fires when the route name is unchanged but params differ.

In Real Router, none of these problems exist:

  • Data passing? The developer controls rendering via <RouteView.Match> — pass props, use context, use any store.
  • Granular subscriptions? One useRouteNode(name) returns { route, previousRoute }. The developer decides what to do.
  • Dependency injection? Components are in the React tree — useContext works. For non-React code, the router has a built-in DI container.
  • Param-change detection? route.name === previousRoute?.name — one line of JavaScript.

An architecture that doesn't create problems doesn't need solutions.


See Also