v1 History: architecture, design, and why v2 moves to atoms
This page is a design retrospective on JARL v1 (jarl-react's original implementation,
long since removed from packages/jarl-react) - distinct from the
Changelog, which just lists version-by-version release notes. This is
about how v1 works and why, and why v2 replaces its internals with
jotai atoms, split across packages/jarl-atoms (the atoms
themselves) and packages/jarl-react (React bindings over them).
The core idea: locations are state, not URLs
Everything else in v1 follows from one decision made at the very start: a route table
maps a URL to a plain, serializable location object ({ page: "product", productId: "123" }), and the rest of the app only ever deals with that object - never a URL
string. Link builds hrefs from the same location objects in reverse. This is what
makes the same route table usable for web (history's createBrowserHistory) and
React Native (createMemoryHistory plus deep-link handling) with no branching in
application code, and it's a decision v2 keeps.
Everything below - context, the HOC, the render-prop Link, resolve/redirect - is
about how that location object gets from a URL, through matching, into your
components, and back out again as an href. That plumbing is what v2 rebuilds.
Legacy React context
RoutingProvider (packages/jarl-react/source/RoutingProvider.js) is a class
component using React's legacy context API (childContextTypes / contextTypes,
predating React.createContext, which shipped in 16.3 partway through JARL's life).
It exposes a single routing object down the tree:
getChildContext() {
return {
routing: {
navigate: this.handleNavigate,
redirect: this.handleRedirect,
stringify: this.handleStringify,
getLocation: this.handleGetLocation,
getResolved: this.handleGetResolved,
isActive: this.handleIsActive
}
};
}
Every consumer (Link, the routing HOC, Router) declares static contextTypes = { routing: routingContextShape } and reads this.context.routing. This works, but it
has the well-known legacy-context downsides: it's a single implicit channel per
provider (no way to select just a slice and skip re-renders), it only works in class
components (no hook equivalent), and React has deprecated it in favour of the new
Context API since before JARL v1 was finished - v1 never migrated, partly because the
whole component tree re-renders on every navigation anyway (see below), which made the
selective-subscription benefits of new Context moot at the time.
RoutingProvider: a controlled component that matches, resolves, and force-updates
RoutingProvider is deliberately a controlled component, mirroring a controlled
<input>: it never owns the "real" location as internal state that only it can see.
Instead:
- It listens to the
historypackage instance it's given (history.listen), and on every change (browser back/forward, or anavigate/redirectcall) it matches the new path against the route table (RouteMap.match). - If any matched route leaf declares a
resolvefunction, those are reduced into a single serialPromisechain (each resolver runs after the previous one resolves, accumulating into oneresolvedobject) - this is JARL's data-loading mechanism, letting a route say "before you render me, fetch this". - If matching (or a resolver) produces a
Redirect(see below), the provider callshistory.replaceinstead of completing the navigation, so the invalid/interim URL never lands in browser history. - Once matching and all resolvers settle,
completeRoutingcallsthis.props.onChange(handing the new{ location, resolved }up to your app state - a plainsetState, Redux dispatch, whatever you want) and then callsthis.forceUpdate().
That last step is the important, slightly awkward one: RoutingProvider doesn't store
location/resolved in its own React state - they're props, fed back in by whatever
owns onChange. So after a navigation it has no state of its own that changed, and the
only way to get consumers deeper in the tree (which read getLocation()/getResolved()
via context, not props) to see the update is an explicit forceUpdate(). It's a
pragmatic solution given legacy context has no subscription model, but it means every
navigation re-renders the entire subtree under the provider, matching happens
synchronously against the whole route table on every path change, and "is a link active"
(isActive) has to re-run matching for both the link's target and the current
location on every render of every Link to compare branches - there's a TODO: PERF
comment in the source about exactly this.
routing(): a HOC over context
const routing = (mapLocationToProps, mapRoutingToProps, mapResolvedToProps) =>
hocFactory(
(WrappedComponent) =>
class Routing extends Component {
static contextTypes = { routing: routingContextShape };
render() {
const { isActive, navigate, stringify, redirect, getLocation, getResolved } = this.context.routing;
const location = mapLocationToProps ? mapLocationToProps(getLocation()) : getLocation();
const callbacks = mapRoutingToProps
? mapRoutingToProps({ isActive, navigate, stringify, redirect }, this.props)
: {};
const resolved = mapResolvedToProps ? mapResolvedToProps(getResolved()) : getResolved();
return <WrappedComponent {...this.props} {...location} {...resolved} {...callbacks} />;
}
},
);
This is a very Redux-connect-shaped API (deliberately - the README credits
redux-first-router as inspiration), and it's the idiomatic way v1 expects you to
consume routing: wrap your top-level App, and switch on the injected page prop -
the README is explicit that there's no <Switch> component, just "use a switch
statement instead". <Router> (Router.js) is the same idea via the function-as-child
pattern instead of a HOC, for spots where introducing a new named component is
overkill.
Link: render props, active-state comparison
Link (Link.js) supports both a plain element API and a function-as-child
("render prop") API:
<Link to={{ page: "search", search: text }}>
{({ href, onClick, active }) => <button onClick={onClick}>Search</button>}
</Link>
which exists so you can build a fully custom clickable element (e.g. a button that
programmatically navigates, as in the JARL README's search-form example) without
duplicating Link's "convert a location into an href and a navigate handler" logic.
Clicking a plain <Link> calls event.preventDefault() and then
context.routing.navigate(to) - there is no actual anchor-tag navigation, href exists
only so the link is a real, right-clickable/Cmd-clickable anchor and works without JS.
resolve/redirect semantics
A route can declare resolve: (location, context) => Promise, run serially down the
matched branch (see above). Two outcomes beyond a plain resolved value matter:
- Returning (or the promise resolving to) a
Redirectinstance breaks the promise chain immediately (Promise.reject(result)) and the provider issues ahistory.replaceto the redirect target - used for auth gates, canonical-URL redirects, etc., without ever completing navigation to the original URL. - An unhandled rejection falls through to
props.onErrorif provided, or otherwise just logs awarning- v1 expects you to handle failure cases with a redirect (e.g. to an error page) rather than letting a resolver throw uncaught.
redirect(to) differs from navigate(to) only in using history.replace instead of
history.push, so a redirect doesn't leave the bad/interim URL behind in the back
button history.
Why v2 moves to atoms
None of the above is wrong - JARL v1 has been stable in production for years - but
several of its rough edges trace back to a single root cause: legacy context plus
forceUpdate is not a real subscription model. Every navigation re-renders
everything under the provider, isActive re-derives both branches from scratch on
every render of every Link, and there's no way for a deeply nested component to
subscribe to just the one piece of location state it cares about.
jotai atoms solve exactly that problem: each atom is an
independent, subscribable unit of state, and React components that read an atom (via
useAtomValue) only re-render when that atom's derived value actually changes - not
on every navigation regardless of relevance. The v2 atoms
(packages/jarl-atoms/src/routeAtom.ts) reframe the whole router around this:
- A single
locationAtom, backed byjotai-locationin the browser (realhistory.pushState/replaceState, responding topopstate) and by a per-store override under Node - so a location can be seeded server-side for prerendering, which is exactly what lets this docs site itself be statically generated. routeAtom(matchPath, makePath, { parent })derives a route atom from a parent route atom (defaulting to arootAtom), matching one path segment at a time and carrying arest.pathof unconsumed segments down to child route atoms - so nested routes compose as a chain of atoms instead of a nested-array route table walked by a singleRouteMap.matchcall.- Each route atom is writable: reading it (
get) gives{ match, exact, values, reverse }; writing it (set) navigates by computing the target path viareverseand updatinglocationAtom- soRouteMap.stringifyandhistory.pushcollapse into one atom write, instead of thestringify+navigatepair of context callbacks v1 exposes. staticRouteAtom/paramRouteAtomare justrouteAtomwith a cannedmatchPath/makePath, andtransformRouteAtomlets one route atom's matched values be reshaped into another shape - composable building blocks instead of one big JSON route table.- The
LinkandRoutecomponents (packages/jarl-react/src/Link.tsx,Route.tsx) read/write a specific route atom directly viauseAtom/useAtomValue, so aLink's active-state and aRoute's match check only re-render when that atom's value changes, not on every navigation everywhere in the tree - the performance TODOs scattered throughRoutingProvider.jsandLink.jsare the direct motivation.
This docs site's own top-level navigation (see the live routing demo) and its
Link/Route implementation are built directly on the real jarl-atoms/jarl-react
packages described above (see packages/docs/src/router/routes.ts) - dogfooding the
same API, not just linking to it.
The location-objects-not-URLs philosophy, the controlled-component posture, and the
resolve/redirect data-loading model all carry forward conceptually into v2; what's
being replaced is specifically the plumbing connecting a URL change to a re-render.