How to Maintain React State in a URL Query String for a Shareable Application State

October 5, 2023

4 min read

How to Maintain React State in a URL Query String for a Shareable Application State
Watch on YouTube

Introduction to Maintaining React State in a URL Query String

In this post, I'll share with you an invaluable hook for maintaining React state in a URL query string. This comes in particularly handy when you wish to make your application state shareable via a URL. I'll be showcasing a practical example today, a pagination parameter for my NextJS app. I'm currently developing an application that includes a list of exam tickets, and I want to enable the sharing of a specific ticket from this list via a direct link. The reusable components and hooks discussed in this video can all be found in the RadzionKit repository on GitHub.

How to Utilize the Hook in an App

Here's how to utilize the hook in an app:

const [ticket, setTicket] = useQueryParamState<number>("ticket", 1)

Supporting Various Types of Values

We feed the name of the query parameter and the initial value to the hook. In return, we receive identical value and set value pairs as we would with the useState hook. The only deviation is that the value is sustained in the URL query string.

Role of useRouter and useState within useQueryParamState Hook

Our hook will be a generic function, allowing us to support various types of values. However, the query parameters will most commonly be storing either a string or a number.

import { useRouter } from "next/router"
import { useCallback, useState } from "react"
import { useHandleQueryParams } from "./useHandleQueryParams"

const parseValue = <T,>(value: any): T => {
  try {
    return JSON.parse(value) as T
  } catch {
    return value as never as T
  }
}

export const useQueryParamState = <T extends number | string>(
  key: string,
  defaultValue: T
) => {
  const [value, setValue] = useState<T>(defaultValue)

  const { push, query, pathname } = useRouter()

  const onChange = useCallback(
    (newValue: T) => {
      push({
        pathname,
        query: {
          ...query,
          [key]: newValue,
        },
      })
    },
    [key, pathname, query, push]
  )

  useHandleQueryParams<Record<string, T | undefined>>((params) => {
    if (params[key] === undefined) {
      return
    }

    const newValue = parseValue<T>(params[key])
    if (newValue === value) return

    setValue(newValue)
  })

  return [value, onChange] as const
}

Avoiding Default Value Update in the Query Param

Within the useQueryParamState hook, we continue to rely on the useState hook to preserve the value in the component state. This is done to force a re-render when the value changes. We also utilize the useRouter hook from NextJS to gain access to the present URL query string and the push function. The latter will be used to update the URL query string – you could alternatively use the replace function if you prefer not to add a new entry to the browser history with every change in value.

The Role of onChange Function

In this hook, we avoid updating the query param with the default value. This only transpires when the user alters the value. Depending on your app, you might want to set the default value in the URL query string upon the first render if it isn't already present.

The Importance of Parsing Value Type

Instead of the setValue function, the hook's consumer is provided with the onChange function. This ensures that the value is consistently updated in the URL query string, and not just within the component state. We avoid invoking the setValue function inside the onChange function since we already monitor for query param alterations in the useHandleQueryParams hook. Here, we verify whether the query parameter with the designated key exists in the URL query string. If present, we parse the value and accordingly update the component state. Although the query object derived from the useRouter hook is "parsed", it has been observed that it often returns numbers as strings. Therefore, it's preferable to undertake an additional parsing step to ensure the correct value type.

import { useRouter } from "next/router"
import { useEffect } from "react"

type QueryParamsHandler<T> = (params: T) => void

export const useHandleQueryParams = <T,>(handler: QueryParamsHandler<T>) => {
  const { isReady, query } = useRouter()

  useEffect(() => {
    if (!isReady) return

    handler(query as unknown as T)
  }, [isReady, query, handler])
}

Description of useHandleQueryParams Hook

The useHandleQueryParams hook is a simple wrapper around the useEffect hook. It calls the handler function once the router is ready and the query parameters are accessible. We need to perform a type assertion because the query type renders every field as optional. However, we will regularly employ this hook in situations where we are certain that the query parameters are present. A typical scenario would be on an authentication page, where we would normally expect to receive a code from the OAuth provider. Without this query parameter, the process could not be completed.