Storing React state in the URL with Next.js

François Best • 20 September 2023 • 7 min read

Rather than keeping React UI state internal to the application memory, where it is lost on unmount and page reloads, we can sync it with the URL query string. This opens up a lot of possibilities:

Take this example:

Interactive demo
Source code

A couple of years ago, I wrote a library to do this in Next.js. It’s used by a fair amount of people, including Vercel on their dashboard.

Avatar for GitHub account 47ng47ng/next-usequerystate

8.6K
5.8M
2.6.0
MIT

Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query string.

$ pnpm add next-usequerystate
$ yarn add next-usequerystate
$ npm install next-usequerystate

Version rolloutLast week

1.20.0
8.7K (27%)
1.17.1
6.6K (21%)
1.19.3
4.6K (14%)
1.7.2
2.9K (09%)
1.17.8
2.9K (09%)
1,56410 Aug5,44611 Aug6,30612 Aug5,81813 Aug6,06014 Aug4,48815 Aug1,33716 Aug1,15317 Aug5,57918 Aug6,76119 Aug6,39820 Aug6,04221 Aug4,82422 Aug1,15223 Aug1,15224 Aug5,07825 Aug5,60126 Aug5,99627 Aug6,02228 Aug4,55829 Aug1,34130 Aug1,20231 Aug4,04601 Sep4,97702 Sep6,52003 Sep6,55504 Sep5,59305 Sep1,57606 Sep1,15607 Sep5,55608 Sep129,857Last 30 days

For it to support the app router, a lot of internal changes were required, due to some limitations in the Next.js router and the Web History API.

But it payed off, as those changes also gave us:

For more details, refer to the documentation, or give the playground a try.

This post will focus on a couple of internal tricks on how to store React state in the URL with Next.js.

Shallow routing

The Next.js app router doesn’t support shallow routing, as of version 13.4.

This has caused a lot of frustration for developpers who want to update query string parameters without triggering network calls to the server.

It also slowed down initial efforts to port next-usequerystate to the app router, especially when binding a query state to high-frequency sources, like:

<input type="text" />
<input type="range" />

To solve these issues, a new direction has been taken: next-usequerystate uses a shallow mode by default, and does so by only using the Next.js router on non-shallow updates.

This is done by tapping directly into the Web History API for shallow updates.

Batching & Throttling

One issue when connecting history.replaceState() to a slider or a text input is that those APIs are rate-limited by the browser.

To avoid hitting the limits, we can batch updates and throttle them under the rate limit.

Empirically, a 50ms throttle seems enough for Firefox, Chrome and Edge.

Between updates of the URL with history.{set,replace}State, we’re just queueing updates. Namely:

key:val:foobarkey:val:fooeggkey:val:count1key:val:count2result: ?=&fooeggcount2=

More recent values take precedence in the query resolution

Keeping track of options in the queue allows overriding the defaults:

Syncing state

Now that the URL updates asynchronously, we need a way to return a state value that corresponds to what will be stored in the URL. It also needs to play well with standard Next.js navigation with <Link> and imperative routing calls.

Internally, the hooks use an individual React state, which get synced by query key using an event emitter.

This emitter is also used to transmit sync triggers from the history API, when external navigation occurs (not query updates). This is done by patching the History methods.

To distinguish between internal and external navigation events, we use the unused title second parameter of replaceState.

We use this to our advantage, and set it to a special marker value when we’re updating the URL internally for query changes. The patched replaceState method can then detect when it’s called from the outside (when the title parameter is not equal to our internal marker), and trigger a sync.

Optimising for performance

Make it work, make it right, make it fast. In that order.

Kent Beck

One advantage of using individual React states per hook is the performance: updates are synchronously propagated to all hooks, keeping the UI snappy.

There are a couple of tricks to make this work with state updater functions though.

If two hooks are updated in the same event loop with SUFs, we want the second one to be based on the result of the first one, as we would expect the URL to eventually behave:

const [count, setCount] = useQueryState(
  'count',
  parseAsInteger.withDefault(0)
)
 
function onClick() {
  setCount(x => x + 1)
  setCount(x => x * 2)
}
 
// First click: counter = 0
// batch update: counter = 1 (0 + 1)
// batch update: counter = 2 (1 * 2)
 
// Second click: counter = 2
// batch update: counter = 3 (2 + 1)
// batch update: counter = 6 (3 * 2)

In order to avoid recreating the state updater function for each state change, which breaks referential equality and de-optimises a lot of consuming code, we use a ref to store the last known state to be applied to the URL.

This is known as the latest ref pattern.

Update queue performance

While writing this post, I realised using an array for the update queue was kind of dumb: since each item overrides previous ones with the same key, we can use a Map instead, and only iterate over available keys on update.

Next.js specifics

So far, we’ve described a system that would work for any React framework. What makes it specific to Next.js is that it handles server-side rendering with correct query values. Those values can also be parsed server-side.

The shallow: false option also allows notifying the server to query updates, to re-render server components, or run getServerSideProps in the pages router.

Credits

A lot of thanks to everyone who helped testing this update, and especially:

And thanks to Erfan for featuring this post in Next.js Weekly #24!


François Best

Freelance developer & founder