useUrlState(initialState, options) is a useState whose value lives in the page's query string — the ?page=2&keyword=laptop on the end of the URL. Filters, sorting and pagination go there for one reason: the URL is the only state the user can see, edit, bookmark and paste into a chat window, and a link that does not reproduce the screen it was copied from is not worth sending.
It is also the only store with a back button. Every write you make is a history entry, and the user can walk back through them whenever they like. Add an entry per keystroke and typing laptop into a filter box buries the page under six of them — the user reaches for Back to leave, and instead watches the filter un-type itself, one letter at a time. Overwrite the entry instead and that goes away, but now Back cannot undo a filter the user deliberately applied.
Implement useUrlState(initialState, options). Turning a query string into pairs and back again is a solved problem — URLSearchParams covers it, and the starter hands you working parseQuery / stringifyQuery helpers. What you are deciding here is what the hook does to the history.
function useUrlState(
initialState?: Record<string, string>, // only for params the URL does not have
options?: {
// push: every write stacks a new entry for Back to land on
// replace: every write overwrites the current entry
navigateMode?: 'push' | 'replace';
},
): [
Record<string, string>,
// takes a PARTIAL patch (or an updater), plus options for THIS write
(
patch: object | ((prev: object) => object),
writeOptions?: { navigateMode?: 'push' | 'replace' },
) => void,
];
// the page was opened at /products?page=3
const [state, setState] = useUrlState({ page: '1', keyword: '' });
state; // { page: '3', keyword: '' } — the URL wins; initialState fills the gaps
A patch merges into what is already there, and every write says what it does to the history:
setState({ keyword: 'laptop' }); // a keystroke: no new entry
setState({ page: '4' }, { navigateMode: 'push' }); // a click: Back returns to page 3
setState({ keyword: undefined }); // drops ?keyword from the URL
setState((prev) => ({ page: String(Number(prev.page) + 1) }));
initialState only fills in params that are absent. Somebody pasted that link precisely because it was page 7 sorted by price.?page=2 reads back as '2', not 2. Nothing in a URL declares a type, and guessing one is how a ref code of 0800123 becomes the number 800123.utm_source, a sibling component's params, and whatever a teammate added last week are in there too. A write must not eat them.window does not exist.