A messaging UI with optimistic sends, streaming/incoming messages, and correct scroll anchoring.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 95-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Send a message optimistically (appears instantly) and reconcile it once the server confirms or rejects it.
Receive and append incoming messages (simulated via interval or socket) without disrupting the user's scroll position.
Auto-scroll to the newest message only when the user was already at (or near) the bottom.
Show per-message status: sending, sent, failed (with retry).
Group consecutive messages from the same sender visually.
EDGE CASES
If the user has scrolled up to read history, a new incoming message must not yank the viewport back down — show a 'new messages' indicator instead.
A failed optimistic send must be visually distinct and retryable without duplicating the message on retry.
Very rapid successive sends must preserve correct ordering even if server confirmations arrive out of order.
Extremely long messages or a burst of many messages shouldn't degrade scroll performance.
ACCESSIBILITY
New incoming messages should be announced via a polite live region without stealing focus from the compose input.
The message list should be reachable and readable via keyboard/screen reader in logical (chronological) order.
Retry and status indicators (failed/sending) must not rely on color alone — pair with text or an icon with an accessible label.
SUGGESTED APPROACH
Give every message a client-generated temporary id and a status field; replace it in place (matching by that id) once the server responds, rather than appending a second copy.
Track whether the scroll container is currently near the bottom (via scrollTop/scrollHeight math) before each append, and only auto-scroll if it was.
Use a ref to the scroll container to adjust scrollTop imperatively after DOM updates (in a layout effect) so appends never cause a visible jump.
Group messages at render time by comparing each message's sender to the previous one, not by mutating the underlying data.
EVALUATION RUBRIC
Optimistic send reconciles correctly with server response, including the failure/retry path, without duplicating messages.
Scroll anchoring correctly distinguishes 'user is at bottom, auto-scroll' from 'user scrolled up, don't yank them down.'
Message ordering stays correct even with out-of-order server confirmations.
New-message announcements don't steal focus from the compose box.
01Understand the product before coding
Learning goals
Send a message optimistically (appears instantly) and reconcile it once the server confirms or rejects it.
Receive and append incoming messages (simulated via interval or socket) without disrupting the user's scroll position.
Auto-scroll to the newest message only when the user was already at (or near) the bottom.
Show per-message status: sending, sent, failed (with retry).
Decisions to state aloud
If the user has scrolled up to read history, a new incoming message must not yank the viewport back down — show a 'new messages' indicator instead.
A failed optimistic send must be visually distinct and retryable without duplicating the message on retry.
Very rapid successive sends must preserve correct ordering even if server confirmations arrive out of order.
Extremely long messages or a burst of many messages shouldn't degrade scroll performance.
02State model and invariants
Messages have stable client ids, optional server ids, sequence information, and explicit sending states. Connection state and scroll state are separate. Server acknowledgement updates the optimistic record in place; it never appends a duplicate.
01ChatStore normalizes socket events and optimistic mutations.
02MessageList renders chronological stable ids and optional virtualization.
03MessageGroup derives visual grouping without changing data.
04Composer owns draft and send command.
05ScrollController owns bottom detection, anchoring, and new-message indicator.
04Reference implementation walkthrough
Step 1
Insert and reconcile optimistically
Generate one client id, append sending state, and send the mutation id. Acknowledgement patches that record with server id and sequence; failure marks it retryable.
Record whether the user was near the bottom before append. Auto-scroll only then; otherwise increment new-message count. Prepending history preserves the first visible anchor and offset.
Use server sequence for confirmed messages and stable client creation order for pending ones. An out-of-order acknowledgement patches identity and sort metadata without moving unrelated optimistic records unpredictably.
Step 4
Own socket lifecycle
Reconnect with bounded backoff, resume from last sequence, deduplicate replayed server ids, surface offline sending behavior, and unsubscribe on room change.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building a chat where your message appears the instant you send it, gets confirmed (or marked failed and retryable) in place, and the view only auto-scrolls to the newest message if you were already at the bottom. The one big idea: give every message a client id the moment it is created, so it has a stable identity before the server ever responds — the server's answer patches that same record instead of adding a second copy.
Chat.tsx
1import{ useEffect, useLayoutEffect, useReducer, useRef, useState }from"react";23type Message={4clientId: string;5 serverId?: string;6 sequence?: number;// server's canonical order, once confirmed7senderId: string;8body: string;9createdAt: number;10status:"sending"|"sent"|"failed";11};12type State={messages:Message[]};13type Action=14|{type:"optimistic"; message:Message}15|{type:"acked"; clientId: string; serverId: string; sequence: number }16|{type:"failed"; clientId: string }17|{type:"retry"; clientId: string }18|{type:"incoming"; message:Message};1920// Confirmed messages order by server sequence; still-sending ones sit after, by creation time.21functionbySortKey(a:Message,b:Message){22if(a.sequence!=null&& b.sequence!=null)return a.sequence- b.sequence;23if(a.sequence!=null)return-1;24if(b.sequence!=null)return1;25return a.createdAt- b.createdAt;26}2728functionreducer(state:State,action:Action):State{29switch(action.type){30case"optimistic":31return{messages:[...state.messages, action.message]};32case"acked":33return{34messages: state.messages.map((m)=>35 m.clientId=== action.clientId36?{...m,serverId: action.serverId,sequence: action.sequence,status:"sent"}37: m,38),39};40case"failed":41return{messages: state.messages.map((m)=> m.clientId=== action.clientId?{...m,status:"failed"}: m)};42case"retry":43return{messages: state.messages.map((m)=> m.clientId=== action.clientId?{...m,status:"sending"}: m)};44case"incoming":45// drop a server message we already have (our own echo, or a reconnect replay)46if(state.messages.some((m)=> m.serverId&& m.serverId=== action.message.serverId))return state;47return{messages:[...state.messages, action.message]};48}49}5051exportfunctionChat({ me, send, subscribe }:{52me: string;53send:(mutationId: string,body: string)=>Promise<{serverId: string; sequence: number }>;54subscribe:(onMessage:(m:Message)=>void)=>()=>void;55}){56const[state, dispatch]=useReducer(reducer,{messages:[]});57const[draft, setDraft]=useState("");58const listRef = useRef<HTMLDivElement>(null);59const wasNearBottom =useRef(true);6061useEffect(()=>subscribe((m)=>dispatch({type:"incoming",message: m })),[subscribe]);6263functiononScroll(){64const el = listRef.current;65if(el) wasNearBottom.current= el.scrollHeight- el.scrollTop- el.clientHeight<80;66}6768// After new messages render, jump to the bottom only if the user was already there.69useLayoutEffect(()=>{70const el = listRef.current;71if(el && wasNearBottom.current) el.scrollTop= el.scrollHeight;72},[state.messages]);7374const sorted =[...state.messages].sort(bySortKey);7576asyncfunctionpost(body: string, clientId?: string){77const id = clientId ?? crypto.randomUUID();78dispatch(clientId79?{type:"retry",clientId: id }80:{type:"optimistic",message:{clientId: id,senderId: me, body,createdAt:Date.now(),status:"sending"}});81try{82const ack =awaitsend(id, body);83dispatch({type:"acked",clientId: id,...ack });84}catch{85dispatch({type:"failed",clientId: id });86}87}8889return(90<div>91<div92 ref={listRef}93 onScroll={onScroll}94 role="log"95 aria-live="polite"96 style={{overflowY:"auto",maxHeight:400}}97>98{sorted.map((m)=>(99<p key={m.clientId} data-mine={m.senderId=== me}>100{m.body}101{m.status==="sending"&&" (sending\u2026)"}102{m.status==="failed"&&(103<button type="button" onClick={()=>post(m.body, m.clientId)}>Retry</button>104)}105</p>106))}107</div>108<form onSubmit={(e)=>{ e.preventDefault();if(draft.trim()){post(draft.trim());setDraft("");}}}>109<input value={draft} onChange={(e)=>setDraft(e.target.value)} aria-label="Message"/>110<button type="submit">Send</button>111</form>112</div>113);114}
How each part works
Every message gets a client id at creation
post() generates a clientId with crypto.randomUUID() before doing anything else. That id is the React key and the identity used everywhere. The message exists and is identifiable the moment you hit send, long before the server has heard of it.
The ack patches the same record
When send() resolves, the 'acked' action finds the message by clientId and fills in serverId, sequence, and status 'sent'. It maps over the list and replaces that one entry — it never pushes a new message. This is why your sent message does not briefly appear twice.
Failure keeps the message and offers retry
'failed' just flips status to 'failed'. The message stays on screen with its text and a Retry button. Retry calls post(body, sameClientId), which dispatches 'retry' (back to 'sending') and re-sends with the same id — so a second attempt cannot create a second message.
Incoming messages are deduplicated by server id
The socket may echo your own message back, or replay messages after a reconnect. The 'incoming' reducer ignores any message whose serverId already exists in the list, so replays and echoes do not pile up.
Sort order tolerates out-of-order acks
bySortKey orders confirmed messages by the server's sequence number and puts still-sending messages after them by local creation time. So even if acks arrive out of order, the confirmed messages settle into the server's canonical order without unrelated pending messages jumping around.
Scroll follows user intent
An onScroll handler continuously records whether the user is within 80px of the bottom into a ref. When new messages render, a useLayoutEffect scrolls to the bottom only if that ref says the user was already there. Someone reading older history is never yanked down by an incoming message.
Why this is correct
A client id created at send time gives every message a stable identity before the server responds, enabling in-place acknowledgement and safe retry.
The server ack must patch the existing record by client id, never append, or the message appears twice.
A failed message stays visible and retries under the same id; it never spawns a duplicate.
Dedupe incoming socket messages by server id so echoes and reconnect replays do not accumulate.
Auto-scroll only when the user was already at the bottom, tracked from the scroll event, not assumed.
05Testing strategy
Critical behavior
Optimistic send reconciles correctly with server response, including the failure/retry path, without duplicating messages.
Scroll anchoring correctly distinguishes 'user is at bottom, auto-scroll' from 'user scrolled up, don't yank them down.'
Message ordering stays correct even with out-of-order server confirmations.
New-message announcements don't steal focus from the compose box.
Failure and boundary cases
If the user has scrolled up to read history, a new incoming message must not yank the viewport back down — show a 'new messages' indicator instead.
A failed optimistic send must be visually distinct and retryable without duplicating the message on retry.
Very rapid successive sends must preserve correct ordering even if server confirmations arrive out of order.
Extremely long messages or a burst of many messages shouldn't degrade scroll performance.
Accessibility
New incoming messages should be announced via a polite live region without stealing focus from the compose input.
The message list should be reachable and readable via keyboard/screen reader in logical (chronological) order.
Retry and status indicators (failed/sending) must not rely on color alone — pair with text or an icon with an accessible label.
06Performance and production hardening
Virtualize long history while preserving an anchor.
Batch high-frequency incoming events into bounded render updates.
Memoize message rows by record identity.
Avoid reading layout repeatedly during each append.
QAInterview questions and model answers
Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.
Model answer
It provides immediate stable identity and a mutation key before the server responds, enabling in-place acknowledgement and safe retry.