skip to content

A React component reads navigator.onLine during render and then subscribes to the window 'online' and 'offline' events inside useEffect. Which changes can this pattern miss, and how do you close the gap?

level: middleimportance: should knowfreq 42%

answer

  1. render first, subscription later
  2. one window with no listener attached
  3. events are not replayed
  4. re-read after you subscribe
  5. the same window reopens on re-subscribe

basics

~20 s

Any change that happens between the render which read the value and the effect that subscribes after paint is missed entirely, leaving stale UI until the next change arrives. Close it by re-reading the source inside the effect right after subscribing.

solid answer

~40 s

Reading the value and subscribing to it are two separate moments. The read happens during render; the effect runs after React commits and the browser paints. In that window nothing is listening, and an event listener does not replay what it missed — so if the connection drops in that window, the component keeps showing "online" until the *next* status flip, which may never come. The fix is cheap: inside the effect, after `addEventListener`, call the same sync function once to re-read `navigator.onLine` and update state if it differs. Note this belongs inside the effect body, not in a mount-only branch, because the same window reopens every time the effect tears down and re-subscribes. This is one of the concrete correctness holes in a hand-rolled `useState` + `useEffect` subscription that `useSyncExternalStore` closes for you.

go deeper

for a junior

Know that an effect runs after the component has rendered and painted, not during render, and that an event listener only hears things that happen after it is attached.

for a middle

Explain the window between the render-time read and the effect-time subscribe, and show the one-line fix: call the sync function once more immediately after subscribing, inside the effect body.

for a senior

Recognise the signature in a bug report — a value that is occasionally stuck wrong and heals itself on the next real change — and say which subscriptions in the codebase you would audit for a missing post-subscribe read.

for a principal

Frame it as a class of defect, not a bug: hand-rolled subscriptions make every team re-solve initial synchronisation. Argue when the team should standardise on useSyncExternalStore so the gap stops being an implementation detail anyone can get wrong.

## Two moments, not one A hand-rolled subscription to an outside system always has two distinct steps: 1. **Read** the current value, so there is something to render on the first paint. This happens during render — `useState(() => navigator.onLine)`, or a direct read in the component body. 2. **Subscribe** to future changes, so later updates arrive. This happens in an effect, which React runs after it has committed the tree and the browser has painted. Candidates picture these as one atomic operation. They are not. Between them the component has already rendered and the browser has already painted, which means real time has passed — a full frame at minimum, more if the commit was large or the main thread was busy. ## What lives in the gap During that window nobody is listening. The browser does not queue `online`/`offline` events for listeners that do not exist yet; the DOM event model is fire-and-forget. So a status change in the gap is simply never delivered. The result is not a momentary glitch — it is a permanently wrong UI. State was initialized to `true`, the network dropped before the listener attached, and the component will now show "online" until the network *changes again*. If the user stays offline, that is forever. ```js useEffect(() => { const sync = () => setIsOnline(navigator.onLine); window.addEventListener('online', sync); window.addEventListener('offline', sync); sync(); // re-read: the status may have flipped since render return () => { window.removeEventListener('online', sync); window.removeEventListener('offline', sync); }; }, []); ``` The single `sync()` call after subscribing is the whole fix. Once the listener is attached, re-reading the source can no longer race with it: any change from that instant on arrives as an event, and any change before it is captured by the read. Order matters — subscribe first, then read. Read-then-subscribe leaves a smaller version of the same gap. ## The gap is not only a mount problem This is why the re-read belongs in the effect body rather than in some "first time only" branch. Whenever a dependency changes, React tears the old subscription down and sets a new one up, and the interval between those two is another unlistened window. A subscription keyed on, say, a room id or a query string re-opens the hole on every key change. Putting the re-read in the effect body means every subscribe is followed by a read, whichever run it is. ## Why not just poll? Some candidates reach for `setInterval` to "stay in sync." That trades a correctness hole for constant background work and a worst-case staleness equal to the interval. Polling is a fallback for sources that offer no change notification at all; when a subscription exists, subscribe and re-read. ## Why not initialize harder? Moving the read into `useState`'s lazy initializer is good practice — it avoids calling the browser API on every render — but it does not help here. The initializer runs during the first render, which is on the wrong side of the gap. Nothing you do during render can observe a change that happens after render. ## What this says about hand-rolled subscriptions The gap is one of a small family of defects that every hand-written `useState` + `useEffect` subscription has to solve by hand, alongside making sure the subscribe and unsubscribe stay symmetric and making sure two components reading the same source during one render pass agree with each other. `useSyncExternalStore` exists precisely because these are not things each feature team should be re-solving: React takes over reading the value, and checks the source again around subscribing so a change in the window is not lost. Being able to name the gap concretely — "an event that fires between render and the effect is gone" — is what separates an answer that has hit this bug from one that has only read about the hook. ## Diagnosing it in the wild The symptom is a component that is right most of the time and stubbornly wrong occasionally, usually after a slow first load or a heavy commit, and which corrects itself the moment the underlying source changes once more. That self-correcting-on-next-change signature is the tell: the subscription works, the initial synchronisation did not.

  • Does the same gap reopen when the effect re-runs because a dependency changed?
    Yes. A dependency change means teardown then setup, and the source can change in between exactly as it can at mount. That is why the re-read goes in the effect body after the subscribe call rather than in a first-run-only branch — every subscribe deserves its own read.
  • Why isn't a lazy useState initializer enough to fix this?
    Because the initializer runs during the first render, on the wrong side of the gap. It gives you a correct starting value at render time and avoids re-calling the browser API each render, but nothing evaluated during render can observe a change that happens after render and before the effect.
  • Should you subscribe first and then read, or read and then subscribe?
    Subscribe first, then read. With the listener already attached, any change after the read arrives as an event, so the two mechanisms overlap with no hole. Reading first leaves a small window between the read and the subscribe where a change is still lost.

saying these in an interview costs you the question

  • Assumes the effect runs synchronously with the render that read the value
  • Thinks the browser replays events fired before a listener existed
  • Says an empty dependency array guarantees the value stays fresh
  • Reaches for a polling interval instead of re-reading after subscribing
  • Believes the problem only exists under server rendering

context