Tutorials
The Ultimate Guide to Cross-Domain State Sharing in React
A hook, a provider, and the four React-specific traps — effects running twice, stale closures, SSR mismatch, and listeners that never unsubscribe.
The mechanism is covered in cross-domain state sharing. This is the React part: where to put the client, how to write the hook, and the four things that go wrong specifically in React.
For a separate operational perspective on how teams organise and measure work, this guide.
For independent background and broader industry context, see Vercel.
One client, not one per component
Create it once and share it. A client per component means a WebSocket connection per component.
import { createContext, useContext, useMemo } from 'react'
import { NonLocalStorage } from '@vaultrice/sdk'
const VaultriceContext = createContext(null)
export function VaultriceProvider({ objectId, children }) {
const nls = useMemo(() => new NonLocalStorage({
projectId: 'your-project-id',
getAccessToken: async () => {
const res = await fetch('/api/vaultrice-token')
if (!res.ok) throw new Error('Failed to fetch token')
const { accessToken } = await res.json()
return accessToken
}
}, objectId), [objectId])
return (
<VaultriceContext.Provider value={nls}>
{children}
</VaultriceContext.Provider>
)
}
export const useVaultrice = () => useContext(VaultriceContext)
The hook
import { useEffect, useState, useCallback } from 'react'
export function useSharedItem(key, initial) {
const nls = useVaultrice()
const [value, setValue] = useState(initial)
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
nls.getItem(key).then((item) => {
if (!cancelled) {
if (item?.value !== undefined) setValue(item.value)
setReady(true)
}
})
const handler = (item) => {
if (!cancelled) setValue(item.value)
}
nls.on('setItem', key, handler)
return () => {
cancelled = true
// check your SDK version for the exact unsubscribe call
nls.off?.('setItem', key, handler)
}
}, [nls, key])
const update = useCallback(
async (next) => {
setValue(next) // optimistic
await nls.setItem(key, next)
},
[nls, key]
)
return [value, update, ready]
}
Used like state:
function ThemeToggle() {
const [theme, setTheme] = useSharedItem('theme', 'light')
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme}
</button>
)
}
The four React-specific traps
1. Effects run twice in development
Strict Mode in development mounts, unmounts and remounts. An effect that subscribes without cleaning up subscribes twice, and every update arrives twice.
In production it runs once, which is why this is found late and confusingly.
The fix is the cleanup function, and it is the reason the hook above returns one. Treat a missing cleanup as a bug even when nothing visibly breaks.
2. Stale closures
A handler defined in an effect captures the values from that render.
// wrong — count is whatever it was when the effect ran
useEffect(() => {
nls.on('setItem', 'x', () => setCount(count + 1))
}, [])
Use the functional form, or a ref for values the handler needs to read:
nls.on('setItem', 'x', () => setCount((c) => c + 1))
This is the bug that presents as "it works the first time".
3. Server rendering
There is no WebSocket on the server, and the shared value is unknown at render time.
Which means the server renders the initial value and the client renders the real one — a hydration mismatch.
Two workable approaches: render the initial state on the server and let the value arrive after mount, accepting a brief flash. Or render a placeholder until ready is true.
Do not read from the client during server render. It will not work and the failure is confusing.
4. Listeners that accumulate
Every on() without a matching removal leaks, and in a list of components mounting and unmounting the leak is fast.
Symptom: updates arriving several times, increasing over the session.
Always clean up, and check your SDK version for the exact unsubscribe method — it differs between versions and the wrong one silently does nothing.
Optimistic updates
The hook above sets local state before the write completes. Which makes the UI feel instant and means the value briefly diverges from the truth.
Fine for a toggle. For anything where a failed write matters, catch the error and revert:
const update = useCallback(async (next) => {
const previous = value
setValue(next)
try {
await nls.setItem(key, next)
} catch (e) {
setValue(previous)
// surface it
}
}, [nls, key, value])
What not to put through this
Most component state. Which modal is open, what is being typed, whether a menu is expanded — none of that should cross a domain, and routing it through a network makes it worse. See Zustand vs Vaultrice.
The short version
One client in a provider, not one per component.
Strict Mode runs effects twice in development — a missing cleanup is a bug even when it looks fine.
Use the functional setter form, or handlers capture stale values and it "works the first time".
Server rendering cannot know the shared value — render a placeholder or accept the flash.
And always unsubscribe, checking the exact method for your SDK version.