The nuqs tagline, “type-safe search params state manager for React”, only represents a small fraction of what nuqs does under the hood. Type-safety is only the tip of the iceberg. There are hidden dangers beneath the surface that you (or, better, your tools) need to be aware of.
Read vs Write
One of the first things people do when they want type-safe URL state is to
bring in validation libraries (Zod, Valibot, ArkType, anything Standard Schema compliant)
to parse URLSearchParams
into valid data types they can use in their apps.
That’s read type-safety, and it is fairly easy to achieve.
You could stop here and wonder why you’d need another third-party library, until
you need to write search params with complex state. Anything that doesn’t
trivially stringify using .toString()
will need a serialisation step that matches
the parsing (math nerds call this property bijectivity).
Validation libraries don’t provide the reverse transform from an object or data type back to the string it got transformed from. To address this, nuqs comes with built-in parsers for common data types, but you can also make your own to give your complex data types a beautiful, compact representation in the URL.
Compactness here is the important property: URLs have a size limit, much like local storage or cookies.
While 2000 characters is generally considered safe, the HTTP spec allows a bit more headroom at 8KB, but practical limitations will be those of the medium you share your URL through, and the (un)willingness of users to click very long links. Remember:
The URL is the first piece of UI your users will see. Design it as such.
See for yourself: which link would you rather click on?
- https://example.com?page=1&size=50&filters=genre:fantasy&sort=releaseYear:asc
- https://example.com?pagination=%7B%22pageIndex%22%3A1%2C%22pageSize%22%3A50%7D&filters=%5B%7B%22id%22%3A%22genre%22%2C%22value%22%3A%22fantasy%22%7D%5D&orderBy=%7B%22id%22%3A%22releaseYear%22%2C%22desc%22%3Afalse%7D
Tip: what state goes in the URL?
Only store state in the URL that you want to share with others (including your future self, with bookmarks and history navigation).
Putting state there only because it’s convenient for it to persist across page reloads will likely make you overuse this pattern.
Deserialise, parse, validate
Validation libraries still have a very important purpose: making sure your state is runtime-safe. Even after deserialisation into the correct data type, there may be invalid values you want to avoid, and that you cannot represent with static typing alone. Things like:
- A number between -90/+90 or -180/+180 for latitude/longitude coordinates
- A string formatted in a certain way (like an email or a UUID)
- A date greater than a given epoch
On read, validation should occur after deserialisation, on a data type relatively close to the desired output (which is ensured by parsing).
Similarly, on write, validation should occur before serialisation, to make sure invalid states don’t get persisted to the URL.
Time Safety
80% of the nuqs codebase does not deal with type safety, but time safety.
One of the lesser known issues with the History API (that most routers use to update the URL without doing a full page load) is that browsers rate-limit URL updates, for security reasons.
Calling updates too quickly will throw an error, potentially crashing your app in the process.
Not all browsers are equal on this rate limiting: Chrome and Firefox allow about 50ms between calls to be safe, but Safari has much higher limits, and requires about 120ms between calls.
This issue surfaces when binding URL state to a high-frequency input, like a text box
<input type="text">
or a slider <input type="range">
.
You could solve this by keeping inputs uncontrolled and deferring the URL update to
a later time (after a debounce timeout or the press of a button), but controlled inputs have their
purpose. If you want to follow external URL updates to reset their state, or if other
parts of the React tree need this state, call useQueryState(s)
anywhere you need
access to that shared state (the same way you would use a global state manager like Zustand, Jotai, etc.),
and nuqs will lift the state out into the URL for you.
Rather than requiring rigidity in userland, nuqs embraces browser limits and solves the time safety problem with a throttled queue and optimistic URL updates.
This also allows batching state updates from different sources, and stitching them together automatically, which is one of the most encountered pain points when doing this manually.
You might want to use useQueryStates
for better type-safety when using
related states.
The upcoming debounce feature makes this process even more apparent, with the additional complexity of having to handle aborting pending updates when the user navigates away from the page they’re on. This prevents stale state updates from being applied on the wrong pathname or overriding link state (last user action wins).
Immutability
Once you’ve shared URLs out in the wild, they become immutable. But your application is anything but immutable.
The statefulness you introduce by adding URL state makes it akin to a database schema. Each shared URL is an immutable database snapshot that your app needs to be able to process throughout its lifetime, to honour the promise encoded in those links. But it should not prevent you from making changes in the expected schema your app accepts.
Just like database schemas, we can handle this with migrations:
- Capture old URLs with a middleware
- Migrate the old state schema into what the app currently expects
- Redirect to the updated URL to continue
We’ve explored declarative ways to do this, but this is something that could benefit more than just nuqs users, so it may materialise as a complementary package.
Time Travel
When updating the URL, you can choose to either replace the current history entry with
the updated state, or push a new one (this is done with the
history: 'push' | 'replace'
option in nuqs).
Pushing a new history entry allows you to use the Back/Forward buttons of the browser as an undo-redo feature, which looks like Redux Devtools’ time travel state debugging.
But this comes at a price: you now have two sources of updates that can manipulate history:
- The original UI element that triggered the update
- The Back/Forward buttons
This is often encountered with a ?modalOpen=true
state, where the Back/Forward buttons
can conflict with the X button to close the modal. Depending on whether the user opened the
modal through the UI or landed on it via a link, the Back button might have different behaviours.
Breaking the Back button leads to a frustrating user experience, and handling it properly involves a few steps:
- Being aware that users can enter your app at any state
- From that state, they can use either the Back button or your UI
- Remember that Back/Forward is a contract for navigation-like interactions
The experimental Navigation API should hopefully make it easier to handle these cases in the future.
Conclusion
Achieving type-safety for URL state is not the endgame, but it’s the beginning of a journey: there are other things that URL state management libraries and application code need to deal with, to provide truly safe and durable state management.
This post is an excerpt of my talk at React Paris, watch it here for more details and tips: