Custom Route Atoms
staticRouteAtom and paramRouteAtom cover most real routes, but both are themselves built
from a smaller primitive: routeAtom. Reach for it directly when a segment's syntax doesn't fit
either - a two-letter locale code, a regex-constrained slug, anything matchPath/makePath
can express that a bare string or bare variable can't.
import { routeAtom, staticRouteAtom } from "jarl-atoms";
export const shopRoute = staticRouteAtom("shop");
export const localeRoute = routeAtom<{ locale: string }>(
(path) => (/^[a-z]{2}$/.test(path) ? { locale: path } : undefined),
({ locale }) => locale,
{ parent: shopRoute },
);matchPath receives the next unconsumed path segment and either returns the values it binds, or
undefined for "this atom doesn't match here". makePath is its inverse, used by reverse() and
writes. Both also receive jotai's get, so a match can depend on other atoms - a feature flag, a
locale list fetched at startup.
Matched values are merged with the parent's, so localeRoute reads back everything shopRoute
bound as well as its own locale. That merge is what lets a leaf route be linked to with one
to object covering the whole chain.
Reshaping values with transformRouteAtom
transformRouteAtom doesn't match path segments itself - it wraps another route atom and
reshapes its values, both for reading and for reverse()/write:
import { paramRouteAtom, transformRouteAtom } from "jarl-atoms";
const idParam = paramRouteAtom("id");
export const numericIdRoute = transformRouteAtom<{ id: string }, { id: number }>(
idParam,
(values) => (isNaN(Number(values.id)) ? undefined : { id: Number(values.id) }),
(values) => ({ id: String(values.id) }),
);getter only runs once the wrapped atom matches, and returning undefined from it rejects the
match entirely - the mechanism a constrained segment uses to say "matched syntactically, but not
semantically". setter is the inverse: it must produce values the wrapped atom itself accepts,
since reverse/write pass straight through to it. Get this wrong (an id that doesn't round-trip
through both directions) and reverse() will build a URL a real navigation won't match.
Nothing about it is path-specific, so it composes over query params just as happily. The
data grid demo parses a ?sort=-price query value into
{ key: "price", direction: "desc" } on the way down and serialises it back on the way up, so
the components below it never see the raw string.
numericRouteAtom
The numeric case above is common enough to ship pre-built: numericRouteAtom is exactly a
paramRouteAtom plus a transformRouteAtom that only matches all-digit segments, converts them
to a number, and can reject values outside an inclusive min/max range:
import { staticRouteAtom, numericRouteAtom } from "jarl-atoms";
export const blogRoute = staticRouteAtom("blog");
export const yearRoute = numericRouteAtom("year", { parent: blogRoute });
export const monthRoute = numericRouteAtom("month", { parent: yearRoute, min: 1, max: 12 });/blog/2024/13 doesn't match monthRoute at all (13 is outside max), rather than matching
with an invalid month - so a bad month never reaches your component as data to validate. Reading
a matched route hands back { year: 2024 } as a real number, not a string you'd otherwise have
to parse yourself.
Constraints spanning several segments: validateRouteAtom
A segment's own options only bound it in isolation. /blog/2024/02/31 passes every one of them -
31 is all digits, and no max on a day segment can know which month it landed in. validateRouteAtom
narrows an existing route to the values a predicate accepts, and it sees the whole chain's values,
not just the last segment's:
import { numericRouteAtom, validateRouteAtom } from "jarl-atoms";
const daySegment = numericRouteAtom("day", { parent: monthRoute });
export const dayRoute = validateRouteAtom(daySegment, ({ year, month, day }) => isValidCalendarDate(year, month, day));31 February now simply doesn't match, and falls through to whatever handles a non-matching URL -
a Switch fallback, or the 404 that notAtom reports (see
Switch & Not Found). The alternative - matching, then checking the
date inside the page component - makes every consumer of that route responsible for a rule the
route itself should own. The blog routing demo does exactly this.
validateRouteAtom is a transformRouteAtom with an identity setter and a predicate for a getter,
so it changes a route's values not at all - only whether it matches. Its predicate takes a Getter
too, for a constraint that depends on other atoms rather than only on the values in the URL.
For a lookup that has to go somewhere asynchronous before it can answer "does this exist", the
predicate shape isn't enough - see asyncRouteAtom in the Data Loading
guide.