Where stray thoughts land,

An alarm I turned on in another tab wasn't showing up.

Subscribing to storage and visibilitychange together.

A user turns the housing-subscription alarm on in one tab, switches back to another, and the UI still says “alarm is off.” The localStorage value is right; the screen is stale. When two tabs look at the same key but disagree about it, the user thinks “I just turned this on, why doesn’t it show?” — and trust takes a hit.

1. Diagnose

I traced the data flow first. The alarm’s on/off state was managed by a jotai atom variant called atomWithSafeLocalStorage — it reads from localStorage on mount and writes back on change.

Two important caveats here. First, jotai’s sync channel is bound to the React tree. Within one React app, atoms stay in sync. But if a different tab on the same origin mutates the same key, our app has no way of knowing. Second, on mobile webviews where a native bridge writes localStorage directly — say, another screen has the native side setItem a value — it has the same blind spot.

Then there’s a mobile-specific scenario. A user backgrounds the app, comes back ten minutes later, and during that gap localStorage may have changed. Background-to-foreground transitions don’t trigger React re-renders, so they need to be picked up by a separate event.

Both channels needed handling for the alarm UI to always reflect reality.

2. Reproduce

Simplest possible repro:

  • Tab A: turn the alarm on. localStorage flips to true.
  • Tab B: open the same page. The screen still says “alarm is off.”
  • Tab A toggles again, or a different screen has native set the value — Tab B doesn’t budge.

Alternative scenario:

  • User turns the alarm off and backgrounds the app.
  • A different screen (another tab or native) turns it on.
  • User foregrounds the app. The screen still says “alarm is off.”

Both scenarios needed an external channel that our app could listen on for changes happening outside its render cycle.

3. Fix

I added a subscribe method to the SafeLocalStorage class. It listens to two events at once — storage (changes from another tab) and visibilitychange (returning from background) — and calls the callback whenever either fires.

subscribe(
  key: string,
  callback: (value: string | null) => void,
): () => void {
  if (typeof window === 'undefined' || typeof document === 'undefined') {
    return () => {}
  }

  const handleStorageEvent = (e: StorageEvent) => {
    if (e.storageArea === localStorage && e.key === key) {
      callback(e.newValue)
    }
  }

  const handleVisibilityChange = () => {
    if (document.visibilityState === 'visible') {
      const value = this.getItem(key)
      callback(value ?? null)
    }
  }

  window.addEventListener('storage', handleStorageEvent)
  document.addEventListener('visibilitychange', handleVisibilityChange)

  return () => {
    window.removeEventListener('storage', handleStorageEvent)
    document.removeEventListener('visibilitychange', handleVisibilityChange)
  }
}

A common gotcha to remember: the storage event fires only when a different tab mutates localStorage. Calling localStorage.setItem from the same tab does not fire it. So same-tab sync is handled by the jotai atom mechanism we already had, and storage covers cross-tab. Their roles separate cleanly.

visibilitychange is a different kind of safety net. It fires when the app returns to the foreground — regardless of whether the change came from another tab or from native. Re-reading localStorage on 'visible' lets the app catch up to anything that happened while it wasn’t watching.

With this subscribe plumbed into the atom, toggling the alarm anywhere — same tab, another tab, while backgrounded — keeps the UI in sync.

4. Prevent recurrence

When working with external state like localStorage, I now sketch out every channel through which it might change before writing the code.

First, same tab vs. other tab. Code that assumes a single tab breaks the moment a user opens the site in two. Knowing that storage events never fire in the originating tab leads naturally to “same-tab sync needs its own channel.”

Second, foreground vs. background. On mobile, users spend a lot of time off-screen. Using visibilitychange’s 'visible' transition as a sync trigger lets you catch up to anything that happened in the gap, all at once — far more efficient than polling.

Third, generalize the pattern to other external state. Cookies, IndexedDB, or anything coming through a native bridge follow the same shape. The question is always: “can this value change without my app knowing?” If yes, design the listening channel up front.

Syncing external state with React state is one of those areas that looks fine until it occasionally doesn’t. Mapping out the channels of change early makes the “occasionally” disappear before it ever shows up in production.