Joonas' blog

Next.js app directory: URL-synced state

published

How to store state in URL with Next.js app directory

TL;DR give me the code

Here’s a Next.js-compatible React hook that

import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
export function useUrlSyncedState<T>(
deserialize: (params: ReadonlyURLSearchParams) => T,
serialize: (data: T, params: URLSearchParams) => void
): [T, Dispatch<SetStateAction<T>>] {
const params = useSearchParams();
// initialize state with deserialized params
// params is always non-null in app directory, so we can use non-null assert
const [state, setState] = useState<T>(() => deserialize(params!));
// store changed state in URL
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const oldParamsString = params.toString();
serialize(state, params);
const str = params.toString();
if (str !== oldParamsString) {
const newHref = `${location.pathname}${str === "" ? "" : `?${str}`}`;
// https://github.com/vercel/next.js/discussions/18072#discussioncomment-109059
window.history.replaceState(
{ ...window.history.state, as: newHref, url: newHref },
"",
newHref
);
}
}, [state, serialize]);
return [state, setState];
}
// usage:
const DESERIALIZE_PAGE = (params: ReadonlyURLSearchParams) => (
params?.has("page") ? parseInt(params.get("page")!, 10) - 1 : 0
);
const SERIALIZE_PAGE = (
page: number,
params: URLSearchParams
) => {
if (page > 0) {
params.set("page", String(page + 1));
} else {
params.delete("page");
}
};
function MyComponent() {
const [page, setPage] = useUrlSyncedState<PaginationState>(
DESERIALIZE_PAGE,
SERIALIZE_PAGE
);
return <div>current page: {PAGE.pageIndex}</div>;
}

Why URL state

Storing any larger or more complicated data in URL makes no sense, but there a few things for which URL state makes the most sense, such as search state and pagination.

In my case, I wanted a clientside search form to store its filter state in the URL. That way the user could freely click search results, explore them, and use back button to return to same results they departed from.

Existing implementations

There exists a next-usequerystate (https://github.com/47ng/next-usequerystate) specifically for this purpose, but it is relying on pre-next.js 13 code (like next/router). Additionally, it seems to be primarily targeted at singular search params. We’ll want something that specifically targets Next.js 13 in order to stay maximally compatible with its routing system, while being flexible enough to be used for all kinds of usecases.

Core behavior

Core behavior in the hook is quite simple:

  1. Read URL search params when initializing useState
  • useSearchParams is conveniently available even during SSR in app directory
  1. Hook into state changes with useEffect to update URL when needed

We generally don’t have to worry about URL -> state updates, since search param changes reload the page anyway and force useState initializer to rerun in that way.

The biggest issue ended up being the state -> URL update: we want to update the URL in a way that neither re-fetches data from server or breaks the Next.js navigation stack. The initial implementation looked something like the following:

window.history.replaceState("", "", newHref);

The problem with replaceState API is that it is not by itself very Next.js compatible, so backwards/forwards navigation broke and didn’t properly change the shown client component. Next attempt was the obvious built-in router.replace API:

router.replace(newHref);

The problem with this option was constant page reloads. Since modifying the search params in a URL is an operation that normally warrants reloading the page, Next.js helpfully refetches the serverside page contents every time router.replace is called. For our purposes, we want to avoid any extra fetches.

So, let’s compromise. replaceState while taking Next.js state into account:

const newHref = `${location.pathname}${str === "" ? "" : `?${str}`}`;
// https://github.com/vercel/next.js/discussions/18072#discussioncomment-109059
window.history.replaceState(
{ ...window.history.state, as: newHref, url: newHref },
"",
newHref
);