A chat client looks simple but has to feel instant, never drop or duplicate a message, survive a flaky connection, and behave correctly as it scrolls. We'll use the RADIO framework, focused on the front-end concerns (the transport itself is a backend detail we only consume).
Pin down the shape of the product first — 1:1 vs group chat and the freshness guarantees change the design the most.
Before designing, what should you nail down?
Functional vs non-functional
Functional: send/receive messages, see history, presence/typing. Non-functional: sending feels instant, messages stay correctly ordered and never duplicated, the UI recovers cleanly after a reconnect, and new messages are announced to assistive tech.
Put the connection lifecycle and message state in a hook; keep the list and bubbles presentational. The list owns scroll behaviour because that's where the tricky UX lives.
Click a node to see what it owns.
Does: Composition root for one conversation.
State: none (delegates to useChat)
Does: Owns the socket lifecycle, message store, send/queue/reconcile, and presence.
State: byId, order, connectionStatus, outbox (unsent)
Encapsulating the socket here means components never touch the transport directly.
Does: Renders ordered messages; stick-to-bottom auto-scroll; aria-live for new ones; paginates older history on scroll-up.
State: scroll position, isStuckToBottom
Does: One message with delivery status + timestamp.
Does: Draft input; emits send; reports typing.
Does: Online status / typing indicators.
What transport carries the messages?
Pick when: Two-way, low-latency messaging — the default for chat.
Store messages keyed by id for O(1) updates and de-duplication, with a separate ordered list of ids for rendering. Every outgoing message gets a stable clientId before it's sent so you can match the server's echo back to the optimistic bubble.
Core types.
How do you keep messages ordered and de-duplicated?
Pick when: The robust default for optimistic UIs.
The hook hides the transport entirely; components just call send and render messages.
Hook + component contracts.
The concerns that make chat feel right: optimistic send + reconciliation, reconnection with a resend queue, ordering/de-dup, and scroll behaviour.
The signature insight: optimistic send, reconciled by clientId
Render the message instantly as sending with a client-generated id. When the server echoes it back (with its real id and sequence), match on clientId and replace the optimistic copy — don't append a second bubble. If the send fails, flip it to failed with a retry affordance. This is what makes chat feel instant without duplicating messages.
Optimistic send + auto-scroll (editable)
Messages show instantly as 'sending', flip to 'sent ✓' on a simulated ack, and a reply arrives. Scroll up and the view stops auto-jumping to the bottom.
Connection resilience is the other half. On disconnect, reconnect with exponential backoff + jitter, and flush a queue of messages that were still sending:
Reconnection with backoff + resend queue (illustrative).
Rounding it out:
aria-live="polite" so screen readers announce incoming messages without stealing focus.Self-check: a production-ready chat covers…
0 / 9 covered
Going deeper (tap to reveal)