Code Walkthrough
This walkthrough explains how the LivePoll web application uses RelayX for real-time voting and poll management.
Application Flow Overview
The application follows this sequence:
- Username Entry - User sets their display name
- Connect to RelayX - Establish WebSocket connection
- Load Polls - Fetch poll list and historical votes from KV Store
- Subscribe to Updates - Listen for new votes in real-time
- Vote & Broadcast - Cast votes that all clients receive instantly
Project Structure
src/
├── services/
│ └── relayService.ts # All RelayX SDK interactions
├── hooks/
│ ├── useRelayConnection.ts # Connection status hook
│ └── usePolls.ts # Poll data hooks
├── stores/
│ └── pollStore.ts # Local user state (votes)
├── pages/
│ └── Index.tsx # Main app page
└── components/
├── PollListScreen.tsx # Poll list view
└── PollViewScreen.tsx # Individual poll view
Why This Structure?
relayService.ts is a singleton that owns the RelayX client. This is important because:
- The WebSocket connection must persist across React component lifecycles
- Subscriptions registered once should survive component unmounts and reconnections
- Multiple components need to read the same real-time data
pollStore.ts is separate because it stores UI-only state (which polls the user voted for). This has no relationship to the backend.
Hooks bridge the singleton service to React components, triggering re-renders when real-time data changes.
1. RelayX Service Setup (relayService.ts)
The service maintains a single client instance and all poll data.
Service State
const state: RelayServiceState = {
client: null,
kvStore: null,
isConnected: false,
connectionStatus: "disconnected",
livePolls: [],
closedPolls: [],
totalVoteCount: 0,
pollVoteCounts: new Map(),
optionVoteCounts: new Map(),
listeners: new Set(),
};
This state stores:
- RelayX client and KV Store instances
- Connection status for UI feedback
- Poll data and vote counts
- Listeners for state change notifications
Connection Flow (relayService.ts:253-330)
async function connect(): Promise<boolean> {
// Check credentials
const { apiKey, apiSecret } = getStoredCredentials();
if (!apiKey || !apiSecret) {
console.error("Cannot connect: missing API credentials");
return false;
}
state.connectionStatus = "connecting";
notifyListeners();
try {
// Create and initialize client
const client = new Realtime({
api_key: apiKey,
secret: apiSecret,
});
await client.init({});
state.client = client;
// Setup event handlers before connecting
return new Promise(async (resolve) => {
client.on("CONNECTED", async (status: boolean) => {
if (status) {
try {
// Initialize KV Store
state.kvStore = await client.initKVStore();
// Fetch initial data
await fetchPolls();
// Set up subscriptions
await setupSubscriptions();
state.isConnected = true;
state.connectionStatus = "connected";
notifyListeners();
resolve(true);
} catch (error) {
console.error("Error initializing after connection:", error);
state.connectionStatus = "disconnected";
resolve(false);
}
}
});
client.on("RECONNECT", (status: string) => {
if (status === "RECONNECTING") {
state.connectionStatus = "reconnecting";
} else if (status === "RECONNECTED") {
state.connectionStatus = "connected";
} else if (status === "RECONN_FAIL") {
state.connectionStatus = "disconnected";
state.isConnected = false;
}
notifyListeners();
});
// Start connection
await client.connect();
});
} catch (error) {
console.error("Error connecting to Relay:", error);
state.connectionStatus = "disconnected";
return false;
}
}
Why Event Handlers Before connect()?
The event handlers must be registered after init() but before connect(). This ensures:
- The CONNECTED event handler is in place when authentication completes
- The initialization code (KV Store, subscriptions) runs at the right time
- The returned Promise resolves only when the app is fully initialized
Connection Events
- CONNECTED: Fires when authentication succeeds. This is where we initialize KV Store, fetch polls, and set up subscriptions.
- RECONNECT: Fires with different statuses during reconnection attempts. The SDK handles reconnection automatically - the app just updates UI state.
2. Fetching Poll Data
Loading Polls from KV Store (relayService.ts:202-251)
async function fetchPolls(): Promise<void> {
if (!state.kvStore) return;
try {
// Get poll list from KV Store
const pollList: RelayPoll[] = (await state.kvStore.get("poll_list")) || [];
const live: Poll[] = [];
const closed: Poll[] = [];
for (const relayPoll of pollList) {
// Fetch historical votes for each poll
const votes = await fetchPollHistory(relayPoll);
const poll: Poll = {
id: relayPoll.id,
title: relayPoll.title,
options: relayPoll.options.map((text, index) => ({
id: `${relayPoll.id}-${index}`,
text,
votes: votes.optionCounts.get(text) || 0,
})),
status: relayPoll.status,
createdAt: new Date(relayPoll.created_at),
totalVotes: votes.total,
};
if (relayPoll.status === "live") {
live.push(poll);
} else {
closed.push(poll);
}
// Store vote counts for real-time updates
state.pollVoteCounts.set(relayPoll.id, votes.total);
state.optionVoteCounts.set(relayPoll.id, votes.optionCounts);
}
state.livePolls = live;
state.closedPolls = closed;
state.totalVoteCount = [...state.pollVoteCounts.values()].reduce((a, b) => a + b, 0);
notifyListeners();
} catch (error) {
console.error("Error fetching polls:", error);
}
}
This function:
- Retrieves the poll list from KV Store using key
"poll_list" - For each poll, fetches historical votes using
history() - Builds poll objects with current vote counts
- Separates polls into live and closed arrays
- Notifies listeners to update UI
Fetching Historical Votes (relayService.ts:89-141)
async function fetchPollHistory(poll: RelayPoll): Promise<{
total: number;
optionCounts: Map<string, number>;
}> {
if (!state.client || !state.client.connected) {
return { total: 0, optionCounts: new Map() };
}
try {
const topic = `poll.${poll.id}`;
const startTime = new Date(poll.created_at);
// Fetch all messages since poll creation
const messages = await state.client.history(topic, startTime);
const optionCounts = new Map<string, number>();
if (Array.isArray(messages)) {
for (const msg of messages) {
const option = msg.message?.option || msg.option;
if (option && typeof option === "string") {
optionCounts.set(option, (optionCounts.get(option) || 0) + 1);
}
}
}
return {
total: Array.isArray(messages) ? messages.length : 0,
optionCounts,
};
} catch (error) {
console.error("Error fetching poll history:", error);
return { total: 0, optionCounts: new Map() };
}
}
The history() method retrieves all messages published to poll.{pollId} since the poll's creation time. Each message contains one vote, so we:
- Count total messages for total votes
- Count messages by option text for per-option votes
- Handle different message structures for compatibility
3. Real-Time Subscriptions
Setting Up Subscriptions (relayService.ts:182-200)
async function setupSubscriptions(): Promise<void> {
if (!state.client) return;
try {
// Global subscription for all poll votes
await state.client.on("poll.>", (data: any) => {
console.log("Global vote received:", data);
});
// Per-poll subscriptions
for (const poll of [...state.livePolls, ...state.closedPolls]) {
await subscribeToPoll(poll.id);
}
} catch (error) {
console.error("Error setting up subscriptions:", error);
}
}
This function:
- Sets up a global wildcard subscription to
poll.>(all poll topics) - Creates individual subscriptions for each poll
Important: setupSubscriptions() is called only once after initial connection. The SDK automatically re-subscribes when reconnecting, so we don't need to call this again.
Poll Subscription Handler (relayService.ts:143-180)
async function subscribeToPoll(pollId: string): Promise<void> {
if (!state.client) return;
const topic = `poll.${pollId}`;
try {
await state.client.on(topic, (data: any) => {
console.log("Received vote on poll:", pollId, data);
// Extract option from message
const option = data?.data?.option || data?.message?.option || data?.option;
if (!option) {
console.log("No option found in vote data, skipping");
return;
}
// Increment poll vote count
const currentPollCount = state.pollVoteCounts.get(pollId) || 0;
state.pollVoteCounts.set(pollId, currentPollCount + 1);
// Increment option vote count
const optionMap = state.optionVoteCounts.get(pollId) || new Map();
optionMap.set(option, (optionMap.get(option) || 0) + 1);
state.optionVoteCounts.set(pollId, optionMap);
// Update poll objects
updatePollCounts(pollId);
// Update total vote count
state.totalVoteCount = [...state.pollVoteCounts.values()].reduce((a, b) => a + b, 0);
notifyListeners();
});
} catch (error) {
console.error("Error subscribing to poll:", pollId, error);
}
}
When a vote arrives:
- Extract the option from the message (handles different message structures)
- Increment the vote count for the poll
- Increment the vote count for the specific option
- Update the poll objects with new counts
- Notify listeners to trigger UI updates
Why Subscriptions Persist
The SDK stores subscriptions internally. When the connection drops and reconnects:
- SDK detects disconnection
- Fires RECONNECT("RECONNECTING") event
- Automatically reconnects with exponential backoff
- Re-authenticates with credentials
- Automatically re-subscribes to all registered topics
- Fires RECONNECT("RECONNECTED") event
The application doesn't need to re-register subscriptions. The RECONNECT handler only updates UI state.
4. Publishing Votes
Vote Function (relayService.ts:343-376)
async function vote(pollId: string, optionText: string): Promise<boolean> {
if (!state.client) {
console.error("Cannot vote: not connected");
return false;
}
try {
const topic = `poll.${pollId}`;
const payload = { option: optionText };
console.log("Publishing vote:", topic, payload);
const success = await state.client.publish(topic, payload);
if (success) {
// Optimistically update counts
const currentPollCount = state.pollVoteCounts.get(pollId) || 0;
state.pollVoteCounts.set(pollId, currentPollCount + 1);
const optionMap = state.optionVoteCounts.get(pollId) || new Map();
optionMap.set(optionText, (optionMap.get(optionText) || 0) + 1);
state.optionVoteCounts.set(pollId, optionMap);
state.totalVoteCount = [...state.pollVoteCounts.values()].reduce((a, b) => a + b, 0);
updatePollCounts(pollId);
notifyListeners();
}
return success;
} catch (error) {
console.error("Error voting:", error);
return false;
}
}
Publishing Flow:
- Validates client exists
- Publishes message to
poll.{pollId}channel - If successful, optimistically updates local vote counts
- Notifies listeners for immediate UI update
- Returns success status
What publish() Returns
publish() returns a boolean indicating whether the message was handed to the WebSocket. It does NOT guarantee:
- Server received the message
- Other clients received the message
- Message was persisted
This is "fire-and-forget" messaging. For production systems, you'd add message IDs and acknowledgment handling.
Optimistic Updates
The code updates vote counts immediately after successful publish, before the subscription handler fires. This gives instant feedback to the user. The subscription handler will fire moments later (both for the sender and all other clients), which would normally increment the count again - but the example doesn't handle this deduplication issue.
5. Creating Polls
Create Poll Function (relayService.ts:378-430)
async function createPoll(title: string, options: string[]): Promise<Poll | null> {
if (!state.kvStore) {
console.error("Cannot create poll: KV store not initialized");
return null;
}
try {
const newRelayPoll: RelayPoll = {
id: crypto.randomUUID(),
title,
options,
created_at: new Date().toISOString(),
status: "live",
};
// Get current poll list
const pollList: RelayPoll[] = (await state.kvStore.get("poll_list")) || [];
// Add new poll
pollList.push(newRelayPoll);
// Save to KV Store
await state.kvStore.put("poll_list", pollList);
console.log("Poll saved to KV store:", newRelayPoll);
// Subscribe to the new poll immediately
await subscribeToPoll(newRelayPoll.id);
// Create internal Poll object
const poll: Poll = {
id: newRelayPoll.id,
title: newRelayPoll.title,
options: newRelayPoll.options.map((text, index) => ({
id: `${newRelayPoll.id}-${index}`,
text,
votes: 0,
})),
status: "live",
createdAt: new Date(newRelayPoll.created_at),
totalVotes: 0,
};
state.livePolls = [poll, ...state.livePolls];
state.pollVoteCounts.set(poll.id, 0);
state.optionVoteCounts.set(poll.id, new Map());
notifyListeners();
return poll;
} catch (error) {
console.error("Error creating poll:", error);
return null;
}
}
Creating a poll:
- Generates a new poll object with UUID
- Reads current poll list from KV Store
- Adds new poll to the list
- Saves updated list back to KV Store
- Subscribes to the new poll immediately so votes arrive in real-time
- Adds poll to local state
- Notifies listeners to update UI
6. React Integration
Connection Hook (useRelayConnection.ts)
export function useRelayConnection() {
const [isConnected, setIsConnected] = useState(false);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
useEffect(() => {
const update = () => {
setIsConnected(relayService.isConnected());
setStatus(relayService.getConnectionStatus());
};
update();
const unsubscribe = relayService.subscribe(update);
return unsubscribe;
}, []);
const connect = useCallback(async () => {
await relayService.connect();
}, []);
const disconnect = useCallback(() => {
relayService.disconnect();
}, []);
return { isConnected, status, connect, disconnect };
}
This hook:
- Subscribes to relayService state changes
- Updates React state when service state changes
- Provides connect/disconnect functions
- Cleans up subscription on unmount
Polls Hook (usePolls.ts)
export function usePolls() {
const [livePolls, setLivePolls] = useState<Poll[]>([]);
const [closedPolls, setClosedPolls] = useState<Poll[]>([]);
const [totalVotes, setTotalVotes] = useState(0);
useEffect(() => {
const update = () => {
setLivePolls(relayService.getLivePolls());
setClosedPolls(relayService.getClosedPolls());
setTotalVotes(relayService.getTotalVoteCount());
};
update();
const unsubscribe = relayService.subscribe(update);
return unsubscribe;
}, []);
const createPoll = useCallback(async (title: string, options: string[]) => {
return relayService.createPoll(title, options);
}, []);
return { livePolls, closedPolls, totalVotes, createPoll, ... };
}
Similar pattern: subscribe to service changes, update React state, provide action functions.
Main Page (Index.tsx)
const Index = () => {
const username = usePollStore((state) => state.username);
const { isConnected, status, connect } = useRelayConnection();
const [currentScreen, setCurrentScreen] = useState<Screen>('list');
// Connect after username is set
useEffect(() => {
if (username && !isConnected && status !== 'connecting') {
if (relayService.hasCredentials()) {
connect();
}
}
}, [username, isConnected, status, connect]);
// Show loading while connecting
if (!isConnected && status === 'connecting') {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<Loader2 className="h-8 w-8 text-primary animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">Connecting to LivePoll...</p>
</div>
);
}
// Render screens based on state
return (
<AnimatePresence mode="wait">
{currentScreen === 'list' && (
<PollListScreen onSelectPoll={handleSelectPoll} />
)}
{currentScreen === 'view' && selectedPollId && (
<PollViewScreen pollId={selectedPollId} onBack={handleBack} />
)}
</AnimatePresence>
);
};
The main page:
- Gets username from Zustand store
- Triggers connection when username is set
- Shows loading state while connecting
- Renders poll list or individual poll view
Voting in UI (PollViewScreen.tsx)
const handleVote = async (optionId: string, optionText: string) => {
if (poll.status !== 'live') return;
const success = await vote(optionText);
if (success) {
recordVote(pollId, optionId);
toast.success('Vote recorded!');
} else {
toast.error('Failed to record vote');
}
};
When user clicks a vote option:
- Calls the
vote()function fromusePollhook - Records vote locally in pollStore
- Shows success/error toast
- UI updates automatically via subscription handler
Key Technologies
- React - UI framework with hooks
- RelayX Web SDK (relayx-webjs) - Real-time pub/sub messaging
- Zustand - Client-side state management
- Framer Motion - Animations
- React Router - Navigation
Real-Time Data Flow Summary
- Initial Load: Connect → Fetch polls from KV Store → Fetch historical votes via
history()→ Subscribe to poll topics - Receiving Votes: Vote published by any client → Subscription handler fires → Vote counts increment → Listeners notified → React state updates → UI re-renders
- Sending Votes: User clicks option →
publish()topoll.{id}→ Optimistic update → Subscription handler fires (with double-count issue) → UI shows new count - Creating Polls: Create poll → Save to KV Store → Subscribe to new poll → Add to local state → UI updates
- Reconnection: Connection drops → SDK reconnects automatically → Subscriptions restored → Messages resume flowing
Important Patterns
Observer Pattern for React Integration
The service maintains a Set of listener callbacks:
function subscribe(listener: Listener): () => void {
state.listeners.add(listener);
return () => state.listeners.delete(listener);
}
function notifyListeners(): void {
state.listeners.forEach((listener) => listener());
}
React hooks subscribe on mount and unsubscribe on unmount. This bridges the singleton service to React's reactivity.
One-Time Subscription Registration
Subscriptions are registered only in setupSubscriptions(), which runs once after initial connection. The SDK persists subscriptions across reconnections, so the RECONNECT handler doesn't re-subscribe.
Optimistic Updates
Vote counts update immediately after publish() returns success, before the subscription handler fires. This provides instant feedback but can cause double-counting issues (addressed in production with deduplication).
Channel Naming
All poll messages use topic pattern poll.{pollId}. The wildcard subscription poll.> matches all poll topics for monitoring purposes.