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.You'll write a hook that looks exactly like useState over the one store the user can read, edit, share — and undo.
Every other store React talks to is invisible furniture. Nobody has ever looked at your localStorage. Nobody bookmarks a cookie or sends one to a colleague.
The query string is not furniture, it is the screen's address. That is the entire reason filters, sorting and pagination live there: the user can see the state, edit it, bookmark it, and paste it to somebody who then sees the same page. State that cannot be linked to is state that quietly makes half your app unshareable.
And that visibility comes with something no other store has. The address bar has a back button, and behind it the browser keeps a stack of every URL you have put there. So a write to the query string is secretly two operations wearing one coat: it stores a value, and it decides what Back does next. The first one is what you were thinking about. The second one is the one that reaches your users.
A history entry is a promise to the user: Back will bring you here. Every write either makes that promise or doesn't, and the browser gives you one function for each — pushState stacks a new entry, replaceState overwrites the current one. Picking wrong is invisible in development, because you never press Back on your own app.
Read the top row as a person, not a developer. They typed a word into a box. They are done, and they want to leave. They press Back and the page does not leave — a letter disappears. They press it again. Another letter. They are not debugging your history stack, they are trying to get out, and the only reliable escape from your page is to close the tab.
Read the URL on mount, write it on set, listen for popstate. The starter hands you working parseQuery / stringifyQuery helpers, so this is the whole hook:
function useUrlState(initialState = {}, options = {}) {
const initialRef = useRef(initialState);
const [state, setState] = useState(() => ({ ...initialRef.current, ...readUrlQuery() }));
const set = useCallback((patch, writeOptions) => {
const fromUrl = readUrlQuery();
const previous = { ...initialRef.current, ...fromUrl };
const changes = typeof patch === 'function' ? patch(previous) : patch;
const query = stringifyQuery({ ...fromUrl, ...changes });
const { pathname, hash } = window.location;
const url = pathname + (query ? '?' + query : '') + hash;
// The URL changed, so this is a navigation — the same thing a link click
// is. Push it, and Back goes to the previous one.
window.history.pushState(null, '', url);
setState({ ...initialRef.current, ...readUrlQuery() });
}, []);
useEffect(() => {
const onPopState = () => { setState({ ...initialRef.current, ...readUrlQuery() }); };
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
return [state, set];
}
This is not a strawman. It passes sixteen of this question's eighteen tests: the URL beats initialState, patches merge, utm_source survives, updaters work, the setter identity is stable. And pushState is the correct instinct — it is genuinely what changing the address means.
It is also, in its one load-bearing decision, what ahooks ships — navigateMode defaults to 'push'. Mount its hook on a filter box, type laptop, and count the history: measured, six keystrokes leave six entries. The two tests it fails are the only two that ever ask what the back button does.
const { useState, useRef, useEffect, useCallback } = require('react');
// ------------------------------------------------------------------- the wire
// Turning a query string into pairs and back again is URLSearchParams' job —
// see the URLSearchParams question. Two things it hands you for free are worth
// knowing about, because they are exactly what a hand-edited URL needs: it
// never throws on a malformed query, and a repeated name reads first-one-wins.
function parseQuery(search) {
const query = {};
for (const [key, value] of new URLSearchParams(search)) {
// First one wins, the same rule as URLSearchParams.get. hasOwnProperty
// rather than `in`, or a param named `toString` would look already-present.
if (!Object.prototype.hasOwnProperty.call(query, key)) query[key] = value;
}
return query;
}
function stringifyQuery(query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue; // undefined is how a param gets removed
params.append(key, value);
}
return params.toString();
}
// The server renders this component too, and there is no location there.
function readUrlQuery() {
if (typeof window === 'undefined') return {};
return parseQuery(window.location.search);
}
// ------------------------------------------------------------------- the hook
function useUrlState(initialState = {}, options = {}) {
// Captured once. The caller writes this literal inline, so it is a new object
// every render holding the same values.
const initialRef = useRef(initialState);
// Same for options — but read on every write, so a ref keeps `set` from
// getting a new identity each render just because the literal is new.
const optionsRef = useRef(options);
optionsRef.current = options;
const [state, setState] = useState(() => ({ ...initialRef.current, ...readUrlQuery() }));
const set = useCallback((patch, writeOptions) => {
// Ask the URL, never our own copy of it. A link, a router navigate() or the
// back button can all have moved it since the render this call came from —
// the same discipline useCookieState is about, for the same reason.
const fromUrl = readUrlQuery();
const previous = { ...initialRef.current, ...fromUrl };
const changes = typeof patch === 'function' ? patch(previous) : patch;
// Merge over what is IN THE URL, not over `previous`. You do not own this
// query string, and rebuilding it from your own keys alone deletes
// everybody else's.
const query = stringifyQuery({ ...fromUrl, ...changes });
const { pathname, hash } = window.location;
const url = `${pathname}${query ? `?${query}` : ''}${hash}`;
// THE line. This write, then the hook's default, then replace — because a
// history entry is a promise, and the safe default is not to make one.
const mode = writeOptions?.navigateMode ?? optionsRef.current.navigateMode ?? 'replace';
if (mode === 'push') window.history.pushState(null, '', url);
else window.history.replaceState(null, '', url);
// Neither call fires an event (see the History Router question), so nothing
// re-renders this component unless we do it ourselves.
setState({ ...initialRef.current, ...readUrlQuery() });
}, []);
useEffect(() => {
// Back and forward. NOT our own writes — pushState is silent.
const onPopState = () => {
setState({ ...initialRef.current, ...readUrlQuery() });
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
return [state, set];
}
module.exports = { useUrlState };
One line moved, and it is the whole question: the choice of pushState or replaceState stopped being hard-coded and became something the caller says, per write.
mode resolves in three steps — this write's writeOptions, then the hook's options, then 'replace'. The three-step shape is what lets one component say this hook is mostly quiet, except this button, which is how real filter bars are actually built. And replace is the floor rather than push because of which mistake you would rather ship: a missing entry is annoying (Back skips a filter the user applied), while a surplus entry is trapping (Back does not leave, six times over). Defaults should fail in the direction you can recover from.
useUrlState({ page: '1', keyword: '' }) on a filter page opened at /products?page=3&utm_source=newsletter.
readUrlQuery() gives { page: '3', utm_source: 'newsletter' }, spread over initialState, so state is { page: '3', keyword: '', utm_source: 'newsletter' }. Page 3 renders — the link the user was sent reproduces the screen it was copied from. keyword came from initialState because the URL had nothing to say about it, and nothing was written: a default is not a choice.l. set({ keyword: 'l' }). fromUrl reads back the two live params, changes is the patch, and the merge is { page: '3', utm_source: 'newsletter', keyword: 'l' }. mode is 'replace' — no writeOptions, no options.navigateMode. replaceState swaps the current entry for /products?page=3&utm_source=newsletter&keyword=l. history.length does not move.aptop. Five more writes, five more replaceState calls, still one entry. The address bar tracks every keystroke; the history stack has not heard about any of them.set({ page: '4' }, { navigateMode: 'push' }). Same merge, but mode is now 'push', so pushState stacks a new entry. history.length goes up by exactly one — the user deliberately left page 3, and Back is now a way to return to it.?page=3&...&keyword=laptop and fires popstate. The listener re-reads the URL and sets state to { page: '3', keyword: 'laptop', utm_source: 'newsletter' }. Page 3 renders, the filter still says laptop, and the user is exactly where they expected.Steps 3 and 4 are the same function, the same state, and the same user. The only thing that differed was what the write claimed about Back, and step 5 is the proof it was worth claiming carefully.
It is tempting to look at step 4 and conclude the rule is frequent writes replace, deliberate writes push, then bake that into the hook. You can't. The hook is handed a patch and an options object; it cannot see a keyboard, and { page: '4' } and { keyword: 'lapto' } are the same shape of nothing-in-particular.
This is why navigateMode is on setState and not only on useUrlState. The information needed to answer the question does not exist inside the hook — it exists at the call site, where somebody knows whether this write is a user deciding something or a user still talking. A hook-level option alone forces one answer onto every write in the component, and a filter bar has both kinds.
The other half of a write is what it does to params you have never heard of.
A cookie has a name, and localStorage has a key, so writing yours cannot touch anybody else's. A query string has none of that separation: it is one flat string that everything on the page shares. The tabs component keeps tab in it. Marketing appends utm_source to every link it sends. A second useUrlState two components over keeps sort.
So { ...fromUrl, ...changes } is not defensive tidiness, it is the difference between merging and replacing the whole thing. Build the next query from state and you ship a filter box that silently deletes the campaign attribution for every user who arrives from an ad — and nothing throws, no test fails, and the first symptom is a marketing dashboard that goes quiet.
?page=2 reads back as '2'. Not 2. This is the honest behaviour and the tests pin it, because a URL has no schema — there is nothing in ?page=2 that says what a page is, and the person who typed it into the address bar certainly didn't say.
The temptation is to help. Every query-string library ships a flag for it, and it is worth seeing what the flag does to real values (query-string, which is what ahooks parses with, measured with parseNumbers: true):
| the URL | what you get | what it was |
|---|---|---|
?ref=0800123 | 800123 | a support code, now missing a digit |
?zip=01234 | 1234 | a postcode that no longer exists |
?v=1.10 | 1.1 | version 1.10, silently downgraded |
?sku=0x1f | 31 | a SKU, read as hexadecimal |
?id=1e3 | 1000 | an id, read as scientific notation |
Every one of those is a value somebody legitimately put in a URL, and the only thing coercion knows about them is that they are shaped a bit like numbers. So this hook hands back what the URL says and lets the caller decide Number(state.page) — because the caller is the only one who knows what the field means.
The same reasoning settles arrays. The URL's one native way to say two things is a repeated name, ?tag=a&tag=b, and this hook's state is a flat map of strings — so a repeat gives you the first value, the same rule as URLSearchParams.get. That is a real limitation and it is deliberate, because the alternative is worse: ahooks returns 'a' for one tag and ['a', 'b'] for two (measured), so the type of the field depends on how many values are in the URL, and state.tag.map(...) crashes only on the days a user picked one filter.
readUrlQuery guards on typeof window === 'undefined', so the hook does not crash while server-rendering. It falls back to initialState.
Which is a slightly absurd place to end up. The server is being asked for that exact URL — the query string is on the request line, it is the most available fact in the entire building — and this hook renders page 1 anyway, because a hook is handed props, not requests. The user's shared link, the one thing this whole design exists to serve, produces an unfiltered first frame that the client then corrects.
The fix is not in the hook:
// React Router 7 — useSearchParams reads the router's location, which the
// server filled in from the actual request. Same URL on both sides.
import { useSearchParams } from 'react-router';
export default function Products() {
const [params, setParams] = useSearchParams();
const page = params.get('page') ?? '1';
// ...and look what its setter takes as a second argument.
const onKeystroke = (keyword) => setParams({ page, keyword }, { replace: true });
}
In a real React Router app, use that. This question builds the hook from the platform anyway for two reasons: the router is doing exactly the work above on your behalf, and you should know what that is — and, more to the point, look at the last line. setSearchParams takes a per-write replace option (measured: default adds one entry, { replace: true } adds none). React Router hit the same wall and drew the same conclusion. The tension isn't an artifact of building it yourself; it's in the problem.
No two of them agree, and the disagreement is on precisely the point of this question.
| default | per-write override | coercion | |
|---|---|---|---|
ahooks useUrlState 3.5.1 | push | none | off by default |
| nuqs 2.9.0 | replace | yes — { history: 'push' } | opt-in parsers |
React Router useSearchParams | push | yes — { replace: true } | none |
| this hook | replace | yes — { navigateMode: 'push' } | none |
ahooks first, because it is the closest relative and the one to check yourself on. It is not in the ahooks package at all — useUrlState ships separately as @ahooksjs/use-url-state, because it needs react-router (its peer range also still stops at React 18). Its state shape is the one this question uses, it merges patches over the URL, it preserves utm_source, and it does not coerce. It is a good hook.
And its setState takes one argument. navigateMode is chosen once, for the whole hook, and every write obeys it — so with the default you get the buried back button, and with navigateMode: 'replace' you get a paginator whose page changes cannot be undone. There is no third option. Measured: six keystrokes → six entries; passing { navigateMode: 'replace' } as a second argument does nothing at all, because nothing reads it.
nuqs is the specialist — query-string state is its entire job, not a side effect of routing — and it is the one that agrees: history: 'replace' is the default, with a per-write override. It is also worth reading for two things this hook doesn't do. Its parsers (parseAsInteger, parseAsIsoDate) make coercion explicit and total: parseAsInteger.parse('banana') returns null rather than NaN or a throw. And it ships two array conventions — parseAsArrayOf for ?tags=a,b (escaping commas inside values) and parseAsNativeArrayOf for ?tag=a&tag=b — which is the most honest possible statement that the URL has no canonical answer for arrays. The leading library shipped both and made you pick.
The one place all three defaults are worth doubting together is the pattern: the two that default to push are routers (react-router, and ahooks' hook, which is a react-router wrapper), where push is what navigation has always meant. The one that thinks of the URL as a state container chose replace. That is the tell — a filter is not a destination.
state. Your state has your keys. The URL has utm_source, tab, and a sibling component's params. A write that serializes state alone deletes them silently. Fix: read the URL, spread your changes on top.?page=2 is text, and so is ?zip=01234. A parser that returns numbers turns the second one into 1234. Fix: hand back strings; let the caller convert the fields whose meaning it knows.initialState. useUrlState({ page: 1 }) gives you 1 until somebody clicks page two, and '2' forever after — one field, two types, switching on whether the user has acted yet. ahooks has this too (measured). Fix: keep initialState strings, so the fallback and the real thing are the same shape.replace, the History API is rate-limited: Safari throws a SecurityError at 100 calls per 30 seconds, which a fast typist in a filter box reaches. It is why nuqs throttles URL writes by default (50ms, and 120ms on Safari, straight from its source). Fix: debounce the URL write and keep the input itself in local state, so the field stays instant.popstate to tell you everything. It fires for Back and Forward, and for nothing else. Not for your own writes, and — measured — not for a router's navigate() either, because that is a pushState underneath. A <Link> that changes the query is invisible to this hook. Fix: inside a router, use its hook; the platform version cannot see what the router does not announce.useState so typing is instant, and pushes it to the URL on a trailing delay. That fixes the rate limit, cuts thirty writes to one, and makes the entry question moot for the noisiest case. nuqs exposes it as limitUrlUpdates: debounce(300).useUrlState({ page: '1' }, { parsers: { page: Number } }) is maybe fifteen lines and buys back the types the URL threw away — with the rule nuqs proves is the right one: a parser must be total, so parse('banana') returns the default rather than NaN.?tag=a&tag=b) survive hand-editing and match getAll; a comma-joined list (?tags=a,b) is shorter and needs escaping for commas inside values. There is no right answer, which is exactly why the hook shouldn't guess one.useSyncExternalStore. Unlike the cookie, the URL has a real subscribe channel — popstate — so the store hook React ships for this shape is genuinely writable here. It also makes the gap obvious: subscribe covers the back button and nothing else, so any write anywhere still has to notify the others itself.useUrlState instances in one page do not learn about each other's writes, for the same reason: pushState is silent. A module-level set of subscribers, notified on every write, is the standard fix and is about ten lines.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.You'll write a hook that looks exactly like useState over the one store the user can read, edit, share — and undo.
Every other store React talks to is invisible furniture. Nobody has ever looked at your localStorage. Nobody bookmarks a cookie or sends one to a colleague.
The query string is not furniture, it is the screen's address. That is the entire reason filters, sorting and pagination live there: the user can see the state, edit it, bookmark it, and paste it to somebody who then sees the same page. State that cannot be linked to is state that quietly makes half your app unshareable.
And that visibility comes with something no other store has. The address bar has a back button, and behind it the browser keeps a stack of every URL you have put there. So a write to the query string is secretly two operations wearing one coat: it stores a value, and it decides what Back does next. The first one is what you were thinking about. The second one is the one that reaches your users.
A history entry is a promise to the user: Back will bring you here. Every write either makes that promise or doesn't, and the browser gives you one function for each — pushState stacks a new entry, replaceState overwrites the current one. Picking wrong is invisible in development, because you never press Back on your own app.
Read the top row as a person, not a developer. They typed a word into a box. They are done, and they want to leave. They press Back and the page does not leave — a letter disappears. They press it again. Another letter. They are not debugging your history stack, they are trying to get out, and the only reliable escape from your page is to close the tab.
Read the URL on mount, write it on set, listen for popstate. The starter hands you working parseQuery / stringifyQuery helpers, so this is the whole hook:
function useUrlState(initialState = {}, options = {}) {
const initialRef = useRef(initialState);
const [state, setState] = useState(() => ({ ...initialRef.current, ...readUrlQuery() }));
const set = useCallback((patch, writeOptions) => {
const fromUrl = readUrlQuery();
const previous = { ...initialRef.current, ...fromUrl };
const changes = typeof patch === 'function' ? patch(previous) : patch;
const query = stringifyQuery({ ...fromUrl, ...changes });
const { pathname, hash } = window.location;
const url = pathname + (query ? '?' + query : '') + hash;
// The URL changed, so this is a navigation — the same thing a link click
// is. Push it, and Back goes to the previous one.
window.history.pushState(null, '', url);
setState({ ...initialRef.current, ...readUrlQuery() });
}, []);
useEffect(() => {
const onPopState = () => { setState({ ...initialRef.current, ...readUrlQuery() }); };
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
return [state, set];
}
This is not a strawman. It passes sixteen of this question's eighteen tests: the URL beats initialState, patches merge, utm_source survives, updaters work, the setter identity is stable. And pushState is the correct instinct — it is genuinely what changing the address means.
It is also, in its one load-bearing decision, what ahooks ships — navigateMode defaults to 'push'. Mount its hook on a filter box, type laptop, and count the history: measured, six keystrokes leave six entries. The two tests it fails are the only two that ever ask what the back button does.
const { useState, useRef, useEffect, useCallback } = require('react');
// ------------------------------------------------------------------- the wire
// Turning a query string into pairs and back again is URLSearchParams' job —
// see the URLSearchParams question. Two things it hands you for free are worth
// knowing about, because they are exactly what a hand-edited URL needs: it
// never throws on a malformed query, and a repeated name reads first-one-wins.
function parseQuery(search) {
const query = {};
for (const [key, value] of new URLSearchParams(search)) {
// First one wins, the same rule as URLSearchParams.get. hasOwnProperty
// rather than `in`, or a param named `toString` would look already-present.
if (!Object.prototype.hasOwnProperty.call(query, key)) query[key] = value;
}
return query;
}
function stringifyQuery(query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue; // undefined is how a param gets removed
params.append(key, value);
}
return params.toString();
}
// The server renders this component too, and there is no location there.
function readUrlQuery() {
if (typeof window === 'undefined') return {};
return parseQuery(window.location.search);
}
// ------------------------------------------------------------------- the hook
function useUrlState(initialState = {}, options = {}) {
// Captured once. The caller writes this literal inline, so it is a new object
// every render holding the same values.
const initialRef = useRef(initialState);
// Same for options — but read on every write, so a ref keeps `set` from
// getting a new identity each render just because the literal is new.
const optionsRef = useRef(options);
optionsRef.current = options;
const [state, setState] = useState(() => ({ ...initialRef.current, ...readUrlQuery() }));
const set = useCallback((patch, writeOptions) => {
// Ask the URL, never our own copy of it. A link, a router navigate() or the
// back button can all have moved it since the render this call came from —
// the same discipline useCookieState is about, for the same reason.
const fromUrl = readUrlQuery();
const previous = { ...initialRef.current, ...fromUrl };
const changes = typeof patch === 'function' ? patch(previous) : patch;
// Merge over what is IN THE URL, not over `previous`. You do not own this
// query string, and rebuilding it from your own keys alone deletes
// everybody else's.
const query = stringifyQuery({ ...fromUrl, ...changes });
const { pathname, hash } = window.location;
const url = `${pathname}${query ? `?${query}` : ''}${hash}`;
// THE line. This write, then the hook's default, then replace — because a
// history entry is a promise, and the safe default is not to make one.
const mode = writeOptions?.navigateMode ?? optionsRef.current.navigateMode ?? 'replace';
if (mode === 'push') window.history.pushState(null, '', url);
else window.history.replaceState(null, '', url);
// Neither call fires an event (see the History Router question), so nothing
// re-renders this component unless we do it ourselves.
setState({ ...initialRef.current, ...readUrlQuery() });
}, []);
useEffect(() => {
// Back and forward. NOT our own writes — pushState is silent.
const onPopState = () => {
setState({ ...initialRef.current, ...readUrlQuery() });
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
return [state, set];
}
module.exports = { useUrlState };
One line moved, and it is the whole question: the choice of pushState or replaceState stopped being hard-coded and became something the caller says, per write.
mode resolves in three steps — this write's writeOptions, then the hook's options, then 'replace'. The three-step shape is what lets one component say this hook is mostly quiet, except this button, which is how real filter bars are actually built. And replace is the floor rather than push because of which mistake you would rather ship: a missing entry is annoying (Back skips a filter the user applied), while a surplus entry is trapping (Back does not leave, six times over). Defaults should fail in the direction you can recover from.
useUrlState({ page: '1', keyword: '' }) on a filter page opened at /products?page=3&utm_source=newsletter.
readUrlQuery() gives { page: '3', utm_source: 'newsletter' }, spread over initialState, so state is { page: '3', keyword: '', utm_source: 'newsletter' }. Page 3 renders — the link the user was sent reproduces the screen it was copied from. keyword came from initialState because the URL had nothing to say about it, and nothing was written: a default is not a choice.l. set({ keyword: 'l' }). fromUrl reads back the two live params, changes is the patch, and the merge is { page: '3', utm_source: 'newsletter', keyword: 'l' }. mode is 'replace' — no writeOptions, no options.navigateMode. replaceState swaps the current entry for /products?page=3&utm_source=newsletter&keyword=l. history.length does not move.aptop. Five more writes, five more replaceState calls, still one entry. The address bar tracks every keystroke; the history stack has not heard about any of them.set({ page: '4' }, { navigateMode: 'push' }). Same merge, but mode is now 'push', so pushState stacks a new entry. history.length goes up by exactly one — the user deliberately left page 3, and Back is now a way to return to it.?page=3&...&keyword=laptop and fires popstate. The listener re-reads the URL and sets state to { page: '3', keyword: 'laptop', utm_source: 'newsletter' }. Page 3 renders, the filter still says laptop, and the user is exactly where they expected.Steps 3 and 4 are the same function, the same state, and the same user. The only thing that differed was what the write claimed about Back, and step 5 is the proof it was worth claiming carefully.
It is tempting to look at step 4 and conclude the rule is frequent writes replace, deliberate writes push, then bake that into the hook. You can't. The hook is handed a patch and an options object; it cannot see a keyboard, and { page: '4' } and { keyword: 'lapto' } are the same shape of nothing-in-particular.
This is why navigateMode is on setState and not only on useUrlState. The information needed to answer the question does not exist inside the hook — it exists at the call site, where somebody knows whether this write is a user deciding something or a user still talking. A hook-level option alone forces one answer onto every write in the component, and a filter bar has both kinds.
The other half of a write is what it does to params you have never heard of.
A cookie has a name, and localStorage has a key, so writing yours cannot touch anybody else's. A query string has none of that separation: it is one flat string that everything on the page shares. The tabs component keeps tab in it. Marketing appends utm_source to every link it sends. A second useUrlState two components over keeps sort.
So { ...fromUrl, ...changes } is not defensive tidiness, it is the difference between merging and replacing the whole thing. Build the next query from state and you ship a filter box that silently deletes the campaign attribution for every user who arrives from an ad — and nothing throws, no test fails, and the first symptom is a marketing dashboard that goes quiet.
?page=2 reads back as '2'. Not 2. This is the honest behaviour and the tests pin it, because a URL has no schema — there is nothing in ?page=2 that says what a page is, and the person who typed it into the address bar certainly didn't say.
The temptation is to help. Every query-string library ships a flag for it, and it is worth seeing what the flag does to real values (query-string, which is what ahooks parses with, measured with parseNumbers: true):
| the URL | what you get | what it was |
|---|---|---|
?ref=0800123 | 800123 | a support code, now missing a digit |
?zip=01234 | 1234 | a postcode that no longer exists |
?v=1.10 | 1.1 | version 1.10, silently downgraded |
?sku=0x1f | 31 | a SKU, read as hexadecimal |
?id=1e3 | 1000 | an id, read as scientific notation |
Every one of those is a value somebody legitimately put in a URL, and the only thing coercion knows about them is that they are shaped a bit like numbers. So this hook hands back what the URL says and lets the caller decide Number(state.page) — because the caller is the only one who knows what the field means.
The same reasoning settles arrays. The URL's one native way to say two things is a repeated name, ?tag=a&tag=b, and this hook's state is a flat map of strings — so a repeat gives you the first value, the same rule as URLSearchParams.get. That is a real limitation and it is deliberate, because the alternative is worse: ahooks returns 'a' for one tag and ['a', 'b'] for two (measured), so the type of the field depends on how many values are in the URL, and state.tag.map(...) crashes only on the days a user picked one filter.
readUrlQuery guards on typeof window === 'undefined', so the hook does not crash while server-rendering. It falls back to initialState.
Which is a slightly absurd place to end up. The server is being asked for that exact URL — the query string is on the request line, it is the most available fact in the entire building — and this hook renders page 1 anyway, because a hook is handed props, not requests. The user's shared link, the one thing this whole design exists to serve, produces an unfiltered first frame that the client then corrects.
The fix is not in the hook:
// React Router 7 — useSearchParams reads the router's location, which the
// server filled in from the actual request. Same URL on both sides.
import { useSearchParams } from 'react-router';
export default function Products() {
const [params, setParams] = useSearchParams();
const page = params.get('page') ?? '1';
// ...and look what its setter takes as a second argument.
const onKeystroke = (keyword) => setParams({ page, keyword }, { replace: true });
}
In a real React Router app, use that. This question builds the hook from the platform anyway for two reasons: the router is doing exactly the work above on your behalf, and you should know what that is — and, more to the point, look at the last line. setSearchParams takes a per-write replace option (measured: default adds one entry, { replace: true } adds none). React Router hit the same wall and drew the same conclusion. The tension isn't an artifact of building it yourself; it's in the problem.
No two of them agree, and the disagreement is on precisely the point of this question.
| default | per-write override | coercion | |
|---|---|---|---|
ahooks useUrlState 3.5.1 | push | none | off by default |
| nuqs 2.9.0 | replace | yes — { history: 'push' } | opt-in parsers |
React Router useSearchParams | push | yes — { replace: true } | none |
| this hook | replace | yes — { navigateMode: 'push' } | none |
ahooks first, because it is the closest relative and the one to check yourself on. It is not in the ahooks package at all — useUrlState ships separately as @ahooksjs/use-url-state, because it needs react-router (its peer range also still stops at React 18). Its state shape is the one this question uses, it merges patches over the URL, it preserves utm_source, and it does not coerce. It is a good hook.
And its setState takes one argument. navigateMode is chosen once, for the whole hook, and every write obeys it — so with the default you get the buried back button, and with navigateMode: 'replace' you get a paginator whose page changes cannot be undone. There is no third option. Measured: six keystrokes → six entries; passing { navigateMode: 'replace' } as a second argument does nothing at all, because nothing reads it.
nuqs is the specialist — query-string state is its entire job, not a side effect of routing — and it is the one that agrees: history: 'replace' is the default, with a per-write override. It is also worth reading for two things this hook doesn't do. Its parsers (parseAsInteger, parseAsIsoDate) make coercion explicit and total: parseAsInteger.parse('banana') returns null rather than NaN or a throw. And it ships two array conventions — parseAsArrayOf for ?tags=a,b (escaping commas inside values) and parseAsNativeArrayOf for ?tag=a&tag=b — which is the most honest possible statement that the URL has no canonical answer for arrays. The leading library shipped both and made you pick.
The one place all three defaults are worth doubting together is the pattern: the two that default to push are routers (react-router, and ahooks' hook, which is a react-router wrapper), where push is what navigation has always meant. The one that thinks of the URL as a state container chose replace. That is the tell — a filter is not a destination.
state. Your state has your keys. The URL has utm_source, tab, and a sibling component's params. A write that serializes state alone deletes them silently. Fix: read the URL, spread your changes on top.?page=2 is text, and so is ?zip=01234. A parser that returns numbers turns the second one into 1234. Fix: hand back strings; let the caller convert the fields whose meaning it knows.initialState. useUrlState({ page: 1 }) gives you 1 until somebody clicks page two, and '2' forever after — one field, two types, switching on whether the user has acted yet. ahooks has this too (measured). Fix: keep initialState strings, so the fallback and the real thing are the same shape.replace, the History API is rate-limited: Safari throws a SecurityError at 100 calls per 30 seconds, which a fast typist in a filter box reaches. It is why nuqs throttles URL writes by default (50ms, and 120ms on Safari, straight from its source). Fix: debounce the URL write and keep the input itself in local state, so the field stays instant.popstate to tell you everything. It fires for Back and Forward, and for nothing else. Not for your own writes, and — measured — not for a router's navigate() either, because that is a pushState underneath. A <Link> that changes the query is invisible to this hook. Fix: inside a router, use its hook; the platform version cannot see what the router does not announce.useState so typing is instant, and pushes it to the URL on a trailing delay. That fixes the rate limit, cuts thirty writes to one, and makes the entry question moot for the noisiest case. nuqs exposes it as limitUrlUpdates: debounce(300).useUrlState({ page: '1' }, { parsers: { page: Number } }) is maybe fifteen lines and buys back the types the URL threw away — with the rule nuqs proves is the right one: a parser must be total, so parse('banana') returns the default rather than NaN.?tag=a&tag=b) survive hand-editing and match getAll; a comma-joined list (?tags=a,b) is shorter and needs escaping for commas inside values. There is no right answer, which is exactly why the hook shouldn't guess one.useSyncExternalStore. Unlike the cookie, the URL has a real subscribe channel — popstate — so the store hook React ships for this shape is genuinely writable here. It also makes the gap obvious: subscribe covers the back button and nothing else, so any write anywhere still has to notify the others itself.useUrlState instances in one page do not learn about each other's writes, for the same reason: pushState is silent. A module-level set of subscribers, notified on every write, is the standard fix and is about ten lines.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.