use-url-sync-state
v1.0.0
Published
A React hook that syncs state to URL search params — making it bookmarkable, shareable, and back-button friendly.
Maintainers
Readme
use-url-sync-state
A React hook that syncs state to URL search params — making your UI state bookmarkable, shareable, and back-button friendly.
The Problem
You build a dashboard with filters — search, status, page number. The user sets everything up perfectly, copies the URL, and sends it to a teammate. The teammate opens it and sees... the default state. All filters gone.
Or the user hits the back button and loses their filters entirely.
useUrlState fixes this — one hook, works for a single field or many.
Installation
npm install use-url-sync-stateBasic Usage (single field)
import { useUrlState } from "use-url-sync-state";
function SearchBox() {
const [state, setState] = useUrlState({ search: "" });
return (
<input
value={state.search}
onChange={(e) => setState({ search: e.target.value })}
placeholder="Search products..."
/>
);
}Multiple Fields (recommended for forms/filters)
Pass an object of defaults and update one or many fields at a time. Every update — whether it touches one field or several — writes the URL in a single change.
import { useUrlState } from "use-url-sync-state";
function ProductsPage() {
const [state, setState] = useUrlState({
search: "",
page: 1,
status: "all",
});
return (
<div>
<input
value={state.search}
onChange={(e) => setState({ search: e.target.value })}
placeholder="Search products..."
/>
<select
value={state.status}
onChange={(e) => setState({ status: e.target.value })}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<Pagination
page={state.page}
onPageChange={(page) => setState({ page })}
/>
{/* Update multiple fields in ONE url change */}
<button
onClick={() =>
setState({ search: "shoes", status: "active", page: 1 })
}
>
Apply Quick Filter
</button>
</div>
);
}Resulting URL as the user interacts:
/products?search=shoes&status=active&page=2That URL is now shareable and bookmarkable. The back button works. Refresh keeps the state.
API
const [state, setState] = useUrlState(defaults, serializers?);Parameters
| Parameter | Type | Description |
|---|---|---|
| defaults | T extends Record<string, unknown> | An object defining every field and its default value — even for a single field, pass { search: "" } |
| serializers | { [key]: Serializer<T[key]> } | Optional — only needed for types the built-in default can't handle (e.g. Date) |
Returns
| Value | Type | Description |
|---|---|---|
| state | T | Current state, read from the URL (or defaults if absent) |
| setState | (updates: Partial<T>) => void | Merges updates into state and writes all changed fields to the URL in one update |
Built-in default serialization (no config needed)
| Value type | URL format | Example |
|---|---|---|
| string | plain text | ?search=shoes |
| number | plain number | ?page=2 |
| boolean | true / false | ?inStock=true |
| array of primitives | comma-separated | ?tags=sale,new,trending |
| object / array of objects | JSON | ?filters={"category":"shoes"} |
Commas in the URL are kept literal (not encoded as %2C) for readability.
Custom Serializer
For values the default can't handle cleanly — like Date:
const dateSerializer = {
parse: (value: string) => new Date(value),
stringify: (value: Date) => value.toISOString().split("T")[0],
};
const [state, setState] = useUrlState(
{ date: new Date() },
{ date: dateSerializer }
);Why One Object Instead of One Hook Call Per Field
An earlier version of this package had two hooks — one for a single field, one for many. That created a trap: updating several fields in the same event handler with separate hook instances can cause them to overwrite each other, because each instance reads window.location independently and doesn't know about the others' pending changes.
// ⚠️ Two independent hook calls — updating both in the same handler
// can lose data, since each setValue reads the URL separately.
const [search, setSearch] = useSomeOtherLibrary("search", "");
const [status, setStatus] = useSomeOtherLibrary("status", "all");
function applyFilter() {
setSearch("shoes"); // writes ?search=shoes
setStatus("active"); // may not see the line above yet — risk of overwrite
}useUrlState avoids this entirely by treating all fields as one state object and writing the whole URL in a single pushState call — so a multi-field update can never partially overwrite itself, whether you're updating one field or five.
Behaviour Notes
- Back/forward button — listens to
popstate, state updates on navigation - Clean URLs — when a value equals its default, the param is removed from the URL automatically
- Malformed URLs — if a param can't be parsed (e.g. manually edited to garbage), the hook falls back to the default for that field instead of crashing
SSR Behaviour & Limitations
This hook is crash-safe on the server (it checks typeof window and never throws during SSR), but it is not SSR-hydrated. This distinction matters:
- On the server, state always renders as
defaults, since there's nowindow.locationto read from. - On the client, immediately after mount, the hook re-reads the real URL and updates state.
- This causes a brief flash from default → actual value on first render. In frameworks with strict hydration checks (e.g. Next.js App Router), this can trigger a hydration mismatch warning if the rendered output differs between server and client.
Why this happens: truly SSR-correct URL state requires reading the request URL on the server, which is framework-specific (Next.js's searchParams prop, Remix loaders, etc.). A framework-agnostic hook has no generic way to access that.
If you're on Next.js App Router and need zero-flash, hydration-safe URL state, pass the server's searchParams in as the initial value rather than relying solely on this hook, or use a framework-specific solution like nuqs.
For client-rendered apps (Vite, CRA, client components without SSR), this limitation doesn't apply — state is correct from the very first render.
When to Use This
✅ Dashboard filters (search, status, category) ✅ Pagination ✅ Tab state you want to be linkable ✅ Multi-field forms/filters that should be shareable as a URL ✅ Any UI state that should survive a page refresh
License
MIT © Danish
