Become a GraphQL expert

We're launching a brand new course. Pre-sale is now live.

View course

Overstacked

Tue Jul 30 2024

Persisted url state with React

Simple hooks to persist state between react and the URL.

cover image

Let's say you have a react component which provides some search functionality.

import React, { useState } from 'react'

export default function Search() {
  const [query, setQuery] = useState({})

  const onSearch = (event) => {
    setQuery((prevQuery) => ({
      ...prevQuery,
      search: event.target.value,
    }))
  }

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={onSearch}
      />
      <SearchResults query={query} >
    </div>
  )
}

This component works fine, but there's a problem: if the user reloads the page, the search query will be lost. This is because the component's state is stored in memory.

In this article, we'll learn how to persist state across page reloads using a react hook to sync state with the URL.

Persisting state in the URL

Persisting state to the URL can be as simple as replacing useState with a custom hook that reads and writes to the URL.

The hook itself will be simple to use, although the implementation can become quite complex. We'll start with a basic example and then expand on it to handle more complex state.

const [query, setQuery] = useState('') // << Standard useState hook
const [query, setQuery] = useUrlState('') // << Custom hook that persists state to the URL

With this abstraction we don't need to worry about how the data is stored, we can just switch out the hook and interact with state in the same way.

So let's jump into a basic implementation of useUrlState.

const useUrlState = <S extends Record<string, any>>(initialState: S) => {
  // Load in the initial state to useState
  const [state, setState] = useState<S>(initialState)

  // Every time the state changes we persist it to the url
  useEffect(() => {
    // Here we convert the react state object to a search string
    const url = new URL(window.location.href)
    for (const key in state) {
      const value = state[key] as string
      url.searchParams.set(key, value)
    }
    // We then replace the current url with the new one
    window.history.replaceState({}, '', url.toString())
  }, [state])

  // On mount only, we read the state from the url and load it back
  // into our local state. This will be triggered when a user navigates
  // to the page or refreshes the page.
  useEffect(() => {
    // We do the opposite of the other useEffect, we read the search params
    // from the url and load them into our state as an object.
    const url = new URL(window.location.href)
    const queryParams = Object.fromEntries(url.searchParams.entries())
    if (url.searchParams.size > 0) {
      setState(queryParams as S)
    }
  }, [setState])

  // Return the state and setState as normal
  return [state, setState] as const
}

This hook would work for simple state, but it's not very flexible. It only works for strings and doesn't handle nested objects or arrays. Let's go further to handle more complex state.

Handling nested objects and arrays

To expand on the simple example we need two helpers functions to handle serializing and deserializing the state.

These functions work on nested objects by converting them to a flat query string with dot notation. For example, { a: { b: 1 } } would be converted to ?a.b=1 and vice versa.

/**
 * Converts a query string to an object
 * @param str The query string to convert
 * @returns The object representation of the query string
 */
const deserialize = (str: string): Record<string, any> => {
  const obj: Record<string, any> = {}

  const pairs = decodeURI(str).split('&')
  pairs.forEach((pair) => {
    if (!pair) {
      return
    }

    const [key, value] = pair.split('=')
    const nestedKeys = key.split('.')
    let currentObj = obj

    nestedKeys.forEach((nestedKey, i) => {
      if (i === nestedKeys.length - 1) {
        const lastValue = value?.includes(',') ? value?.split(',') : value
        currentObj[nestedKey] = lastValue
      } else {
        if (!currentObj[nestedKey]) {
          currentObj[nestedKey] = {}
        }
        currentObj = currentObj[nestedKey]
      }
    })
  })

  return obj
}
/**
 * Converts an object to a query string
 * @param obj The object to convert
 * @param parentKey The parent key of the object
 * @returns The query string representation of the object
 */
const serialize = (obj: Record<string, any>, parentKey?: string): string => {
  const str = Object.keys(obj)
    .map((key) => {
      const nestedKey = parentKey ? `${parentKey}.${key}` : key
      const value = obj[key]

      if (value === null || value === undefined) {
        return null
      }

      if (typeof value === 'object') {
        return serialize(value, nestedKey)
      }

      return `${nestedKey}=${value}`
    })
    .filter((e) => e !== null)
    .join('&')

  return encodeURI(str)
}

Now we can update our useUrlState hook to use these helpers.

const useUrlState = <T extends Record<string, any>>(
  initialState: T = {} as T
) => {
  const isServer = typeof window === 'undefined'
  const searchString = !isServer && window.location.search.slice(1)
  const [state, setState] = useState<T>(
    searchString ? (deserialize(searchString) as T) : initialState
  )

  useEffect(() => {
    const queryString = serialize(state)
    const newUrl = `${window.location.pathname}?${queryString}${window.location.hash}`
    window.history.replaceState({}, '', newUrl)
  }, [state])

  return [state, setState] as const
}

And if you're using next.js, you can use the useRouter instead of the native window.history object.

const useUrlState = <T extends Record<string, any>>(
  initialState: T = {} as T
) => {
  const router = useRouter()
  const isServer = typeof window === 'undefined'
  const searchString = !isServer && window.location.search.slice(1)
  const [state, setState] = useState<T>(
    searchString ? (deserialize(searchString) as T) : initialState
  )

  useEffect(() => {
    const queryString = serialize(state)
    const newUrl = `${window.location.pathname}?${queryString}${window.location.hash}`
    router.replace(newUrl, undefined, { shallow: true })
  }, [state])

  return [state, setState] as const
}

With this you can now swtich out useState with useUrlState and have your state persisted to the URL.


You can get more actionable ideas in my popular email newsletter. Each week, I share deep dives like this, plus the latest product updates. Join over 80,000 developers using my products and tools. Enter your email and don't miss an update.

You'll stay in the loop with my latest updates. Unsubscribe at any time.

© Copyright 2024 Overstacked. All rights reserved.

Created by Warren Day