# Realtime State Sync

> Implement real-time multi-client state synchronization using Socket.io with a three-layer architecture (server authority, broadcast, client singleton). Use when the user needs WebSocket communication, real-time sync, live updates, admin-controlled state, or multi-user collaboration features.

- Skill: `ph13917403910/realtime-state-sync` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ph13917403910/realtime-state-sync`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ph13917403910/realtime-state-sync/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: PH13917403910 (https://skillmd.com/u/ph13917403910)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ph13917403910/realtime-state-sync

---


# 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()       │            │
└─────────────┘                   └──────────────┘                └────────────┘
```

1. **Server** holds the single source of truth in memory
2. **Client singleton** maintains one Socket.io connection per browser tab
3. **React hooks** subscribe to state changes via `useEffect` listeners

## Layer 1: Server state (server.mjs)

```javascript
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)

```typescript
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

```typescript
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)

```typescript
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:verb` format
- Admin events always prefixed with `admin:`
- Server-to-client broadcasts use `noun:sync` or `noun:update`

## Server-side admin authentication

```javascript
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:

```javascript
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)
- [ ] `useAppState` hook subscribes to `state:sync`
- [ ] Admin actions gated by `requireAdmin()`
- [ ] Events follow `noun:verb` naming
- [ ] Sensitive data filtered from broadcast payload
- [ ] Timer is server-authoritative (no client-side countdown as source of truth)

