Sign-ups paused

Sign-ups and billing are temporarily unavailable while we rebuild our infrastructure. The SDK and documentation remain available, and existing accounts are unaffected.

Vaultrice

Blog / Tutorials

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.

5 min read Updated 2026-08-XX @vaultrice/sdk

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.