Skip to content
academia.sh

Lesson 03 / 22

Route Parameters and Query

Carrying state in the address; separating the path parameter from query state, reading and validating through a schema, omitting defaults from the address, canonical form, resetting dependent parameters, and the history entry decision.

Contents

The route tree delivered the state carried by the path to the components: which station, which section. The rest of the measurement history screen’s state, though, is not in the address yet. A date range might be selected, a measurement type filtered, the list on its second page, sorted descending by date.

When these live in the component’s local variables, the user cannot share that view, bookmark it, or return to the previous filter with the back button. Written into the address, though, the address grows long, gets cluttered, and every change becomes a candidate for pushing a new history entry. This lesson separates two questions: which state enters the address, and how the state that enters gets written.

Path, Query, or Memory

The History and Location lesson separated the address’s parts by the kind of state they carry: the path carries the resource’s identity, the query carries which view of that same resource is requested, and the fragment carries a position within the document. Routing turns this distinction into an actionable rule.

A path parameter selects a resource. The station ID belongs in the path, because when it changes, what is displayed is a different object. A path parameter has no default: the resource is either specified or it is not.

A query parameter adjusts the presentation of the same resource. Filters, sorting, the page number, and the display format belong here. Each one has a default, and the screen still makes sense when the user has selected none of them.

Memory, meanwhile, holds state that has no place in the address. The History and Location lesson supplied the test: does the user expect to see the same screen when this address is opened in a new tab? An open tooltip, a slider’s momentary value while being dragged, an animation’s progress all fail this test.

There is a borderline case: information meaningful only for this session but that needs to survive across navigation — the last row viewed in a list, for instance. This belongs in the history entry’s state object; it is not written to the address, but it comes back when the user navigates back.

The Path Parameter Is External Input

A path parameter is a field the user can write into. It can be typed into the address bar by hand, come from an old bookmark, or have been generated incorrectly in a link. The matcher having extracted it does not mean it is valid.

This holds on the security side too. A user who can open /station/north-slope can just as well open /station/east-ridge. The client validating the parameter catches format errors; only the server can validate authorization. This distinction is the subject of the next lesson.

Validation has three distinguishable outcomes. A malformed parameter — a letter where a number was expected — falls to the not-found view. A well-formed parameter with no matching resource is discovered through the server’s response and also produces not-found. A valid parameter is passed on to the view.

The Schema of Query State

The query string is a string map: every value is text, every name can appear more than once, and its content comes from outside. These three properties make it impossible to use the query directly as state. A schema sits between them: a definition that declares each parameter’s type, default, and valid value set.

// query-state.mjs — reading the query string through a schema and writing it in canonical form
const SCHEMA = {
  measurement: { type: "choice", default: "all", options: ["all", "temperature", "humidity", "wind"] },
  threshold: { type: "number", default: null },
  page: { type: "integer", default: 1, min: 1 },
  sort: { type: "choice", default: "descending", options: ["ascending", "descending"] },
  tag: { type: "multi", default: [] },
};

// Single validation point: both reading and writing pass through this.
function validate(rule, raw) {
  if (rule.type === "choice")
    return rule.options.includes(raw) ? { value: raw } : { invalid: true };
  const n = rule.type === "integer" ? Number.parseInt(raw, 10) : Number(raw);
  if (!Number.isFinite(n) || (rule.min !== undefined && n < rule.min)) return { invalid: true };
  return { value: n };
}

function read(query) {
  const p = new URLSearchParams(query);
  const state = {}, warnings = [];
  for (const [name, rule] of Object.entries(SCHEMA)) {
    if (rule.type === "multi") {
      const all = p.getAll(name);
      state[name] = all.length ? [...new Set(all)].sort() : rule.default;
      continue;
    }
    const raw = p.get(name);
    if (raw === null) { state[name] = rule.default; continue; }
    const result = validate(rule, raw);
    if (result.invalid) { state[name] = rule.default; warnings.push(`${name}=${raw} invalid`); }
    else state[name] = result.value;
  }
  return { state, warnings };
}

function write(state) {
  const p = new URLSearchParams();
  for (const [name, rule] of Object.entries(SCHEMA)) {     // schema order gives the canonical order
    const value = state[name];
    if (rule.type === "multi") {
      for (const d of [...new Set(value ?? [])].sort()) p.append(name, d);
      continue;
    }
    if (value === null || value === undefined || value === rule.default) continue;
    if (validate(rule, String(value)).invalid) continue;   // invalid value is not written to the address
    p.set(name, String(value));
  }
  const s = p.toString();
  return s ? `?${s}` : "";
}

console.log("-- reading --");
for (const query of [
  "",
  "?measurement=temperature&page=3",
  "?page=abc&measurement=snow&threshold=-3.5&tag=night&tag=snow&tag=night",
  "?page=0&sort=ascending&measurement=relative+humidity",
]) {
  const { state, warnings } = read(query);
  console.log((query || "(empty)").padEnd(66), JSON.stringify(state));
  if (warnings.length) console.log(" ".repeat(66), "warnings:", warnings.join("; "));
}

console.log("-- writing --");
for (const state of [
  { measurement: "all", threshold: null, page: 1, sort: "descending", tag: [] },
  { measurement: "temperature", threshold: -5, page: 1, sort: "descending", tag: [] },
  { measurement: "relative humidity", threshold: null, page: 4, sort: "ascending", tag: ["snow", "night"] },
]) console.log(JSON.stringify(state).padEnd(94), write(state) || "(empty)");

console.log("-- round trip --");
const initial = { measurement: "wind", threshold: 12.5, page: 2, sort: "ascending", tag: ["night"] };
const str = write(initial);
console.log("string  ", str);
console.log("same     ", JSON.stringify(read(str).state) === JSON.stringify(initial));
console.log("stable   ", write(read(str).state) === str);

console.log("-- on the address --");
const address = new URL("https://example.test/station/north-slope/measurements?page=9&measurement=humidity");
const updated = { ...read(address.search).state, measurement: "temperature", page: 1 };
console.log(address.pathname + write(updated));
-- reading --
(empty)                                                            {"measurement":"all","threshold":null,"page":1,"sort":"descending","tag":[]}
?measurement=temperature&page=3                                    {"measurement":"temperature","threshold":null,"page":3,"sort":"descending","tag":[]}
?page=abc&measurement=snow&threshold=-3.5&tag=night&tag=snow&tag=night {"measurement":"all","threshold":-3.5,"page":1,"sort":"descending","tag":["night","snow"]}
                                                                   warnings: measurement=snow invalid; page=abc invalid
?page=0&sort=ascending&measurement=relative+humidity               {"measurement":"all","threshold":null,"page":1,"sort":"ascending","tag":[]}
                                                                   warnings: measurement=relative humidity invalid; page=0 invalid
-- writing --
{"measurement":"all","threshold":null,"page":1,"sort":"descending","tag":[]}                   (empty)
{"measurement":"temperature","threshold":-5,"page":1,"sort":"descending","tag":[]}             ?measurement=temperature&threshold=-5
{"measurement":"relative humidity","threshold":null,"page":4,"sort":"ascending","tag":["snow","night"]} ?page=4&sort=ascending&tag=night&tag=snow
-- round trip --
string   ?measurement=wind&threshold=12.5&page=2&sort=ascending&tag=night
same      true
stable    true
-- on the address --
/station/north-slope/measurements?measurement=temperature

The reading side does not fail on any input. A missing parameter falls to its default, a malformed number falls to its default and produces a warning, an undefined option is rejected. This behavior is called robustness: an old address a user still has lying around opens a meaningful view instead of dropping the application into an error screen.

Two decisions show up on the writing side. A value equal to the default is not written to the address; in the first row, every field is a default, so the query is empty. This does more than keep the address short: the same screen is always referred to by a single address. An invalid value is not written either; in the third row, the undefined measurement type is dropped and the remaining fields are written.

Canonical Form

The same state having exactly one string representation is called canonical form. The writer in the example achieves this with two rules: parameters are written in schema order, and multi-valued sets are sorted within themselves. This is why the tag set snow, night shows up in the output in the order night, snow.

The round-trip section tests two properties. Reading is the inverse of writing: state that is written, when read back, produces the same object. Writing is stable: state that is read back, when written again, produces the same string. Together, the two say that the address is a reliable carrier of state.

Canonical form pays off in three places. A server state cache can use the address as its key; because the same view is never referred to by two different strings, no duplicate cache entries appear. Whether two addresses show the same screen can be determined by a plain string comparison. And addresses shared by different users end up identical.

Dependent Parameters

The final section is an example of a decision. With page nine and the humidity filter in the address, the measurement type is switched to temperature; the output carries no page number, because it was explicitly reset to one.

When a filter changes, the old page number loses its meaning: page nine might not exist under the new filter, and the user sees an empty list. The same dependency holds for a selected row’s ID, an infinite list’s loaded length, and a position within a sort. These resets do not happen on their own; which parameter invalidates which is written down explicitly.

The reverse direction matters just as much: an unrelated parameter is preserved. While the measurement type changes, the sort and the threshold must stay in place. Code that concatenates the query string by hand loses this protection; code that reads the state as a whole and writes over it keeps it.

Pushing a History Entry or Replacing One

Every change to query state changes the address; not every change, though, has to push a new entry onto the history. The distinction established in the History and Location lesson turns into a decision here.

Pushed: changes the user would want to return to with the back button. Moving between pages, changing the measurement type, reversing the sort.

Replaced: intermediate states and corrections the user does not expect to return to. A search field that pushes an entry for every character typed turns the back button into a mechanism that undoes typing letter by letter. Fields like this replace during typing; combined with the debounce from the Form Events lesson, the address updates once after typing stops.

The correction made at the application’s startup also uses replace: if the address holds an invalid value, it is pulled into canonical form, but this correction leaves no history entry. If it did, the back button would take the user back to the invalid address, and a loop would form.

Summary

  • A path parameter selects a resource and has no default; a query parameter adjusts the same resource’s presentation and each one has a default; the rest of the state stays in memory or in the history entry’s state object.
  • The path parameter is external input; format validation happens on the client, but authorization validation cannot be left to the client. Query state is likewise read through a schema: missing, malformed, and undefined values fall to their defaults instead of sending the application to an error screen.
  • Values equal to the default and invalid values are not written to the address; fixing the parameter order and the order of multi-valued sets produces canonical form.
  • Canonical form makes it possible to produce a cache key, compare two views, and keep shared addresses identical.
  • If one parameter invalidates another, the reset is written down explicitly; unrelated parameters are preserved.
  • Changes the user expects to return to are pushed onto the history; intermediate states and startup corrections replace the current entry.

Next Step

The address now carries, in full, which view of which resource is requested. The one thing it does not carry is whether the user has the right to see that resource. The station settings screen is open only to authorized users; measurement entry only to signed-in users. A path typed by hand into the address bar tries to open these screens too. The next lesson covers access conditions tied to a route, turning a condition into a decision, returning to the requested address after signing in, and why client-side enforcement is not a security boundary.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close