useUrlStateLoading saved progress…

useUrlState

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.

Signature

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,
];

Examples

// 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) }));

Notes

  • The URL is the value. The first render reads whatever the query string says now; initialState only fills in params that are absent. Somebody pasted that link precisely because it was page 7 sorted by price.
  • Everything is a string. ?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.
  • You do not own the query string. utm_source, a sibling component's params, and whatever a teammate added last week are in there too. A write must not eat them.
  • Every write is a history entry, or is not. Pick a default and let a single write override it — only the caller knows whether Back should stop here.
  • Garbage arrives. The URL is the one store a user can hand-edit and truncate, so a malformed query is an input, not an edge case.
  • The server renders this component too, where window does not exist.
Loading editor…