Module 7: Url As State

Mini-project: Mercado's search in the URL

Overview

The moment has come to bring the entire module together in one piece applied to Mercado. In this mini-project you put all of the storefront's search in the URL: the term (query), the category (category), the sort (sort) and the page (page) stop living in useState and move to the searchParams, with the URL as the source of truth. The deliverable is triple: the state-in-the-URL module —a validated serialize/parse for the four pieces—, the real React code that integrates it with the router (useSearchParams), and the logic executed in Node that verifies the four properties: exact round-trip, sharing (two sessions derive the same view with getVisibleProducts), back/forward over the history stack, and validation of broken URLs —closed with a scorecard against useState—. It's not a toy demo: it's Mercado's URL box, ready to use, with each claim measured in a real run.

Connection with the module. It's the capstone of the URL box. It gathers the seven lessons: the URL as source (L2), the serialize/parse (L3), the three virtues (L4), what goes in the URL (L5), the validated parse (L6) and the integration with the router (L7). And it closes the boundary: here you build and verify Mercado's state-in-the-URL with URLSearchParams; the in-depth integration with Next's router —Server Components, the server model— is the nextjs guide. With this you finish module 7, and the URL box is closed: together with the cart (store, M3), the theme (Context, M2) and the products (React Query, M4-M6), Mercado has each piece of state in its correct box.

The plan: what we're going to build and verify

The project has three parts, in order:

  1. The module. A serialize/parse for the search state { query, category, sort, page }, with validation (whitelist of sort and category, page integer ≥ 1, query bounded) and omitted defaults.
  2. The verification. Four runs in Node: the exact round-trip of the complete state; the share demo (two sessions, same view); back/forward over the stack; and the validation of broken URLs.
  3. The scorecard. The table that contrasts the search in the URL against the same in useState, property by property —the verdict that justifies the decision—.

The tree and the cycle

This is the storefront with the search in the URL. The SearchBar and the controls navigate (change the URL); the ProductList reads the URL and derives the view. There's no filter useState:

flowchart TD
    URL[("URL  ?q=mouse&category=peripherals&sort=price&page=2")]
    SB["SearchBar / Filters  (router.push)"]
    PL["ProductList  (useSearchParams -> parse -> getVisibleProducts)"]

    URL --> PL
    SB -- "serialize + router.push" --> URL
    PL -. "the user changes a filter" .-> SB

The cycle is the module's: the URL is parsed to state, the state derives the view, and changing a filter serializes back to the URL and navigates. The URL rules; the UI follows it. Everything shareable is on the envelope.

The real React code: the search read from the URL

This is how Mercado's search looks with the state in the URL (what module 7 implements). The ProductList reads and derives; the controls navigate. The only useState is the input's draft (lesson 5).

'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { useState } from 'react';

// Reads the URL, derives the view. No filter useState.
function SearchPage() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const state = parse(searchParams.toString());          // VALIDATED parse (lesson 6)
  const visible = getVisibleProducts(products, state.query, state.category, state.sort);

  // the input's DRAFT is local; only the APPLIED goes to the URL (lesson 5)
  const [draft, setDraft] = useState(state.query);

  function navigate(next) {
    router.push('/search' + serialize({ ...state, ...next })); // serialize + push (lesson 7)
  }

  return (
    <>
      <form onSubmit={(e) => { e.preventDefault(); navigate({ query: draft, page: 1 }); }}>
        <input value={draft} onChange={(e) => setDraft(e.target.value)} />
      </form>
      <CategoryFilter value={state.category} onChange={(c) => navigate({ category: c, page: 1 })} />
      <SortSelect value={state.sort} onChange={(s) => navigate({ sort: s, page: 1 })} />
      <ul>{visible.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
      <Pagination page={state.page} onChange={(p) => navigate({ page: p })} />
    </>
  );
}

Notice three details of the module: (1) on changing query, category or sort, page: 1 is reset (a new search starts on the first page); (2) each change is a navigation (router.push), not a setState; (3) the local draft is promoted to the URL only on submit. Everything else —what to show— is derived by reading the URL.

Worked example: the module, verified

Let's run the complete verification: the validated serialize/parse module, and the four runs plus the scorecard.

'use strict';
const products = [
  { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599, category: 'peripherals', inStock: true },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900, category: 'peripherals', inStock: false },
  { id: 'p3', name: 'USB-C Hub',           priceCents: 3499, category: 'peripherals', inStock: true },
  { id: 'p4', name: 'Laptop Stand',        priceCents: 4500, category: 'furniture',   inStock: true },
  { id: 'p5', name: 'Desk Lamp',           priceCents: 1999, category: 'furniture',   inStock: true },
  { id: 'p6', name: 'Gaming Mouse',        priceCents: 4599, category: 'peripherals', inStock: true },
];
const formatPrice = (cents) => '$' + (cents / 100).toFixed(2);

// getVisibleProducts: the SAME from react-fundamentals (M5).
function getVisibleProducts(products, query, category, sort) {
  const q = query.trim().toLowerCase();
  return products
    .filter((p) => p.name.toLowerCase().includes(q))
    .filter((p) => category === 'all' || p.category === category)
    .sort((a, b) => sort === 'price-desc' ? b.priceCents - a.priceCents : a.priceCents - b.priceCents);
}
const toSortArg = (s) => (s === 'price-desc' ? 'price-desc' : 'price-asc');

// ---- Mercado's state-in-the-URL module ----
const SORTS = ['relevance', 'price', 'price-desc'];
const CATEGORIES = ['all', 'peripherals', 'furniture'];

function serialize(state) {
  const p = new URLSearchParams();
  if (state.query) p.set('q', state.query);
  if (state.category && state.category !== 'all') p.set('category', state.category);
  if (state.sort && state.sort !== 'relevance') p.set('sort', state.sort);
  if (state.page && state.page !== 1) p.set('page', String(state.page));
  const qs = p.toString();
  return qs ? '?' + qs : '';
}
function parse(search) {
  const p = new URLSearchParams(search);
  const sortRaw = p.get('sort') || 'relevance';
  const catRaw = p.get('category') || 'all';
  const pageRaw = Number.parseInt(p.get('page') || '1', 10);
  return {
    query: (p.get('q') || '').trim().slice(0, 64),
    category: CATEGORIES.includes(catRaw) ? catRaw : 'all',
    sort: SORTS.includes(sortRaw) ? sortRaw : 'relevance',
    page: Number.isInteger(pageRaw) && pageRaw >= 1 ? pageRaw : 1,
  };
}
const view = (s) => getVisibleProducts(products, s.query, s.category, toSortArg(s.sort))
  .map((p) => `${p.name} ${formatPrice(p.priceCents)}`);
const show = (s) => `{ query: ${JSON.stringify(s.query)}, category: ${JSON.stringify(s.category)}, ` +
  `sort: ${JSON.stringify(s.sort)}, page: ${s.page} }`;

console.log('=== M7 Mini-project: Mercado\'s search in the URL ===\n');

// 1) exact round-trip of the complete search state
console.log('--- 1) round-trip of the search state ---\n');
const state = { query: 'mouse', category: 'peripherals', sort: 'price', page: 2 };
const url = serialize(state);
console.log('   state:       ' + show(state));
console.log('   serialize -> ' + url);
console.log('   parse     -> ' + show(parse(url)));
console.log('   exact round-trip: ' + (JSON.stringify(parse(url)) === JSON.stringify(state)));

// 2) share: two sessions -> same view via getVisibleProducts
console.log('\n--- 2) share the link "mouse sorted by price" ---\n');
const link = '?q=mouse&sort=price';
const a = parse(link), b = parse(link);
console.log('   shared link: https://mercado.app/search' + link);
console.log('   session A derives: [' + view(a).join(', ') + ']');
console.log('   session B derives: [' + view(b).join(', ') + ']');
console.log('   same view: ' + (JSON.stringify(view(a)) === JSON.stringify(view(b))));

// 3) back/forward over the history stack
console.log('\n--- 3) back/forward (history = stack of URLs) ---\n');
const history = ['', '?q=mouse', '?q=mouse&category=peripherals', '?q=mouse&category=peripherals&sort=price'];
let cursor = history.length - 1;
history.forEach((h, i) => console.log('   [' + i + '] "' + h + '"' + (i === cursor ? '   <- you are here' : '')));
cursor--;
console.log('   back    -> [' + cursor + ']: ' + show(parse(history[cursor])));
cursor++;
console.log('   forward -> [' + cursor + ']: ' + show(parse(history[cursor])));

// 4) validation: broken URLs -> clean state
console.log('\n--- 4) validation of broken URLs ---\n');
['?page=abc', '?sort=hax', '?category=zzz&page=-3'].forEach((u) => {
  console.log('   ' + ('"' + u + '"').padEnd(24) + ' -> ' + show(parse(u)));
});

// 5) scorecard: URL vs useState
console.log('\n--- 5) scorecard: the search in the URL vs in useState ---\n');
console.log('   Property                | useState (memory)  | URL (searchParams)');
console.log('   ------------------------|--------------------|-------------------');
console.log('   survives the reload     | NO                 | YES');
console.log('   shareable by link       | NO                 | YES');
console.log('   bookmarkable            | NO                 | YES');
console.log('   back/forward undoes     | NO                 | YES');
console.log('   validatable on entry    | n/a (no input)     | YES (parse validates)');

console.log('\nDeliverable: the serialize/parse module (validated), the React code (useSearchParams),');
console.log('and this Node run. Mercado\'s search now lives in its correct box: the URL.');

What to expect. When you run the file with Node, the output is exactly this:

=== M7 Mini-project: Mercado's search in the URL ===

--- 1) round-trip of the search state ---

   state:       { query: "mouse", category: "peripherals", sort: "price", page: 2 }
   serialize -> ?q=mouse&category=peripherals&sort=price&page=2
   parse     -> { query: "mouse", category: "peripherals", sort: "price", page: 2 }
   exact round-trip: true

--- 2) share the link "mouse sorted by price" ---

   shared link: https://mercado.app/search?q=mouse&sort=price
   session A derives: [Wireless Mouse $25.99, Gaming Mouse $45.99]
   session B derives: [Wireless Mouse $25.99, Gaming Mouse $45.99]
   same view: true

--- 3) back/forward (history = stack of URLs) ---

   [0] ""
   [1] "?q=mouse"
   [2] "?q=mouse&category=peripherals"
   [3] "?q=mouse&category=peripherals&sort=price"   <- you are here
   back    -> [2]: { query: "mouse", category: "peripherals", sort: "relevance", page: 1 }
   forward -> [3]: { query: "mouse", category: "peripherals", sort: "price", page: 1 }

--- 4) validation of broken URLs ---

   "?page=abc"              -> { query: "", category: "all", sort: "relevance", page: 1 }
   "?sort=hax"              -> { query: "", category: "all", sort: "relevance", page: 1 }
   "?category=zzz&page=-3"  -> { query: "", category: "all", sort: "relevance", page: 1 }

--- 5) scorecard: the search in the URL vs in useState ---

   Property                | useState (memory)  | URL (searchParams)
   ------------------------|--------------------|-------------------
   survives the reload     | NO                 | YES
   shareable by link       | NO                 | YES
   bookmarkable            | NO                 | YES
   back/forward undoes     | NO                 | YES
   validatable on entry    | n/a (no input)     | YES (parse validates)

Deliverable: the serialize/parse module (validated), the React code (useSearchParams),
and this Node run. Mercado's search now lives in its correct box: the URL.

Read the verification from start to finish, because it's the entire module applied to Mercado.

Part 1 — the round-trip of the complete state. The search state with the four pieces —{ query: "mouse", category: "peripherals", sort: "price", page: 2 }— serializes to ?q=mouse&category=peripherals&sort=price&page=2 and comes back identical (exact round-trip: true). That exactness, now with category included, is the license for the URL to be the source of truth of the whole search, not just a couple of fields.

Part 2 — share. The link ?q=mouse&sort=price is opened by two sessions and both derive the same view —Wireless Mouse $25.99, Gaming Mouse $45.99, the two mice by price— with getVisibleProducts (same view: true). This is the "share mouse sorted by price" the introduction promised, now closed: the link contains the search, and anyone reconstructs it.

Part 3 — back/forward. The history stack shows how the user stacked filters: home → ?q=mouse+category=peripherals+sort=price. They're at [3]. "Back" takes them to [2] (sort: "relevance" —removes the sort, keeps search and category—), "forward" restores [3] (sort: "price"). Each state is a URL of the stack; moving in the history restores states, for free.

Part 4 — validation. The three broken URLs —?page=abc (non-numeric), ?sort=hax (outside the whitelist), ?category=zzz&page=-3 (nonexistent category + negative page)— all produce a clean state: the garbage is replaced by defaults (page: 1, sort: "relevance", category: "all"). The validated parse (lesson 6) guarantees that no URL, however broken or malicious, puts garbage into the state.

Part 5 — the scorecard sums up the verdict in five rows, and they all tell the same story: the search in useState doesn't survive the reload, isn't shared, isn't saved in bookmarks, doesn't respond to the "back" button, and doesn't even have an input to validate; the search in the URL meets all five. It's not an opinion: each "YES" of the URL column you measured in parts 1 to 4. Mercado's search now lives in its correct box.

Common mistakes

Putting the search in the URL but leaving the draft there too. What happens: it navigates on each keystroke of the input, not only on applying. Why it happens: "the search in the URL" was implemented without distinguishing draft from applied. How to detect it: the history fills up (an entry per letter) and "back" deletes letters instead of undoing the search. How to fix it: the input's draft is a local useState (lesson 5); only the applied query (submit) is navigated to the URL. In the project's React code, that's the line navigate({ query: draft }) inside the onSubmit, not inside the onChange.

Forgetting to reset the page on changing a filter. What happens: the user is on page 5, changes the search, and stays on page 5 —which perhaps no longer has results—. Why it happens: it navigates changing only the filter, dragging the old page. How to detect it: changing category or sort leaves an empty or misplaced list. How to fix it: on changing query, category or sort, reset page: 1 in the same navigation (in the project, navigate({ category: c, page: 1 })). A new search starts on the first page.

Integrating the naive parse instead of the validated one. What happens: the project uses a parse that trusts the URL, and an old or edited link puts page: NaN into the state. Why it happens: the "happy" parse from lesson 3 was copied instead of the robust one from lesson 6. How to detect it: crashes or empty lists on opening shared links or ones with typos. How to fix it: the project's parse is the validated one —whitelist of sort/category, page integer ≥ 1, query bounded—. The URL is user input; the parse that integrates it with the router always validates.

Exercises

Exercise 1 — Add inStock to the module. Mercado wants a "in stock only" filter (inStock, boolean, default false) shareable by link. Write the lines you'd add to serialize and to parse (respecting omitted defaults, type conversion and validation), and say which box inStock falls in.

See solution

inStock is a search filter: shareable and recoverable → URL box. In serialize (omits the default false):

if (state.inStock) p.set('inStock', 'true');

In parse (reconstructs the boolean; missing or anything that isn't "true"false):

inStock: p.get('inStock') === 'true',

Keys: (1) the boolean is saved as text ('true') and reconstructed with === 'true' (not Boolean(...), which would give true for "false"); (2) the default false is omitted from the URL, leaving the link clean; (3) the round-trip stays exact. Being a shareable filter, inStock goes in the URL alongside query, category, sort and page.

Exercise 2 — Predict the share. With the project's module, a user shares ?q=&category=furniture&sort=price-desc. (a) What state does it parse? (b) What view does it derive (use the example's catalog)? (c) Why doesn't the empty q= break anything?

See solution

(a) parse('?q=&category=furniture&sort=price-desc'){ query: "", category: "furniture", sort: "price-desc", page: 1 }. q is empty → query: ""; category is furniture (in the whitelist) → passes; sort is price-desc (in the whitelist) → passes; page missing → 1.

(b) getVisibleProducts(products, "", "furniture", "price-desc"): filters by query: "" (includes everything), filters by category furniture (leaves Laptop Stand $45.00 and Desk Lamp $19.99), sorts descending by price → [Laptop Stand $45.00, Desk Lamp $19.99].

(c) The empty q= doesn't break anything because parse treats it as the default (query: ""), which means "no search filter". An empty query is a valid state —it shows the whole category—, not an error. The absence and the empty converge to the same default, which is exactly what the defaults discipline (lesson 3) seeks.

Exercise 3 — Write the verdict. Write, in three or four sentences, the verdict you'd present to the team to move Mercado's search from useState to the URL. Include one measured virtue and the boundary with the nextjs guide.

See solution

A possible verdict:

"Today Mercado's search (query, category, sort, page) lives in useState, so it can't be shared by link, doesn't survive a reload, and the 'back' button doesn't undo filters —we verified it: the link with state in useState arrives blank to the other user—. We propose moving it to the URL (searchParams): with a validated serialize/parse, the search becomes shareable ('look at mouse sorted by price'), bookmarkable and navigable with back/forward, and the parse sanitizes any broken link (?page=abc → page 1) so it doesn't break the app. The concept and the mechanic (URLSearchParams) are already built and verified in Node; the integration with the router (useSearchParams, navigation) follows the standard pattern, and its complete server-side model is covered by the nextjs guide. The cart stays in the store (M3), the theme in Context (M2) and the products in React Query (M4-M6): only the search changes box."

What matters: it carries a measured virtue (the link in useState arrives blank / shareable in the URL), names the tool (URLSearchParams + router), and marks the boundary (the router's complete integration is nextjs).

Summary and next step

In this mini-project you put all of Mercado's search in the URLquery, category, sort, page— and verified it by executing the entire module: the exact round-trip of the complete state, the share demo (two sessions, same view with getVisibleProducts), the back/forward over the history stack, and the validation of broken URLs (garbage → clean defaults), closed with the scorecard that contrasts the URL against useState in five properties. You delivered the three pieces: the validated serialize/parse module, the React code that integrates it with useSearchParams/useRouter (with the local draft and the page reset), and the Node run that proves it. Mercado's URL box is closed, and with it module 7.

Before closing you should be able to: build a validated serialize/parse for a search state; integrate it with the router by reading and navigating; verify the four properties by executing them; and write the verdict of why the search goes in the URL.

And where the guide goes next. With this module you finished the fourth and last box: you already know how to classify each piece of state —local, global client, server, URL— and handle each with its tool. Module 8 is the capstone of the whole guide: you handle all of Mercado's state in its correct boxes at once —the search/filters in the URL (what you just built), the cart in a store (M3), the theme in Context (M2), and the products with React Query (cache + stale-while-revalidate + a mutation with invalidation, M4-M6)—. You'll deliver the state classification table, the real React code, and the logic executed in Node. This project's search-in-the-URL is one of the four pieces of that capstone; there you bring them all together.

Resources