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.