Real-time State Sync (Socket.io)
Core architecture: three layers
┌─────────────┐ state:sync ┌──────────────┐ React hook ┌────────────┐
│ Server │ ──────────────▶ │ Client │ ────────────▶ │ Components │
│ (authority)│ │ (singleton) │ │ (subscribe)│
│ server.mjs │ ◀────────────── │ socket- │ ◀──────────── │ │
│ │ admin:*, etc. │ client.ts │ emit() │ │
└─────────────┘ └──────────────┘ └────────────┘
- Server holds the single source of truth in memory
- Client singleton maintains one Socket.io connection per browser tab
- React hooks subscribe to state changes via
useEffectlisteners
Layer 1: Server state (server.mjs)
const state = {
currentStage: 0,
timer: { running: false, remaining: 0, total: 0 },
// Add domain-specific fields
};
function broadcastState(io) {
io.emit("state:sync", {
currentStage: state.currentStage,
// Only expose what clients need (not full internal state)
});
}
Rules:
- Server state is the single source of truth — clients never write directly
broadcastState()is called after every mutation- Filter sensitive fields before broadcasting (e.g. admin data, answers)
Layer 2: Client singleton (socket-client.ts)
import { io, Socket } from "socket.io-client";
let socket: Socket | null = null;
export function getSocket(): Socket {
if (!socket) {
socket = io({ path: "/socket.io", transports: ["websocket", "polling"] });
}
return socket;
}
One socket per tab. All hooks call getSocket() — never create new connections.
Layer 3: React hooks (useSocket.ts)
State subscription hook
import { useState, useEffect } from "react";
import { getSocket } from "@/lib/socket-client";
import type { AppState } from "@/lib/types";
const DEFAULT_STATE: AppState = { /* initial values */ };
export function useAppState(): AppState {
const [state, setState] = useState<AppState>(DEFAULT_STATE);
useEffect(() => {
const socket = getSocket();
const handler = (s: AppState) => setState(s);
socket.on("state:sync", handler);
socket.emit("state:request"); // ask for current state on mount
return () => { socket.off("state:sync", handler); };
}, []);
return state;
}
Admin hook (privileged actions)
export function useAdmin() {
const [authed, setAuthed] = useState(false);
const login = useCallback((password: string) => {
getSocket().emit("admin:auth", password, (ok: boolean) => {
setAuthed(ok);
if (ok) sessionStorage.setItem("admin_authed", "1");
});
}, []);
const setStage = useCallback((stage: number) => {
getSocket().emit("admin:setStage", stage);
}, []);
return { authed, login, setStage };
}
Event naming conventions
| Pattern | Direction | Example |
|---|---|---|
state:sync |
server → all | Broadcast full state |
timer:sync |
server → all | Broadcast timer state |
admin:* |
client → server | admin:setStage, admin:auth |
[feature]:* |
client → server | poll:vote, game1:submit |
command:execute |
server → non-admin | Push commands to participants |
Rules:
- Use
noun:verbformat - Admin events always prefixed with
admin: - Server-to-client broadcasts use
noun:syncornoun:update
Server-side admin authentication
const ADMIN_PWD = process.env.ADMIN_PWD || "changeme";
socket.on("admin:auth", (pwd, cb) => {
const ok = pwd === ADMIN_PWD;
if (ok) socket.join("admin");
cb(ok);
});
function requireAdmin(socket) {
return socket.rooms.has("admin");
}
socket.on("admin:setStage", (stage) => {
if (!requireAdmin(socket)) return;
state.currentStage = stage;
broadcastState(io);
});
Admin sockets join the "admin" room. All privileged handlers check requireAdmin() first.
Timer pattern
Server-authoritative timer with interval broadcast:
let timerInterval = null;
socket.on("admin:startTimer", (seconds) => {
if (!requireAdmin(socket)) return;
state.timer = { running: true, remaining: seconds, total: seconds };
clearInterval(timerInterval);
timerInterval = setInterval(() => {
if (state.timer.remaining <= 0) {
clearInterval(timerInterval);
state.timer.running = false;
io.emit("timer:finished");
} else {
state.timer.remaining--;
}
io.emit("timer:sync", state.timer);
}, 1000);
});
Checklist
- Server state object defined with all fields
-
broadcastState()called after every mutation - Client singleton in
socket-client.ts(never multiple connections) -
useAppStatehook subscribes tostate:sync - Admin actions gated by
requireAdmin() - Events follow
noun:verbnaming - Sensitive data filtered from broadcast payload
- Timer is server-authoritative (no client-side countdown as source of truth)