Skip to content
D
Documentation

Sync state with URL hash

how-to
2 min readUpdated

Sync state with a URL hash

Use create with a custom StateStorage adapter and persist when a shareable URL must restore selected Zustand state. This example stores a fish count in the URL hash and renders the count in React.

When to use this

Use this pattern for small, shareable state such as a filter, selected tab, or view setting. The storage adapter keeps the URL representation separate from the store; persist still performs serialization and rehydration.

Store the state in the hash

Create the hash adapter, then pass it through createJSONStorage. The name is the key inside the hash, so choose a unique name for the store.

tsx
import { create } from 'zustand'
import {
  createJSONStorage,
  persist,
  type StateStorage,
} from 'zustand/middleware'

type FishStore = {
  fishes: number
  addAFish: () => void
}

const hashStorage: StateStorage = {
  getItem: (key) => {
    const searchParams = new URLSearchParams(window.location.hash.slice(1))
    return searchParams.get(key)
  },
  setItem: (key, value) => {
    const searchParams = new URLSearchParams(window.location.hash.slice(1))
    searchParams.set(key, value)
    window.location.hash = searchParams.toString()
  },
  removeItem: (key) => {
    const searchParams = new URLSearchParams(window.location.hash.slice(1))
    searchParams.delete(key)
    window.location.hash = searchParams.toString()
  },
}

export const useFishStore = create<FishStore>()(
  persist(
    (set, get) => ({
      fishes: 0,
      addAFish: () => set({ fishes: get().fishes + 1 }),
    }),
    {
      name: 'fish-store',
      storage: createJSONStorage<FishStore>(() => hashStorage),
    },
  ),
)

createJSONStorage writes the persisted envelope as JSON. After an update, the hash contains an encoded fish-store value with the state and its persistence version. A new page load reads that value before the component displays the restored count.

Render and update it in React

Read the state and action through the store hook. Mounting this component gives you a visible count and a button; clicking the button updates both the count and the URL hash.

tsx
import { useFishStore } from './fish-store'

export function App() {
  const fishes = useFishStore((state) => state.fishes)
  const addAFish = useFishStore((state) => state.addAFish)

  return (
    <main>
      <p>Fishes: {fishes}</p>
      <button type="button" onClick={addAFish}>
        Add a fish
      </button>
    </main>
  )
}

The component re-renders when its selected fishes value changes. Copy the page URL after adding fish, open it in a new tab, and the store rehydrates the count from the hash.

Use a query parameter instead

A query-string adapter has the same StateStorage shape. Use history.replaceState in setItem so updating the state changes the URL without navigating or refreshing the page. The adapter below reads the store's persisted value from ?fish-store=... and writes it in place.

ts
import type { StateStorage } from 'zustand/middleware'

export const queryStorage: StateStorage = {
  getItem: (key) => {
    return new URLSearchParams(window.location.search).get(key)
  },
  setItem: (key, value) => {
    const searchParams = new URLSearchParams(window.location.search)
    searchParams.set(key, value)
    window.history.replaceState(
      null,
      '',
      `${window.location.pathname}?${searchParams.toString()}${window.location.hash}`,
    )
  },
  removeItem: (key) => {
    const searchParams = new URLSearchParams(window.location.search)
    searchParams.delete(key)
    const query = searchParams.toString()
    window.history.replaceState(
      null,
      '',
      `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`,
    )
  },
}

Replace the storage option in the store with createJSONStorage(() => queryStorage). The rest of the store and component remain unchanged. If the query string is empty, this adapter leaves it empty until the first persisted update.

Options that matter

OptionTypeDefaultWhat it does
namestring—Names the persisted value and must be unique.
storagePersistStoragecreateJSONStorage(() => window.localStorage)Selects the storage implementation used to read, write, and remove persisted state.
partialize(state: Object) => Object(state) => stateSelects which fields go into the URL.
versionnumber0Identifies the persisted state format.
migrate(persistedState: Object, version: number) => Object | Promise<Object>(persistedState) => persistedStateConverts a persisted value from an older version.

Pitfalls

  • URL values are visible and shareable. Persist only state that is safe to put in a URL.
  • The URL adapter makes persisted values visible and shareable, so validate untrusted or stale URL data before treating it as store state. See Persist store data for the createJSONStorage, JSON.parse, and JSON.stringify details.
  • If you use asynchronous persisted storage elsewhere, hydration can finish after the initial render. See Persist store data.

Live demos

Was this page helpful?

Sync state with URL hash — zustand · GPT-5.6 Luna