Use URL persistence when you want a copied link to restore store data. In this browser-only React example, clicking Add a fish updates the count and the URL hash. Opening the copied URL in a new tab restores that count. You can switch the same store to query parameters with a localStorage fallback.
Keep the create, persist, and createJSONStorage setup, but replace its storage with a URL-backed StateStorage adapter; see Persist store data for the persistence setup.
1. Create hash storage
Add this file to your React project. getItem returns the stored string, or null when the key is absent. setItem updates one hash parameter, and removeItem deletes it; both preserve other hash parameters.
tsimport type { StateStorage } from 'zustand/middleware'
export const urlStorage: StateStorage<void> = {
getItem: (key) => {
const params = new URLSearchParams(window.location.hash.slice(1))
return params.get(key)
},
setItem: (key, value) => {
const params = new URLSearchParams(window.location.hash.slice(1))
params.set(key, value)
window.location.hash = params.toString()
},
removeItem: (key) => {
const params = new URLSearchParams(window.location.hash.slice(1))
params.delete(key)
window.location.hash = params.toString()
},
}
Treat the hash as a parameter list, not as a route or an element anchor.
2. Connect the adapter to a typed store
Use food-storage as the URL parameter name and share only fishes; see Type stores and middleware for typing storage with PersistStorage and selecting persisted fields with partialize.
tsimport { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { urlStorage } from './hash-storage'
type FishData = {
fishes: number
}
type FishStore = FishData & {
addAFish: () => void
}
export const useFishStore = create<FishStore>()(
persist(
(set) => ({
fishes: 0,
addAFish: () => set((state) => ({ fishes: state.fishes + 1 })),
}),
{
name: 'food-storage',
storage: createJSONStorage<FishData>(() => urlStorage),
partialize: (state) => ({ fishes: state.fishes }),
version: 0,
},
),
)
Each action calls set, which updates the store and writes the selected data to storage. The JSON value has the persistence envelope {"state":{"fishes":1},"version":0}, not just {"fishes":1}. URLSearchParams encodes that string in the URL.
3. Mount the React counter
Use separate selectors for the count and action. This entry point mounts the counter into your project's root element.
tsximport { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { useFishStore } from './fish-store'
function FishCounter() {
const fishes = useFishStore((state) => state.fishes)
const addAFish = useFishStore((state) => state.addAFish)
return (
<main>
<p>Fish: {fishes}</p>
<button onClick={addAFish}>Add a fish</button>
<p>Copy the address after adding fish to share this count.</p>
</main>
)
}
const root = document.getElementById('root')!
createRoot(root).render(
<StrictMode>
<FishCounter />
</StrictMode>,
)
With no stored value, the counter starts at zero. Click Add a fish: the count increases and the address gains a food-storage hash parameter. Copy the address and open it in a new tab. The synchronous adapter hydrates the store during initialization, restoring the count.
Try the authors' live hash demo.
4. Use query parameters instead
For query persistence, add the following adapter and change the import in fish-store.ts from ./hash-storage to ./query-storage. Keep the store and React counter unchanged.
This variant reads the named query parameter first and falls back to localStorage when that parameter is absent. Every update writes to both places. It uses history.replaceState to update the address in place without a refresh, preserving the path, other query parameters, and the hash.
tsimport type { StateStorage } from 'zustand/middleware'
export const urlStorage: StateStorage<void> = {
getItem: (key) => {
const url = new URL(window.location.href)
return url.searchParams.get(key) ?? window.localStorage.getItem(key)
},
setItem: (key, value) => {
const url = new URL(window.location.href)
url.searchParams.set(key, value)
window.history.replaceState(window.history.state, '', url.toString())
window.localStorage.setItem(key, value)
},
removeItem: (key) => {
const url = new URL(window.location.href)
url.searchParams.delete(key)
window.history.replaceState(window.history.state, '', url.toString())
window.localStorage.removeItem(key)
},
}
Click Add a fish to populate the query parameter, then copy the address. Opening that link restores its count even if the receiving browser has a different locally stored count.
The authors' example conditionally writes to the URL only when query parameters already exist. This variant always populates the URL, so you can share state starting from an address with no query string.
Try the authors' live query demo.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
name | string | Required | Sets the storage key and therefore the URL parameter name. Use a unique name per store. |
storage | PersistStorage<FishData> | createJSONStorage(() => window.localStorage) | Replaces browser local storage with your JSON-wrapped URL adapter. |
partialize | (state: FishStore) => FishData in this example | (state) => state | Selects the fields to persist and share. |
version | number | 0 | Adds a version to the persistence envelope. A differing stored numeric version needs a migration to be used. |
Pitfalls
- Do not parse or stringify JSON again inside these adapters.
createJSONStoragealready does both. Returnnullfor a missing key, not an empty string that fails JSON parsing. - URL data is editable.
createJSONStorageparses JSON but does not validate its shape. For production use, implement a validatingPersistStoragebefore accepting untrusted shared state. - These adapters persist updates; they do not register listeners for subsequent URL navigation. Open the copied link as a new page to hydrate it. For explicit hydration control, see Control persistence hydration.
- Keep browser-only URL storage out of a shared server store. For server-rendered React applications, see Next.js store setup.
Related
- Persist store data — storage configuration and asynchronous hydration.
- Migrate persisted state — keep older shared links compatible when the persisted shape changes.
Was this page helpful?