Building a Chat: Chapter 3 β The Frontend Client
Tony Duong
May 31, 2026 γ» 7 min
π¬ This is the live chat widget you can see in the bottom-right corner of this very site. This series walks through exactly how I built it β click the chat bubble to try it, then read on to see how it works under the hood.
The Rails server from chapter 2 broadcasts JSON over WebSockets. This chapter covers the TypeScript layer that consumes it: the ActionCable client wrapper, the type system for channel data, the ChatWidget lifecycle, and a useRef pattern that took an embarrassing number of hours to get right.
The ActionCable client layer
File: src/lib/chat-client.ts
This module sits between React and the Rails WebSocket server. Two module-level singletons ensure exactly one connection per role:
let visitorConsumer: Consumer | null = null;
let adminConsumer: Consumer | null = null;
createConsumer opens the WebSocket. The visitor authenticates via query parameter (createConsumer(\${WS_URL}?token=${sessionToken}`)), while the admin authenticates via session cookie -- no token, just the cookie set by adminLogin. Every fetchcall to the Rails server usescredentials: "include"`, which is required for the browser to send and receive cookies cross-origin. This applies to both REST calls and the WebSocket upgrade handshake.
subscribeVisitorChannel and subscribeAdminChannel wrap consumer.subscriptions.create with typed callbacks, accepting a received function typed to the appropriate channel data union.
The missing type declarations
@rails/actioncable ships with zero TypeScript declarations. The project includes hand-written types at src/types/actioncable.d.ts -- a declare module defining Consumer, Subscription, CreateMixin, and createConsumer. Without this file, npm run build fails. The types are minimal: we only declare what we call.
Type-safe channel data
File: src/lib/chat-types.ts
Channel data uses discriminated unions keyed on a type field:
export type VisitorChannelData =
| { type: "history"; conversation: { id: number; visitor_name: string }; messages: ChatMessage[] }
| { type: "message"; message: ChatMessage }
| { type: "error"; error: string };
In a switch (data.type), TypeScript narrows automatically -- inside case "history":, data.messages exists and is ChatMessage[]. AdminChannelData follows the same pattern with five variants (conversations, new_message, history, conversation_deleted, error).
These types mirror the JSON structures from the Rails channels (chapter 2). No code generation -- you keep them in sync manually, and the compiler catches mismatches at build time.
ChatWidget lifecycle
File: src/components/ChatWidget.tsx
The widget has three states:
| State | isOpen |
hasName |
WebSocket | UI |
|---|---|---|---|---|
| Closed | false |
-- | None | Floating button |
| Open, no name | true |
false |
None | Name input form |
| Open, chatting | true |
true |
Connected | ChatPanel with messages |
The WebSocket connection exists only in state 3, enforced by the guard at the top of the subscription effect:
useEffect(() => {
if (!isOpen || !hasName) return;
const sub = subscribeVisitorChannel(tokenRef.current, { received, connected, disconnected });
return () => { sub.unsubscribe(); disconnectVisitor(); };
}, [isOpen, hasName]);
Session identity is a UUID in localStorage:
function getSessionToken(): string {
let token = localStorage.getItem("chat_session_token");
if (!token) {
token = crypto.randomUUID();
localStorage.setItem("chat_session_token", token);
}
return token;
}
This maps 1:1 to a Conversation via find_or_create_by!(session_token:) (chapter 2). Same token = same conversation across page refreshes. The visitor's name is persisted separately so the name form is skipped on return visits.
On connect, the server transmits a "history" event. The "message" handler uses deduplication (prev.some(m => m.id === data.message.id)) to prevent double-rendering when ActionCable retransmits after a brief disconnect.
The useRef trap
This is the hardest bug in the codebase. Without the fix:
1. useEffect runs β creates subscription
2. Message arrives β received() calls setMessages()
3. Re-render. isOpen is still true but is a new closure reference
4. Effect cleanup runs β effect re-runs β new subscription
5. Server sends history β setMessages() β re-render β goto 4
The Rails server logs flood with connect/disconnect pairs. The fix: a ref that tracks isOpen without participating in the dependency array:
const isOpenRef = useRef(isOpen);
useEffect(() => { isOpenRef.current = isOpen; }, [isOpen]);
Inside the received callback, read isOpenRef.current instead of isOpen. The ref gives you the latest value without forcing the subscription effect to re-run.
The same pattern appears in src/components/AdminChat.tsx with selectedIdRef -- the admin's callback needs the selected conversation ID to decide whether to append messages to the visible list, without re-subscribing every time the user clicks a different conversation.
The rule: if a callback inside useEffect needs a value that changes frequently, but the effect itself should not re-run on that change, use a ref.
ChatPanel -- the shared message component
File: src/components/ChatPanel.tsx
ChatPanel is a presentational component used by both the visitor widget and the admin dashboard. It receives messages, onSendMessage, and isConnected as props. It has no idea which side it represents.
Auto-scroll uses a hidden <div ref={messagesEndRef} /> at the bottom of the message list, scrolled into view via useEffect whenever messages changes. Message alignment is determined by msg.sender -- visitor messages go right, admin messages go left. In AdminChat, the same logic is inverted. The parent controls perspective.
The visitor side is now wired end to end -- from the floating button through the WebSocket subscription to message rendering. Chapter 4 covers the admin experience: how the admin dashboard manages multiple conversations over a single WebSocket, and how Web Push notifications alert the admin when they are away from the browser.
Try it out
Exercise 1: Verify session isolation
Open the chat widget in two browsers (or regular + incognito). Send messages from both. In a Rails console, run Conversation.last(2) and confirm two records with different session_token values.
Conversation.last(2).pluck(:id, :session_token, :visitor_name)
# => [[2, "a1b2c3...", "Alice"], [1, "d4e5f6...", "Bob"]]
Each browser generated its own UUID via crypto.randomUUID(), stored in its own localStorage.
Exercise 2: Add a typing indicator type
In src/lib/chat-types.ts, add | { type: "typing"; is_typing: boolean } to VisitorChannelData. Handle it in ChatWidget.tsx's switch statement by showing "Tony is typing..." when active.
Add state (const [isTyping, setIsTyping] = useState(false)) and a case:
case "typing":
setIsTyping(data.is_typing);
break;
Render conditionally: {isTyping && <p className="text-xs text-muted-foreground px-3 py-1">Tony is typing...</p>}. No server changes needed -- TypeScript is satisfied and you can test via the browser console.
Exercise 3: Break the useRef pattern
Remove the isOpenRef declaration and its sync effect from ChatWidget.tsx. Replace isOpenRef.current with isOpen in the received callback. Open the widget, send a message, and watch the Rails server logs flood with connect/disconnect pairs. Then revert.
Delete the ref and its sync effect, then change the callback to read isOpen directly. In chat-server/log/development.log, you will see rapid cycling:
VisitorChannel is transmitting the subscription confirmation
VisitorChannel stopped streaming
Each cycle creates a subscription, sends history, updates state, re-renders, tears down, and recreates. Revert to stop it.
Exercise 4: sessionStorage vs localStorage
Change getSessionToken to use sessionStorage. Open the chat, send a message, close the tab, reopen it. Your conversation history is gone.
Replace localStorage with sessionStorage in both the getItem and setItem calls. sessionStorage is scoped to the tab -- closing it destroys the token. A new UUID is generated on reopen, creating a new Conversation with no messages. The original uses localStorage because it persists across tabs and browser restarts.