Module 1: The Four Kinds Of State
URL state: what's shareable
Overview
The fourth and last drawer is URL state: the data the user would want to share, save or recover —the search, the filters, the sort, the page—. In Mercado, when someone searches "mouse", sorts by price and goes to page 2, that state shouldn't live hidden in a useState: it should live in the URL (?q=mouse&sort=price&page=2). Why? Because put there it becomes shareable (you can send the link over WhatsApp and the other person sees exactly the same thing), bookmarkable (you save it in favorites and recover it tomorrow) and navigable (the browser's "back" button undoes the last filter). All that comes free for one reason: when the state is in the URL, the URL is the source of truth. The UI is built by reading the link, not the other way around.
Connection with the module. The module's decision rule asks, in second place —right after the backend—, "should it be shareable or survive a reload?". This lesson is that question. It's the box where what people, out of inertia, put in a local useState goes: the filters and the search. And classifying it well has an enormous consequence for the experience —the difference between an app whose links can be shared and one where reloading wipes everything—. Watch the boundary: here we work the concept (the URL as state) and the browser's standard API (URLSearchParams), which runs the same in Node and in the browser. How that connects with React —the router, useSearchParams— is module 7 of this guide; and the Next.js router, with its own model, is the nextjs guide. Here you classify the box and execute its essential mechanic: serialize and parse.
An analogy: the address, on the envelope
When you send a letter, where do you write the recipient's address? On the envelope, on the outside —you don't keep it only in your head—. And that decision, so obvious you don't even think about it, has three consequences that are exactly the three virtues of state in the URL:
- Anyone who picks up the envelope knows where it's going. They don't need to ask you; the address travels with the envelope. Just like a link: whoever receives it sees the same search and the same filters, without you having to explain anything. It's shareable.
- You can save it and pick it back up. You leave the envelope in a drawer, take it out tomorrow, and the address is still there. Just like a bookmark: you save
?q=mouse&sort=priceand on returning you recover that identical state. It's bookmarkable / recoverable. - If you send it again, it arrives at the same place. The address on the envelope is reproducible: same envelope, same destination. Just like reloading the page (F5): the URL rebuilds the same state. It survives the reload.
Compare with keeping the address only in your memory (the local useState): if you forget it, it's lost; you can't give it to anyone; and if you faint (reload the page), it disappeared. That's the cost of putting in local state what belonged on the envelope. The URL box's sign is, then: would the user want to share this, save it or recover it after reloading? If yes, write it on the envelope.
The case in Mercado: the search, in the URL
In the storefront, the SearchBar and the filters produce three pieces: the search term (query), the sort (sort) and the page (page). All three belong to the URL. Here's how the state and its corresponding URL look:
search state URL
──────────────────────────── ──────────────────────────────
query: "mouse" /search?q=mouse&sort=price&page=2
sort: "price" <──────>
page: 2
the UI is built by READING the URL (URL = source of truth)
changing a filter = changing the URL (not a local setState)
The mindset change is this: instead of const [query, setQuery] = useState("") and filtering with that, the app reads query from the URL and, when the user types something new, updates the URL. The URL rules; the UI follows it. That's why the links work: there's no "hidden" state the URL doesn't reflect. Everything shareable is on the envelope.
Let's apply the decision rule to the already-applied query: does its truth live in the backend? No —it's what the user typed, not remote data—. Should it be shareable/recoverable? Yes —"look at these results" is exactly what one shares, and reloading shouldn't wipe it—. Second question affirmative: url. (Remember the subtlety from lesson 2: the text while being typed is local; the already applied is url. Two moments, two boxes.) And be careful not to confuse the query with its results: query is url, but the products the server returns for that query are server (a cache). The input goes on the envelope; the response is a copy from the bank.
Worked example: serialize, parse and an exact round-trip
The essential mechanic of state in the URL is a round-trip translation: from your state object to a query string (serialize), and from a query string back to your state object (parse). If that translation is exact —a lossless round-trip—, then the URL can be the source of truth without problems. We run it in Node with URLSearchParams, which is the same API you'd use in the browser (it's included in both, no installing anything):
// URL state: the filters/search/page live in searchParams.
// It's shareable, bookmarkable and works with back/forward, because the URL
// IS the source of truth. We use URLSearchParams (native in Node and in the browser).
// state -> query string (serialize). We only write what is NOT the default.
function serialize(state) {
const params = new URLSearchParams();
if (state.query) params.set('q', state.query);
if (state.sort && state.sort !== 'relevance') params.set('sort', state.sort);
if (state.page && state.page !== 1) params.set('page', String(state.page));
const qs = params.toString();
return qs ? '?' + qs : '';
}
// query string -> state (parse). We fill in defaults for what's missing.
function parse(search) {
const params = new URLSearchParams(search);
return {
query: params.get('q') || '',
sort: params.get('sort') || 'relevance',
page: Number(params.get('page') || '1'),
};
}
const show = (s) => `{ query: ${JSON.stringify(s.query)}, sort: ${JSON.stringify(s.sort)}, page: ${s.page} }`;
console.log('=== The URL as state: serialize / parse ===\n');
console.log('1) The user searches "mouse", sorts by price, page 2:');
const state = { query: 'mouse', sort: 'price', page: 2 };
const url = serialize(state);
console.log(' state -> URL: ' + url);
console.log('\n2) Round-trip: parse that same URL back to state:');
const back = parse(url);
console.log(' URL -> state: ' + show(back));
console.log(' matches the original: ' + (JSON.stringify(back) === JSON.stringify(state)));
console.log('\n3) Share the link: another user opens the URL cold (no prior state):');
const shared = '?q=mouse&sort=price&page=2';
console.log(' pastes the URL: https://mercado.app/search' + shared);
console.log(' state rebuilt from scratch: ' + show(parse(shared)));
console.log('\n4) Defaults: if there are no filters, the URL stays clean (we don\'t clutter the link):');
console.log(' default state -> URL: "' + serialize({ query: '', sort: 'relevance', page: 1 }) + '" (empty)');
console.log('\n5) Back/forward: history is a stack of URLs (the URL is the source):');
const history = ['', '?q=mouse', '?q=mouse&sort=price', '?q=mouse&sort=price&page=2'];
history.forEach((h, i) => console.log(` step ${i}: "${h}" -> ${show(parse(h))}`));
console.log(' back (step 3 -> 2): ' + show(parse(history[2])));
console.log(' forward (step 2 -> 3):' + show(parse(history[3])));
What to expect. When you run the file with Node, the output is exactly this:
=== The URL as state: serialize / parse ===
1) The user searches "mouse", sorts by price, page 2:
state -> URL: ?q=mouse&sort=price&page=2
2) Round-trip: parse that same URL back to state:
URL -> state: { query: "mouse", sort: "price", page: 2 }
matches the original: true
3) Share the link: another user opens the URL cold (no prior state):
pastes the URL: https://mercado.app/search?q=mouse&sort=price&page=2
state rebuilt from scratch: { query: "mouse", sort: "price", page: 2 }
4) Defaults: if there are no filters, the URL stays clean (we don't clutter the link):
default state -> URL: "" (empty)
5) Back/forward: history is a stack of URLs (the URL is the source):
step 0: "" -> { query: "", sort: "relevance", page: 1 }
step 1: "?q=mouse" -> { query: "mouse", sort: "relevance", page: 1 }
step 2: "?q=mouse&sort=price" -> { query: "mouse", sort: "price", page: 1 }
step 3: "?q=mouse&sort=price&page=2" -> { query: "mouse", sort: "price", page: 2 }
back (step 3 -> 2): { query: "mouse", sort: "price", page: 1 }
forward (step 2 -> 3):{ query: "mouse", sort: "price", page: 2 }
Each block demonstrates a virtue of having the state in the URL:
1 and 2 — the round-trip is exact. The state { query: "mouse", sort: "price", page: 2 } serializes to ?q=mouse&sort=price&page=2, and parsing that URL returns the same state (matches the original: true). That exactness is the license to use the URL as the source of truth: nothing is lost in the translation. If the round-trip failed —if parsing gave you something different—, the URL couldn't be the source, because the rebuilt UI wouldn't match the one you shared.
3 — the link is rebuilt cold. Another user, without any prior state in their browser, pastes ?q=mouse&sort=price&page=2 and gets exactly { query: "mouse", sort: "price", page: 2 }. This is what a local useState can't give: local state lives in your session's memory; nobody else has it. The URL, in contrast, contains the state, so anyone who receives it rebuilds it from scratch. It's the address on the envelope: it travels with it.
4 — the defaults keep the URL clean. If the state is the default (no search, sort by relevance, page 1), the URL stays empty (""). It's a good practice: don't clutter the link with ?q=&sort=relevance&page=1 when it isn't needed. On parsing an empty URL, the defaults fill in on their own (step 0 of block 5: "" → { query: "", sort: "relevance", page: 1 }). The absence of a parameter means its default value.
5 — back/forward comes free. Here's the jewel. The browser's history is, literally, a stack of URLs. Since each state is a URL, moving through the history is moving between states: step 3 (?q=mouse&sort=price&page=2) has "back" as step 2 (?q=mouse&sort=price, which parses to page 1), and "forward" returns to step 3. The browser's "back" button undoes the last filter change without you programming anything, because the URL already keeps track. With local state, you'd have to reimplement a history by hand; with the URL, the browser already does it.
Notice a detail of step 2 → back: on returning from page=2 to the URL without page, the state parses page: 1 —the default—. The URL doesn't store an explicit page=1 (block 4), but on parsing it the default fills it back in. The envelope doesn't need to write "page 1"; it's understood.
What goes in the URL (and what doesn't)
The URL box is for the shareable/recoverable, but not everything that changes belongs there. The discipline:
goes in the URL (shareable / survives the reload) does NOT go in the URL
────────────────────────────────────────────────── ─────────────────────────────────────
query (applied search) input draft while typing -> local
sort (order) menuOpen, isHovered -> local
page (pagination) the cart -> global (not shared by link)
filters (category, price range, inStock) products (results) -> server (it's a cache)
the active tab of a shareable view session tokens, sensitive data -> never in the URL
Two warnings. First: the draft vs. the applied. Putting every keystroke the user types in the URL would fill the history with garbage (one entry per letter) and would trigger meaningless navigations; the text while being typed is local, and only the applied query (on pressing Enter, or after a small debounce) goes to the URL. Second: nothing sensitive in the URL. The URL is shared, saved in history, appears in server logs; never put session tokens, passwords or private data there. The sensitive is global client state (or server), never the envelope's.
Common mistakes
Storing the filters in local useState. What happens: query, sort and page live in a component's state. Why it happens: "they're values that change, they go in useState", out of react-fundamentals habit. How to detect it: the user reloads and loses their search; they share the link and the friend sees a blank home; the "back" button doesn't undo the last filter. How to fix it: if the user would want to share or recover that state, it goes in the URL (the rule's second question). Local state is for what can die; the filters should survive.
Duplicating the state in the URL and in useState at once. What happens: query is stored in the URL and in a useState, and they're kept in sync with a useEffect. Why it happens: you want "the best of both". How to detect it: bugs where the URL says one thing and the UI another, because the two copies desynced. How to fix it: a single source of truth. If the data belongs to the URL, the URL is the source: the UI reads from the URL and writes to the URL, without a parallel copy in state. (This is the same "two copies that diverge" antipattern that lesson 6 measures for server state: having two sources of the same truth always ends badly.)
Putting server or sensitive data in the URL. What happens: the already-loaded product list, or the session token, is serialized in the URL. Why it happens: "state I want to keep" is confused with "state that goes on the envelope". How to detect it: kilometer-long URLs with data that should come from the backend; or —serious— sensitive information visible in the link. How to fix it: the inputs that are shareable go in the URL (what to search, how to sort), not the results (that's server, a cache re-requested from the query) nor anything sensitive (that never). The envelope carries the address, not the contents of the package.
Exercises
Exercise 1 — URL or not? For each piece, decide whether it goes in the URL and justify with the question "would the user want to share it or recover it after reloading?": (a) the category selected in the filters; (b) the text being typed in the search box, letter by letter; (c) the page number of the listing; (d) whether the cart modal is open; (e) the list's sort (price, relevance, newest); (f) the user's session token.
See solution
- (a) category → url. It's a filter; "look at the keyboards on sale" is exactly what one shares and wants to recover. It goes in
?category=keyboards. - (b) text being typed letter by letter → NOT url (it's local). The draft is local; putting every keystroke in the URL would fill the history with garbage. Only the applied
querygoes to the URL. - (c) page → url. "Page 3 of results" should be shared and survive the reload.
?page=3. - (d) open cart modal → NOT url (it's local). It's ephemeral UI of a component; nobody shares "I have the modal open", and it shouldn't survive a reload. Local (although some apps put it in the URL for deep-linking; by default, local).
- (e) sort → url. Like
sortin the example: shareable and recoverable.?sort=price. - (f) session token → NEVER url (it's global client, in a secure place). The URL is shared and recorded in logs; putting a token there is a security leak. Its box is global client, and it lives in secure storage, not on the envelope.
Exercise 2 — Why the link is rebuilt cold. With the executed example, explain why another user, without any prior state, could rebuild { query: "mouse", sort: "price", page: 2 } just by pasting the URL. What would have to happen, in contrast, to rebuild that state if it lived in a local useState of your session?
See solution
They could rebuild it because the URL contains the state: ?q=mouse&sort=price&page=2 carries, written out, everything needed, and parse() translates it back to { query: "mouse", sort: "price", page: 2 }. The URL is the address on the envelope: it travels with the link, so anyone who receives it has the complete state, without depending on anything prior. That's why the exact round-trip matters: it guarantees that what's parsed is identical to what was serialized.
If that state lived in a useState local to your session, it would be impossible to rebuild it just from a link: local state lives in your browser's memory, and the link doesn't carry it. For the other user to see the same, you'd have to transmit that state to them through another channel —tell them, send them a message "search mouse, sort by price, go to page 2"— and they'd replicate it by hand. The URL eliminates that step: the state is the link. That's the whole advantage of classifying the filters as url instead of local.
Exercise 3 — Serialize a new filter. Mercado adds an "in stock only" filter (inStock, boolean) and a price range (priceMax, number). Design how they'd look in the URL, what serialize and parse should do with them, and which default value you'd omit from the URL. Write the lines you'd add to serialize and to parse.
See solution
-
In the URL:
?inStock=true&priceMax=5000(the price in cents, like the rest of Mercado:$50.00). The default ofinStockisfalseand that ofpriceMaxis "no cap"; both are omitted from the URL when they're at their default, so as not to clutter the link. -
In
serialize(only written if it's NOT the default):
if (state.inStock) params.set('inStock', 'true'); // omits the default false
if (state.priceMax) params.set('priceMax', String(state.priceMax)); // omits "no cap"
- In
parse(fills the default if missing):
inStock: params.get('inStock') === 'true', // "true" -> true; absent -> false
priceMax: params.get('priceMax') ? Number(params.get('priceMax')) : null, // absent -> no cap
Design keys: (1) booleans and numbers are stored as text in the URL (everything in a URL is a string), so you have to convert on parsing (=== 'true', Number(...)); (2) the default is omitted from the URL and refilled on parsing, keeping the links clean; (3) the round-trip must stay exact —serializing and re-parsing a state must return the same state—. These two filters are, just like query/sort/page, url state: shareable and recoverable.
Summary and next step
In this lesson you opened the last drawer: URL state, the shareable, bookmarkable and recoverable —the search, the sort, the page, the filters—. You saw that its virtue is born from one single thing: the URL is the source of truth, and the UI is built by reading it. You measured it by executing the essential mechanic with URLSearchParams: serialize state→URL and parse URL→state in an exact round-trip, rebuild a shared link cold (what local state can't), omit the defaults for clean links, and see how back/forward comes free because the history is a stack of URLs. You kept the analogy of the address on the envelope —it travels with the link, it's saved, it's reproduced— and the two warnings: the draft is local (only the applied goes to the URL), and the sensitive never goes on the envelope. The tool (the router, useSearchParams) is module 7; here you classified the box and executed its translation.
Before moving on you should be able to: define URL state and its three virtues; explain why the exact round-trip is the license to use the URL as the source; distinguish the draft (local) from the applied value (url) and the input (url) from the results (server); and justify why duplicating the state in the URL and in useState at once is an antipattern.
With this you have the four boxes complete: local (L2), global (L3), server (L4) and url (L5). Lesson 6 comes back to the box that names the module —server state— to measure the thesis in full force: how treating it as your own state generates copies that diverge (the same product showing two prices at once), and how a single truth in cache cures it. It's the "why classifying well matters" made measurement.
Resources
- MDN, "URLSearchParams" — developer.mozilla.org/en-US/docs/Web/API/URLSearchParams. The standard API we use to serialize and parse; the same in the browser and in Node. This box's reference. In English.
- MDN, "The Location interface" — developer.mozilla.org/en-US/docs/Web/API/Location. How the browser exposes the current URL (
location.search), the source of truth we read and write. In English. - React, "Choosing the State Structure" — react.dev/learn/choosing-the-state-structure. The "single source of truth" principle and not duplicating state: why the filter goes in the URL or in
useState, never in both. In English. - TkDodo, "React Query and React Context" — tkdodo.eu/blog/react-query-and-react-context. Useful context on where each kind of state lives; it complements the URL / server separation this lesson starts to mark (the input is url, the results are server). In English.