apocalypse

dev

URL as a state manager

#URL as a state manager - why and how

#Introduction

In the broad ecosystem of UI frameworks, one of the hardest tasks we are faced with, is to find an appropriate state manager for our application. The state manager is a system operating within the lifespan of an application (unlike the database), responsible for:

  • storing data
  • updating it when requested (by user action or other side effects)
  • sharing and synchronizing that data with multiple parts of our application

React has a state mechanism built-in on a component level (the useState hook), but that's not the full-blown state manager yet - its scope is limited to the component in which it's defined. If we need to share some state across different parts of our app, we can use another React's built-in tool, the Context API. For more complex use cases we can use one of many-many external libraries - the infamous Redux, MobX, Zustand, Recoil, just to name a few.

The title of this post suggests, that there is one more option there for us. Indeed, the URL, especially the URL's query params, can work as a state manager as well - they do store data (not very complex, though), they can be updated when requested, and they are available from every place in our app by default.

#Why use URL's query params?

I want to make this clear upfront - the URL won't replace an actual state manager solution across our entire app. It's not like we can just uninstall Redux and keep all the state in the URL. I think it's quite obvious, but it wouldn't hurt to clarify.

That being said, the URL can complement our state manager for certain use cases. To find out what are those cases, let's enumerate what are the advantages of keeping the state in URL, or better, of having our application's state synchronized with the URL params:

  • you can share the URL with someone else, and they would get the exact same view of our app
  • you can bookmark and re-visit the page with the exact configuration you bookmarked it with
  • you can use browser's navigation buttons to move back and forth, instead of having to manually modify the form to revert to the state you were in a moment before the change
  • the application state persists after a reload (you could use LS/Cookies for that too)

With that in mind, what could URL params be useful for?

  • listing pages with filtering/sorting mechanisms (for products, posts or any other sets of items)
  • search results pages
  • product pages with different configurations for the same product
  • generally the cases where you would want to take advantage of the points mentioned above

#What are the challenges?

There is one, big challenge, coming from the fact, that the URL is somewhat external to our application. We need to use some special API, hopefully provided by the framework/library we are using, to handle two parts of this challenge:

  • the easy one - update URL whenever user caused the state to change, after the app is up and running
  • the hard one - parse the URL params (and provide default values for the ones that are unset) during the page load, so that the initial page would already be rendered in a proper configuration, defined by the query params

Those are the same challenges we are faced with when using local storage, cookies or any other external source of data, so if you've dealt with those in the past, you may have some intuition on how to do this the right or the wrong way. Generally, it's a good idea to keep the synchronization part of logic in one place.

The other challenge comes from the fact, that each value of each query param is a string by design. How do you keep numbers, arrays or dates in the URL? You need to parse them, both ways - when updating them in your app to push them to the URL and when reading them from the URL to use them in your app.

I'm going to write a part two to this article, in which I will show you my way of solving those problems, so if you are interested, you can check it out here.

#How to work with query params in Next.js 13+

Since I'm using Next.js to build this blog, I'm also gonna give examples based in this framework. Next.js 13 introduced the App Router and server components, so we have to cover both the client and the server side. They will work differently, because server components can't use hooks.

Let's start with client components, though. There are 3 hooks that will be relevant in this topic. They all can be imported from next/navigation:

  • useSearchParams - this hook provides us with the URLSearchParams object, containing all the params currently set in the URL. It will be most important for the initial render, where we have to extract the params from the URL to generate a proper view of the page.
  • useRouter - this hook gives us access to the router navigation API, such as router.back() or router.push(). We will use the latter to push new query params to the URL, whenever the user changes something on the page. IMPORTANT - this hook is now imported from next/navigation, not next/router.
  • usePathname - this hook returns a string, containing the current relative pathname (without the hostname or params, e.g. '/posts/all'). We will need it, to create a full path for router.push()

There is one more important object, built in JS - the URLSearchParams. It's a convenient interface to work with search params. This object has the get, set, delete methods, as well as a .toString() method, that would return a string with all currently set params combined, like name=Max&age=20. This object will be relevant soon.

Before we write any code, let's first look at the type definition for the useSearchParams hook:

useSearchParams(): ReadonlyURLSearchParams

The "readonly" part is important - it acts like the URLSearchParams object, but we can only use the read methods, like .get(), .has() or .entries(). Using methods like .set() or .delete() will cause errors. So... how can we actually set some new parameters? It will be best to write down a list of steps we need to perform both when parsing URL params and when pushing new params to define what we actually need and how can we achieve it.

Parsing URL params:

  1. Get all the params from useSearchParams hook
  2. (Try to) get the value of a specific param, if it's set
  3. Provide a default value if it's not set
  4. Use this value as an initial value for the first page render

This flow doesn't require us to modify query params yet. We only read them, so there is no issue with the fact that they are read-only. Now let's write down the second flow - the one for pushing new params.

Pushing new params:

  1. Get the new value that we want to push (most likely from onChange function)
  2. (Optionally) format this value to string if it's not string (but Date, for example)
  3. Read current URL state
  4. Perform router.push() with updated/added query param - merge this new query param into existing URL state

The fourth step is the most error-prone one. Remember, that we want to preserve all existing query params and only add/modify one of them.

All of currently set params are available through ReadonlyURLSearchParams object from useSearchParams hook, and the built-in URLSearchParams object have the .toString() method, so if only we could update this object provided by the hook, we will be settled... But we can! I mean, not directly. We can do a little trick:

const searchParams = useSearchParams(); const newSearchParams = new URLSearchParams(searchParams);

Now we can add, update or remove one particular parameter, without altering the rest of them. That's good! There will be a little different flow when removing param, but we will skip it for now.

useURLState.ts
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; export interface UseURLStateParams<Value> { paramName: string; fallback?: Value; valueToUrl: (value: Value) => string; urlToValue: (param: string) => Value; } export const useURLState = <Value = string>({ paramName, fallback, valueToUrl, urlToValue, }: UseURLStateParams<Value>) => { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); const newSearchParams = new URLSearchParams(searchParams); function onChange(newValue: Value | null) { if (!newValue) { newSearchParams.delete(paramName); } else { const parsedValue = valueToUrl(newValue); if (!parsedValue) { newSearchParams.delete(paramName); } else { newSearchParams.set(paramName, parsedValue); } } router.push(`${pathname}?${newSearchParams.toString()}`); }; const urlParamValue = searchParams.get(paramName); return { onChange, value: urlParamValue ? urlToValue(urlParamValue) : fallback, }; };

Related tags:

url
state
manager
management
query
params
useSearchParams
useRouter
usePathname
URLSearchParams
nextjs
app
router