Module 7: Url As State
The URL is the source of truth
Overview
This lesson nails the underlying principle of the whole module, the one that gives meaning to the five lessons that follow: the URL is the source of truth of the filters, the search, the sort and the page. It's not an implementation detail; it's a way of thinking about the UI. In most of the apps you wrote until now, the state lived in a useState and the URL was, at best, a late reflection. Here we invert the relationship: the state lives in the URL, and the UI is built by reading it. When the user changes a filter, they don't do setState: they change the URL, and the UI is re-derived from the new URL. The URL rules; the UI follows it. That single change of direction —from "the UI keeps the state" to "the UI reads the state from the URL"— is what makes the links work, makes reloading not erase anything, and makes the "back" button make sense.
Connection with the module. The introduction gave you the map; this lesson lights its first link. Before learning to serialize and parse (lesson 3) or measuring the virtues (lesson 4), you have to understand why we put the state there: because the URL is the only box that survives the refresh and travels with the link. Everything else in the module leans on this principle. And it marks the boundary with the previous: in react-fundamentals you learned to derive the view (getVisibleProducts); here the only thing that changes is where its inputs come from —no longer from a useState, but from the parsed URL—. The derivation is the same; the source is new.
An analogy: the map coordinates you send by chat
Imagine you're in a big park and you want a friend to reach where you are. You have two ways to tell them. The first: you describe it from memory —"come in through the north gate, walk toward the fountain, turn at the big tree"—. That description lives in your head; if you get it wrong or if your friend forgets a step, it's lost, and only you have it. The second: you send them the exact map coordinates by chat. Your friend opens the link and sees your point, identical, without depending on your memory nor your description.
That's the difference between saving the state in useState and saving it in the URL. The useState is the description in your head: it lives in your session's memory, nobody else has it, and if you reload it's erased. The URL is the coordinates: it contains the state, so anyone who receives it reaches the same place, and you yourself can go back to it tomorrow. And there's a key detail: the coordinates are the source of truth of the point. It doesn't matter what you remember; the point is what the coordinates say. Same with the URL: the view isn't what a useState says it should be; the view is what the URL, parsed, produces. The UI reads the coordinates and goes there.
The essential loop: the UI is derived by reading the URL
The heart of "the URL is the source" is a three-step loop that repeats on each render:
URL ──parse──▶ state ──derive──▶ view
▲ │
└──────── serialize ◀── the user changes a filter
Read (parse): the UI takes the current URL (?q=mouse&sort=price-desc) and translates it to a state object ({ query, sort, page }). Derive: with that state, it computes the view —the visible products— just like in react-fundamentals. Write (serialize): when the user changes a filter, the UI serializes the new state to a URL and navigates there; that triggers a new render, which reads the URL again. Notice what does not exist in this loop: a useState that keeps the filters. The state isn't "saved" in the component; it's read from the URL each time. The URL is the only place where it lives.
In real React, that loop is written like this (you'll see it in depth in lesson 7, but look at the shape):
// The UI READS the URL (doesn't keep the filters in useState):
function ProductList() {
const searchParams = useSearchParams(); // the current URL
const { query, sort } = parse(searchParams.toString());
const visible = getVisibleProducts(products, query, 'all', sort); // derive
return <ul>{visible.map((p) => <ProductCard key={p.id} product={p} />)}</ul>;
}
// The handler WRITES the URL (navigates), doesn't do setState:
function onSortChange(newSort) {
const next = { ...parse(searchParams.toString()), sort: newSort };
router.push('/search' + serialize(next)); // change filter = change URL
}
Worked example: the loop, and the reload that proves it
Let's execute the loop in Node: the user does three actions (search, sort, paginate), and each one changes the URL; the UI reads the new URL and derives the view with getVisibleProducts. At the end, the moment of truth: reloading. With the URL as source, the state is restored by parsing the same link; with useState, it's born empty.
'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);
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');
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 : '';
}
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 view = (s) => getVisibleProducts(products, s.query, 'all', toSortArg(s.sort))
.map((p) => `${p.name} ${formatPrice(p.priceCents)}`);
console.log('=== M7 L2: the URL is the source of truth ===\n');
console.log('The loop: the UI is DERIVED by reading the URL (url -> state -> view).\n');
let url = ''; // starts at the home
console.log('action URL derived view');
console.log('--------------------- ---------------------------------- ------------------------------------------');
function step(label, nextState) {
url = serialize(nextState); // the action writes to the URL
const s = parse(url); // the UI READS the URL (source of truth)
console.log(label.padEnd(23) + ('"' + url + '"').padEnd(36) + '[' + view(s).join(', ') + ']');
}
step('searches "mouse"', { query: 'mouse', sort: 'relevance', page: 1 });
step('sorts high to low', { query: 'mouse', sort: 'price-desc', page: 1 });
step('goes to page 2', { query: 'mouse', sort: 'price-desc', page: 2 });
console.log('\n>>> The user reloads the page (F5). Current URL: "' + url + '"\n');
console.log('WITH the URL as source (the UI reads the URL on mount):');
const afterReloadURL = parse(url); // re-parses the SAME URL
console.log(' restored state: { query: ' + JSON.stringify(afterReloadURL.query) +
', sort: ' + JSON.stringify(afterReloadURL.sort) + ', page: ' + afterReloadURL.page + ' } <- intact');
console.log('\nWITH useState as source (the state lives in the session memory):');
const afterReloadUseState = { query: '', sort: 'relevance', page: 1 }; // useState is born empty on mount
console.log(' restored state: { query: ' + JSON.stringify(afterReloadUseState.query) +
', sort: ' + JSON.stringify(afterReloadUseState.sort) + ', page: ' + afterReloadUseState.page +
' } <- lost');
console.log('\nSame F5, two results: the URL survives the refresh; the useState does not.');
What to expect. When you run the file with Node, the output is exactly this:
=== M7 L2: the URL is the source of truth ===
The loop: the UI is DERIVED by reading the URL (url -> state -> view).
action URL derived view
--------------------- ---------------------------------- ------------------------------------------
searches "mouse" "?q=mouse" [Wireless Mouse $25.99, Gaming Mouse $45.99]
sorts high to low "?q=mouse&sort=price-desc" [Gaming Mouse $45.99, Wireless Mouse $25.99]
goes to page 2 "?q=mouse&sort=price-desc&page=2" [Gaming Mouse $45.99, Wireless Mouse $25.99]
>>> The user reloads the page (F5). Current URL: "?q=mouse&sort=price-desc&page=2"
WITH the URL as source (the UI reads the URL on mount):
restored state: { query: "mouse", sort: "price-desc", page: 2 } <- intact
WITH useState as source (the state lives in the session memory):
restored state: { query: "", sort: "relevance", page: 1 } <- lost
Same F5, two results: the URL survives the refresh; the useState does not.
Read the output calmly. The table is the loop in action. Each user action changes the URL (the middle column), and the view (the right column) is re-derived from that new URL —not from a saved state—. Notice the second row: on sorting high to low, the URL gains sort=price-desc and the view inverts (Gaming Mouse $45.99 goes up, Wireless Mouse $25.99 goes down). The UI didn't "remember" the sort: it read it from the URL and derived with getVisibleProducts. That's the point —the URL is what the UI consults to know what to show—.
The reload is the proof. After the three actions, the URL is ?q=mouse&sort=price-desc&page=2. The user presses F5. With the URL as source, mounting the component parses the same URL again and the state comes out intact: { query: "mouse", sort: "price-desc", page: 2 }. With useState as source, mounting the component creates the state from scratch —useState('') is born empty—, so the restored state is the blank home: it's lost. The same F5, two opposite results. And the reason is a single one: the URL exists outside the component's life cycle (the browser keeps it), while the useState lives inside it (it resets when the component mounts again). That's why the URL survives the refresh and the useState doesn't.
This contrast is the whole argument of the lesson. It's not that the URL is "more elegant": it's that it's the only place from which a filter can be restored after a reload, because it's the only one that doesn't reset with the component.
Deeper: what "source of truth" means
A source of truth is the place from which a data is read, not a copy that's kept synchronized. When we say the URL is the source of truth of the filters, we mean three concrete things. First: there's a single place where the query lives —the URL—, not two (the URL and a useState). Second: the UI reads from that place every time it renders, instead of keeping its own copy. Third: to change the data, you write to that place (you navigate to another URL), and the change propagates because the UI reads again.
This connects directly with a principle you already saw in react-fundamentals ("Choosing the State Structure"): don't duplicate state. A filter must live in the URL or in a useState, never in both. If it lives in both, you have two sources of the same truth, and sooner or later they diverge —the URL says one thing, the UI shows another— because keeping them synchronized by hand (with a useEffect) always breaks in some edge case. The rule is clean: if the data belongs to the URL, the URL is the only source; the UI reads from there and writes there, without a parallel copy.
And what does the URL expose? The searchParams —the part after the ?—. ?q=mouse&sort=price-desc&page=2 has three parameters: q, sort, page. In the browser, that string is given by location.search, and React's router exposes it with useSearchParams(). In both cases, you convert it to state with URLSearchParams (lesson 3). The point of this lesson is prior to the mechanic: deciding that those three values live in the URL, and making the UI read them from there instead of saving them.
Common mistakes
Treating the URL as a reflection of the state, not as its source. What happens: the real state lives in useState, and "the URL is updated too" with a useEffect so it looks nice. Why it happens: one arrives with the model "the state lives in React" and finds it hard to invert. How to detect it: the address bar changes, but reloading still loses the state (because the real source was the useState). How to fix it: invert the direction. The URL doesn't reflect the state: it is the state. The UI reads from the URL; the filter useState disappears. If on reloading you lose the state, the URL wasn't your source.
Duplicating the filter in the URL and in useState at once. What happens: query is saved in the URL and in a useState, synchronized with a useEffect. Why it happens: you want to "read fast from the local state" and "have a nice URL" at the same time. How to detect it: bugs where the URL says ?q=mouse but the list shows something else, because the two copies got out of sync. How to fix it: a single source. If the data belongs to the URL, delete the useState: read from the URL and write to the URL. Two sources of the same truth always end up diverging (it's the same "copies that diverge" antipattern you measured with server state).
Putting the state in the URL but still reading from the useState. What happens: it navigates to ?q=mouse correctly, but the component keeps computing the view from an old useState. Why it happens: half the loop was written (the serialize/navigate) but not the other (the parse/read). How to detect it: the URL changes, but the list doesn't; or it changes one render late. How to fix it: the loop has two sides. Writing to the URL isn't enough; you have to read from the URL to derive the view. If the component doesn't read useSearchParams, the URL is decorative.
Exercises
Exercise 1 — Explain the reload. With the executed example, write in your words why the same F5 restores the state when the source is the URL and loses it when the source is useState. Use the words "outside the component" and "resets".
See solution
On reloading, the component is mounted again from scratch. The difference is in where the state lived. The URL lives outside the component: the browser keeps it through the reload, so on mounting again, the component parses the same URL (?q=mouse&sort=price-desc&page=2) and gets the state intact. The useState, on the other hand, lives inside the component: on remounting, each useState('') resets to its initial value (empty), so the previous state is lost —it wasn't saved anywhere that survives the mount—. The URL survives because it's external; the useState doesn't because it's internal to the component's life cycle.
Exercise 2 — The loop without useState. A colleague says: "to have the filters in the URL, I save query in useState, and every time it changes, I update the URL with a useEffect". Explain why that isn't "the URL as source" and what fails. Propose the correct loop.
See solution
That makes the URL a reflection of the useState, not its source. The real source is still the useState, and that brings two problems: (1) on reloading, the useState is born empty and the URL —which reflected the useState— no longer has anything to restore it into the state, so the filter is lost (or you have to write another useEffect to read the URL on mount, and now you have two effects synchronizing two copies); (2) you have two sources of the same truth (URL and useState), which can diverge.
The correct loop has no filter useState: (a) the UI reads the URL with useSearchParams and parses it to state; (b) it derives the view from that state; (c) on changing a filter, it navigates to the new URL (router.push(serialize(next))), which triggers a render that reads the URL again. A single source, without a synchronization useEffect, and the reload works on its own because the URL is what's read.
Exercise 3 — Read the second row. In the example's table, the row "sorts high to low" changed the URL to ?q=mouse&sort=price-desc and the view became [Gaming Mouse $45.99, Wireless Mouse $25.99]. Explain where that new order came from: did the UI save it somewhere?
See solution
It didn't save it in any useState. The new order came from deriving the view from the URL. On sorting, the handler navigated to ?q=mouse&sort=price-desc; on the next render, the UI read that URL, parsed it to { query: "mouse", sort: "price-desc", page: 1 }, and called getVisibleProducts(products, "mouse", "all", "price-desc"), which sorts high to low —that's why Gaming Mouse $45.99 ended up on top—. The order isn't "remembered" in the component: it's in the URL (sort=price-desc), and the view is recomputed by reading it. If you reloaded here, the order would be kept, precisely because it lives in the URL and not in the session's memory.
Summary and next step
In this lesson you inverted the relationship between the UI and its state: the URL is the source of truth of the filters, and the UI is built by reading it. You saw the essential loop —read (parse) → derive → write (serialize) → navigate— and noticed that in it there's no filter useState: the state isn't saved in the component, it's read from the URL each time. You measured it with the reload: the URL lives outside the component (survives the F5), the useState lives inside (resets). You saved the map-coordinates analogy —the source of truth of the point are the coordinates, not your memory— and the three forms of the duplication antipattern: the URL as reflection, the double copy synchronized with useEffect, and writing the URL but still reading from the useState.
Before moving on you should be able to: explain what it means for the URL to be "the source of truth"; describe the three-step loop without useState; justify why the URL survives the reload and the useState doesn't; and detect when a data is duplicated in two sources.
Lesson 3 goes down to the mechanism that makes the loop possible: serialize and parse with URLSearchParams. You're going to see, executed, the back-and-forth translation —state→URL and URL→state— in an exact round-trip, how the defaults are omitted to leave the link clean, and why everything in the URL is text (so you have to convert types on parsing: Number(page), not "2"). It's the tool the loop uses on both sides.
Resources
- React, "Choosing the State Structure" (section "Avoid duplication in state") — react.dev/learn/choosing-the-state-structure. The single-source-of-truth principle and why not to duplicate the filter in the URL and in
useState. In English. - MDN, "Location: search" — developer.mozilla.org/en-US/docs/Web/API/Location/search. How the browser exposes the
?...part of the URL (thesearchParams), the source the UI reads. In English. - React, "Managing State" — react.dev/learn/managing-state. The general framework of where state lives and when it's best to take it out of React (to the URL). In English.
- Next.js, "useSearchParams" — nextjs.org/docs/app/api-reference/functions/use-search-params. The hook with which the UI reads the URL in React; the "read the source" mechanic you'll see in depth in lesson 7. In English.