The client half of our quiz engine is one custom hook, useGameWs. It is 444 lines. It contains:
- 15
useRefcalls - 1
useState - 6
useEffect - 10
useCallback
And this is its entire return value:
export interface UseGameWsResult {
status: WsStatus
}
One string from a union of six: connecting, auth, connected, reconnecting, polling, error. Every player's phone in the pub runs this, and the only thing the UI learns from it is which of six words applies, plus the events that get handed to a callback.
The ratio of refs to state is the interesting part, so let us start there.
Why fifteen refs is not a code smell here
Here is what they hold:
const wsRef = useRef<WebSocket | null>(null)
const mountedRef = useRef(true)
const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollingFallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollFailuresRef = useRef(0)
const isPollingRef = useRef(false)
const lastMessageAtRef = useRef(0)
const sessionEndedRef = useRef(false)
A socket, three timer handles, two counters, three flags, a timestamp. Not one of those is something React should re-render for. Nothing on screen changes because a reconnect attempt counter went from 3 to 4, or because a timer handle was replaced. These are the moving parts of a machine that happens to live inside a component, and the correct amount of React involvement in them is zero.
The rule I would write down, having made the mistake in the other direction first: state is what the UI reads; a ref is what the machine remembers. Promoting any of those ten to state would buy nothing and cost a render per tick, on sixty phones, during a question.
The ref that mirrors the state
This one is different, and its comment is the most useful thing in the file:
/**
* Mirrors `status` synchronously.
*
* React may invoke a setState updater more than once (StrictMode, the React
* Compiler), so side effects must never run from inside one.
*/
const statusRef = useRef<WsStatus>('connecting')
const setStatus = useCallback((next: WsStatus) => {
statusRef.current = next
setStatusState(next)
}, [])
There are two reasons this exists, and only the second one is really about React.
The first is timing. A socket onclose handler needs to know what the status is now, in the same tick, to decide whether to schedule a backoff reconnect or a slower degraded retry. status from useState is the value captured when the callback was created. statusRef.current is the value this instant. Async event handlers that outlive a render need the ref.
The second is the sharp one. The obvious trick for reading current state inside a callback is setStatus(current => { if (current === 'x') doSomething(); return next }), using the updater argument to peek at the latest value. React is explicitly allowed to call that updater more than once, and in development StrictMode it deliberately does. So doSomething() fires twice, and in this file doSomething() is "open a WebSocket" or "start a polling loop". We run with reactCompiler: true as well, which is another reason not to assume anything about how often a function React owns gets called. A synchronous mirror is the boring, correct answer.
The ref that exists to break a cycle
This one I am less proud of:
// connect and scheduleReconnect call each other through timers; going via a
// ref keeps their useCallback dependency lists honest.
const connectRef = useRef<() => void>(() => {})
connect schedules a reconnect on close. scheduleReconnect calls connect when its timer fires. Two useCallbacks that depend on each other cannot both list the other as a dependency, so one of them goes through a ref that an effect keeps pointed at the latest version.
It works, and the comment is honest about what it is. But a mutable function pointer is the kind of thing that is obvious for a week and mysterious for a year, and it is the clearest sign in the file that a state machine written as a reducer, with the socket handled in one effect, would be a better shape than ten mutually recursive callbacks. I have not rewritten it because it currently works on a Friday night with sixty phones on pub wifi, and "it is the wrong shape" has never once been the reason an evening went wrong.
There is a second transport hiding in here
This is the part most WebSocket hook tutorials do not have. If reconnection has not succeeded 20 seconds after the socket dropped, the hook stops waiting and starts polling:
const POLLING_FALLBACK_MS = 20_000
const POLL_INTERVAL_MS = 5_000
const POLL_JITTER_MS = 1_500
const MAX_POLL_FAILURES = 3
const WS_RETRY_WHILE_DEGRADED_MS = 30_000
The poll calls a Next.js server action that returns a full state snapshot, which the hook feeds to the same onEvent callback the socket would have, as a synthetic state_snapshot event. The component above it cannot tell the difference. Meanwhile the socket is still being retried in the background every 30 seconds, and the moment a reconnect gets as far as auth_ok, polling stops and the status flips back to connected.
Two things in those five constants are there because of the room rather than the code.
The jitter is because a pub is a correlated failure. When the access point behind the bar hiccups, it does not drop one phone, it drops every phone, and they all start their fallback timer in the same second. Without jitter you get sixty requests arriving in lockstep every five seconds. With up to 1.5 seconds of random spread, the same sixty phones smear across the interval.
The 30 second background retry while degraded is the opposite concern: once you have accepted a working fallback, hammering the thing that is broken is how you turn a degraded evening into a dead one.
And the failure counter only gives up after three consecutive failed polls, because the honest base rate for one failed request on pub wifi is "that happens".
Waking up is its own problem
The single most common thing a player does during a quiz is put their phone down, which locks it, which freezes every timer in the page and often kills the socket without telling the page about it. So:
const wake = () => {
if (document.visibilityState !== 'visible' || sessionEndedRef.current) return
const ws = wsRef.current
const fresh = Date.now() - lastMessageAtRef.current < WAKE_FRESH_MS
if (ws && ws.readyState === WebSocket.OPEN && fresh && statusRef.current === 'connected') {
// Alive, but events may have been missed while the page was frozen.
ws.send(JSON.stringify({ type: 'sync' }))
return
}
reconnectNow()
}
document.addEventListener('visibilitychange', wake)
window.addEventListener('online', wake)
window.addEventListener('pageshow', wake)
Three events, one handler, two outcomes. If the socket is open and we heard from the server within the last 25 seconds, it is probably fine and just needs a resync, so ask for one. Otherwise do not negotiate: tear it down and reconnect immediately, skipping the backoff, because a player staring at a dead screen does not care that we are being polite to the server.
pageshow is in that list for the back/forward cache. A phone returning to the tab from bfcache gets a pageshow and not always a visibilitychange, and a restored page comes back with a socket that is already dead.
The watchdog covers the inverse case, a half-open socket that is open as far as the browser is concerned and connected to nothing:
const STALE_SOCKET_MS = 50_000 // server heartbeats every 20 s
Fifty seconds of silence on a socket that should speak every twenty means two missed heartbeats, which is a dead connection wearing a readyState === OPEN costume. It gets replaced.
What this buys, in the room
All of this exists so that a player who joined by scanning the code on their table never has to do anything about any of it. They did not install an app, they did not make an account, and the entire client is a browser tab on a phone that was in a pocket thirty seconds ago. There is no "reconnect" button in the design, because the design assumes nobody will press it.
And the thing all this machinery is protecting is one specific moment: the leaderboard moving the instant a question closes. That is the product. A table seeing between question four and question five that they have gone from third to second is worth 444 lines of connection paranoia, because the alternative, a phone that quietly stopped updating and a table that finds out at the end, is the exact failure a paper quiz does not have.
If I rewrote the hook tomorrow, the structure would change and the constants would not. The numbers are what we learned from the room; the callbacks are just how this version happens to be wired.













