cmd/sgai/skel/.sgai/skills/coding-practices/react-sse-patterns/SKILL.md
SSE with useSyncExternalStore, reconnection with exponential backoff, snapshot rehydration, typed event parsing, connection status UI. Use when implementing SSE data stores, real-time update hooks, or connection resilience in the React SPA. Triggers on SSE, EventSource, useSyncExternalStore, real-time updates, reconnection, or live data tasks.
npx skillsauth add sandgardenhq/sgai react-sse-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Guide for implementing Server-Sent Events (SSE) in the SGAI React SPA using React's useSyncExternalStore pattern. Covers the external store module, auto-reconnect with exponential backoff, snapshot rehydration on reconnect, typed event parsing, connection status UI, and domain hooks that combine initial fetch with live SSE updates.
STPA References: R-1 (auto-reconnect), R-2 (connection status banner), R-3 (unbounded reconnection), R-19 (snapshot rehydration).
lib/sse-store.tsuseWorkspaces, useSession)react-best-practices instead)SSE Store (lib/sse-store.ts) NOT in React Context
├── EventSource connection Singleton, module-level
├── subscribe() / getSnapshot() useSyncExternalStore API
├── Auto-reconnect Exponential backoff 1s → 30s
└── Typed event parsing workspace:update, session:update, etc.
Domain Hooks (hooks/)
├── useWorkspaces() Initial fetch + SSE live updates
├── useSession(name) Initial fetch + SSE live updates
├── useMessages(name) Initial fetch + SSE live updates
└── ... Pattern: fetch + subscribe
Key constraint: The SSE store is an external store, NOT a React Context. This follows React's official recommendation for external data sources. Components subscribe via useSyncExternalStore(store.subscribe, store.getSnapshot).
lib/sse-store.ts)The SSE store is a standalone TypeScript module that manages the EventSource connection at module level.
// lib/sse-store.ts
// --- Types ---
type SSEEventType =
| 'workspace:update'
| 'session:update'
| 'messages:new'
| 'todos:update'
| 'log:append'
| 'changes:update'
| 'events:new'
| 'compose:update';
interface SSEEvent<T = unknown> {
type: SSEEventType;
data: T;
timestamp: number;
}
type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
interface SSEStoreState {
connectionStatus: ConnectionStatus;
lastEventTimestamp: number;
events: Map<SSEEventType, SSEEvent>;
}
// --- Module-level state (NOT React state) ---
let state: SSEStoreState = {
connectionStatus: 'disconnected',
lastEventTimestamp: 0,
events: new Map(),
};
let eventSource: EventSource | null = null;
let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();
// --- Subscribe / getSnapshot for useSyncExternalStore ---
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): SSEStoreState {
return state;
}
function emitChange(): void {
// Create new state reference to trigger React re-render
state = { ...state };
for (const listener of listeners) {
listener();
}
}
// --- Connection Management ---
const MAX_BACKOFF_MS = 30_000;
const BASE_BACKOFF_MS = 1_000;
function getBackoffDelay(): number {
const delay = Math.min(
BASE_BACKOFF_MS * Math.pow(2, reconnectAttempts),
MAX_BACKOFF_MS
);
return delay;
}
function connect(): void {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/api/v1/events/stream');
eventSource.onopen = () => {
reconnectAttempts = 0;
state = { ...state, connectionStatus: 'connected' };
emitChange();
};
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
state = { ...state, connectionStatus: 'reconnecting' };
emitChange();
scheduleReconnect();
};
// Register typed event listeners
const eventTypes: SSEEventType[] = [
'workspace:update',
'session:update',
'messages:new',
'todos:update',
'log:append',
'changes:update',
'events:new',
'compose:update',
];
for (const eventType of eventTypes) {
eventSource.addEventListener(eventType, (e: MessageEvent) => {
const event: SSEEvent = {
type: eventType,
data: JSON.parse(e.data),
timestamp: Date.now(),
};
state = {
...state,
lastEventTimestamp: event.timestamp,
events: new Map(state.events).set(eventType, event),
};
emitChange();
});
}
}
function scheduleReconnect(): void {
// R-3: Unbounded reconnection - never give up
if (reconnectTimer) clearTimeout(reconnectTimer);
const delay = getBackoffDelay();
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
connect();
}, delay);
}
function disconnect(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
eventSource?.close();
eventSource = null;
state = { ...state, connectionStatus: 'disconnected' };
emitChange();
}
// --- Public API ---
export const sseStore = {
subscribe,
getSnapshot,
connect,
disconnect,
};
Reconnection uses exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (max).
Rules:
Attempt 0: 1s
Attempt 1: 2s
Attempt 2: 4s
Attempt 3: 8s
Attempt 4: 16s
Attempt 5+: 30s (capped)
On initial connect or reconnect, the SSE endpoint sends a full state snapshot as the first event. This prevents stale data after reconnection.
Server-side contract: GET /api/v1/events/stream sends a snapshot event as the first message containing the complete current state. After that, incremental events follow.
Client-side handling:
eventSource.addEventListener('snapshot', (e: MessageEvent) => {
const snapshot = JSON.parse(e.data);
// Replace entire state from snapshot
const newEvents = new Map<SSEEventType, SSEEvent>();
for (const [type, data] of Object.entries(snapshot)) {
newEvents.set(type as SSEEventType, {
type: type as SSEEventType,
data,
timestamp: Date.now(),
});
}
state = {
...state,
events: newEvents,
lastEventTimestamp: Date.now(),
};
emitChange();
});
Show a "Reconnecting..." banner when disconnected for more than 2 seconds.
// hooks/useConnectionStatus.ts
import { useSyncExternalStore } from 'react';
import { sseStore } from '../lib/sse-store';
export function useConnectionStatus(): ConnectionStatus {
const state = useSyncExternalStore(
sseStore.subscribe,
sseStore.getSnapshot
);
return state.connectionStatus;
}
// components/ConnectionBanner.tsx
export function ConnectionBanner() {
const status = useConnectionStatus();
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
if (status === 'reconnecting' || status === 'disconnected') {
const timer = setTimeout(() => setShowBanner(true), 2000);
return () => clearTimeout(timer);
}
setShowBanner(false);
}, [status]);
if (!showBanner) return null;
return (
<Alert variant="warning">
Reconnecting to server...
</Alert>
);
}
All SSE events have typed names. Parse and validate on receive:
| Event Name | Payload Type | Description |
|------------|-------------|-------------|
| workspace:update | WorkspaceData | Workspace list/detail changed |
| session:update | SessionData | Session status, current agent, workflow state |
| messages:new | MessageData | New inter-agent message |
| todos:update | TodoData | Todo list changed |
| log:append | LogData | New output log lines |
| changes:update | ChangesData | JJ diff changed |
| events:new | EventData | New progress event |
| compose:update | ComposeData | GOAL.md composer state |
Define TypeScript interfaces for each payload in types/sse.ts.
Domain hooks combine an initial API fetch with SSE live updates:
// hooks/useWorkspaces.ts
import { use, useSyncExternalStore } from 'react';
import { sseStore } from '../lib/sse-store';
import { api } from '../lib/api';
const workspacesPromise = api.getWorkspaces();
export function useWorkspaces() {
// Initial data via React 19 use() + Suspense
const initialData = use(workspacesPromise);
// Live updates via SSE
const sseState = useSyncExternalStore(
sseStore.subscribe,
sseStore.getSnapshot
);
const liveData = sseState.events.get('workspace:update');
// SSE data takes precedence when available
return liveData ? liveData.data : initialData;
}
Pattern: Every domain hook follows this structure:
use(fetchPromise) for initial data (wrapped in Suspense)useSyncExternalStore for live SSE updatesSSE store is NOT React Context — It's a module-level external store. Components subscribe via useSyncExternalStore. This is React's recommended pattern for external data sources.
Never give up reconnecting — Reconnection is unbounded. The store retries forever with exponential backoff capped at 30s. Users should always see fresh data eventually.
Snapshot first, incremental after — On every connect/reconnect, the server sends full state. The client replaces its entire cache from the snapshot, then applies incremental events.
Show connection status after 2s — Don't flash banners on brief disconnects. Only show "Reconnecting..." if disconnected for >2 seconds.
Domain hooks combine fetch + SSE — Initial page data comes from use() + Suspense. SSE keeps it fresh. Never use only one or the other for data that changes.
SSE events published after commit — The Go backend emits SSE events AFTER transaction commit (R-20), never during mutation. This prevents the React UI from seeing uncommitted state.
// Correct: module-level store, not Context
const state = useSyncExternalStore(sseStore.subscribe, sseStore.getSnapshot);
// WRONG: Don't put SSE in Context
const SSEContext = createContext<EventSource | null>(null);
function SSEProvider({ children }) {
const [es] = useState(() => new EventSource('/api/v1/events/stream'));
return <SSEContext.Provider value={es}>{children}</SSEContext.Provider>;
}
export function useSession(name: string) {
const initial = use(api.getSession(name));
const sse = useSyncExternalStore(sseStore.subscribe, sseStore.getSnapshot);
const live = sse.events.get('session:update');
return live?.data?.name === name ? live.data : initial;
}
// WRONG: No data until first SSE event arrives
export function useSession(name: string) {
const sse = useSyncExternalStore(sseStore.subscribe, sseStore.getSnapshot);
return sse.events.get('session:update')?.data;
}
Before completing SSE work, verify:
useSyncExternalStore, not React Contextuse() + useSyncExternalStoredocumentation
Start, stop, and steer agentic sessions in sgai workspaces. Use when you need to launch AI agent sessions, halt running sessions, or inject steering instructions to guide the agent mid-execution without stopping it.
development
Monitor sgai workspace status, events, progress, diffs, and workflow diagrams. Use when you need to observe what agents are doing, track progress, get the current state of all workspaces, subscribe to real-time updates via SSE, or inspect code changes.
development
Access agents, skills, and code snippets available in sgai workspaces. Use when you need to discover what agents are defined in a workspace, browse available skills, get skill instructions, find code snippets by language, or retrieve snippet content for a specific task.
data-ai
Handle agent questions and work gates in sgai workspaces. Use when an agent is blocked waiting for human input, when you need to respond to multi-choice questions, approve work gates, or provide free-text answers to agent queries.