Ramonda

Reading the URL

To read the current URL inside a component — the id in /players/9, a ?query, a #hash — use Navigator:

import { createRoutes, createRouter } from "@ramonda/router";

const routes = createRoutes({ "/players/:id": <Profile /> });
export const { Navigator } = createRouter(routes);

export class Player extends Component {
  route = this.use(Navigator);

  render() {
    const { id } = this.route.params("/players/:id");
    return <article>Player {id}</article>;
  }
}

The table is in the example on purpose: the pattern you name is checked against it, so the two cannot drift. In a real app the kit is minted once and imported — import { Navigator } from "./routes" — and the check comes with it.

Name the pattern and the type comes out of it. id is a string with nothing annotated, and the pattern is checked against the table your kit was built from — a route that table does not declare is a type error, and so is a static path, because it has no params to read. That is the same machinery route("/players/:id", { id }) has always used for building an href, pointed the other way.

What it gives you:

pathname/players/9
params(pattern)the :params of that pattern, typed from it
params<T>()the same values, typed by what you assert — the untyped door
searchParams?a=1&b=2{ a: "1", b: "2" }
hashTagsthe #… segments

You react to the part you read

pathname and searchParams are tracked separately: a component reading only pathname doesn't re-render when a query parameter changes, and vice versa. You get that for free — and it's why a plain <Link> (which reads none of it) doesn't re-render on every navigation.

The pattern is checked, not trusted

A named pattern is a claim about which route the component is standing on, and a claim nothing verifies is worth nothing: if the outlet above matched a route with no :id, params("/players/:id").id would be undefined where the type says string, and it would travel. So every :name in the pattern has to be present in what the outlet matched, and it throws otherwise — naming both the pattern you asked for and the route you are actually on. It is the mirror of route("/u/:id", {}), which has always refused to build /u/undefined.

It is not an equality check on the route key. A component rendered by both /players/:id and /users/:id names one of them and is correct on both, because what it asked for is satisfied on both — the claim is about the params, not the spelling. When two routes genuinely disagree about their params, that is what params<T>() is for.

Does naming the route make the component less reusable? It names a coupling that was already there: params<{ id: string }>() needs a route supplying id just as much, and that is now said out loud too — see below. And a component that is genuinely reusable should not be reading the URL at all — have the page read the param and pass it down, <UserAvatar userId={id} />, for the same reason a value one child needs is a prop rather than a context. If a component reads params(), it is part of a route; the pattern only writes that down.

params needs an outlet

params() comes from the <RouteOutlet> that matched, so it only means something inside the routed page. A nav bar beside the outlet has pathname but no params — which is right, it isn't part of any route.

Reading pathname there is correct, and reading params(pattern) there throws. ramonda-check says it first, and it says the other half too — naming a route that is not the one you are on:

[ramonda-check] 2 params(pattern) read(s) the routing cannot answer:

  src/App.tsx:264:29
    <Layout> reads `route.params("/users/:id")`, and no arrangement in this build
    puts it under a <RouteOutlet>.

  src/UserPanel.tsx:12:19
    <UserPanel> reads `nav.params("/guide/:slug")`, but the route that mounts it
    is "/users/:id", which supplies no `:slug`.

The second one is the case nothing else can reach. <UserPanel> is a child of a routed page, so it is under an outlet — the fault is that the outlet above it matched a different route. The checker knows because it carries the table's key down with the view, and the key is the only place a route's params are written.

A component under two routes that both supply what it asked for is silent: /users/:id and /people/:id both give :id, and the claim is about the params, not the spelling. But one arrangement that fails is enough — a component rendered inside a routed page and beside the outlet is reported, because the second place has no matched route and the read throws there.

The types cannot answer either of these. params("/users/:id") is checked against the paths your table declares, never against the route the component is standing on.

params() is judged by the keys it names

The argument-less door claims a route too, the moment it names a key. params<{ teamId: string }>() says teamId is always there; so does const { teamId } = params(), and so does params().teamId. Under a route that supplies no :teamId, all three read undefined where the type says string — and unlike the pattern door, this one does not throw. Nothing tells you at all:

[ramonda-check] 1 params() read(s) claiming a key the routing does not supply:

  src/TeamHeader.tsx:14:19
    App > RouteOutlet > TeamPage > TeamHeader
    <TeamHeader> reads `nav.params()` for `userId`, but the route that mounts it is
    "/teams/:teamId", which supplies no `userId`. The read gives `undefined` where the
    type says `string`, without throwing. Name the pattern instead, or mark the key
    optional if this component really is rendered by routes that disagree.

Say it may be absent and nothing is reported, because nothing was claimed:

import { Component } from "@ramonda/core";
import { createRoutes, createRouter } from "@ramonda/router";

const routes = createRoutes({ "/players/:id": <Profile />, "/teams/:teamId": <Team /> });
export const { Navigator } = createRouter(routes);

// Rendered by both, and they disagree about their params — the case params<T>() is for.
export class OwnerChip extends Component {
  route = this.use(Navigator);

  render() {
    const p = this.route.params<{ id?: string; teamId?: string }>();
    return <b>{p.id ?? p.teamId}</b>;
  }
}

A default does the same job — const { teamId = "" } = params() — and so does reading off a variable one line later, which stays unjudged on purpose. Asking whether a key is there is not claiming that it is, so params().hasOwnProperty("teamId") is fine. Two things are deliberately not answered: a type argument that is a name rather than a written-out shape, and a key built from an expression.

What it cannot see. A route table whose keys are computed — table[page.path] = … in a loop — names its paths at runtime, so the checker knows the views are routed but not under what; it reports neither. A pattern in a let, or one built by concatenation, is the same. And a navigator handed over as a prop rather than taken with this.use(Navigator) is not recognised. A pattern or a table key held in a const is followed, so extracting your routes into constants costs you nothing.

Params are always strings

They come out of a URL, so they are strings. Parse them where you use them, and treat a missing or malformed one as a real possibility — a URL is user input.

Keep UI state in the URL

A tab, a filter, a search query — anything you'd reach for @state to hold can instead live in the URL. Then it survives a reload, it's shareable as a link, and Back undoes it, all for free. Read it with searchParams, and change it with updateSearchParams:

import { Navigator } from "./routes";

export class Filters extends Component {
  route = this.use(Navigator);

  setColor(color: string) {
    this.route.updateSearchParams((prev) => ({ ...prev, color }));
  }
  setSize(size: string) {
    this.route.updateSearchParams((prev) => ({ ...prev, size }));
  }
}

Three things make this the right tool for fast-changing state like a filter panel:

  • It touches only the query. pathname doesn't change, so the route never re-matches — no page swap, cheap on every keystroke. (Routes match on the path only; the query is never part of a route pattern.)
  • It stays put. No scroll to the top, so filtering a long table doesn't yank you away from it. Pass { scroll: true } on the rare time you want the jump.
  • It's race-free. Give it a function and it receives the freshest params, so two filters changed in the same instant don't clobber each other — the second reads the first's write. This is why the functional form matters: setColor and setSize firing together both land, instead of one silently dropping the other.

Pass a plain object instead of a function to replace the query outright. And by default each change is a new history entry, so Back steps through them; pass { replace: true } (e.g. while someone is typing) to avoid filling the history.

The same updateHashTags exists for the hash, with the same options.

Next