Machine-Coding: Build a Chat UI with Optimistic Updates

The chat UI machine-coding round tests whether the candidate can build a real-time-feeling messaging interface from scratch. The exercise covers state management, optimistic updates, scroll behavior, message status indicators, and accessibility — most of these are not obviously chat-related, which is part of why the round is challenging.

This piece walks through the implementation patterns and what interviewers grade.

The typical prompt

  • “Build a chat UI. The user types a message, presses Enter, and the message appears in the chat history.”
  • “Build a Slack-like chat interface using this mock send-message API. Implement optimistic updates so messages appear instantly even on slow networks.”
  • “Build a messaging interface where messages can be in ‘sending’, ‘sent’, or ‘failed’ states.”

What interviewers grade

  • Working chat send/display. Messages appear when sent.
  • Optimistic updates. Messages show immediately with a ‘sending’ indicator; transition to ‘sent’ on confirmation.
  • Failure handling. Failed messages show as ‘failed’ with a retry option.
  • Scroll behavior. Scroll to bottom on new messages, but only if the user was already at the bottom.
  • Input handling. Enter sends, Shift+Enter inserts a newline, empty messages don’t send.
  • Accessibility. ARIA live region for new messages so screen readers announce them.
  • Message ordering. Messages appear in correct chronological order even when send latencies vary.

Implementation

Step 1: state and structure

type Message = {
  id: string;
  text: string;
  status: 'sending' | 'sent' | 'failed';
  timestamp: number;
};

function ChatUI({ sendMessage }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

  return (
    <div className="chat">
      <div className="messages" role="log" aria-live="polite">
        {messages.map(m => <MessageBubble key={m.id} message={m} onRetry={retry} />)}
        <div ref={messagesEndRef} />
      </div>
      <form onSubmit={handleSubmit}>
        <textarea
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={handleKeyDown}
        />
      </form>
    </div>
  );
}

Step 2: optimistic send

async function handleSubmit(e) {
  e.preventDefault();
  if (!input.trim()) return;
  const message: Message = {
    id: crypto.randomUUID(),
    text: input,
    status: 'sending',
    timestamp: Date.now(),
  };
  setMessages(prev => [...prev, message]);
  setInput('');
  try {
    await sendMessage(message);
    setMessages(prev =>
      prev.map(m => m.id === message.id ? { ...m, status: 'sent' } : m)
    );
  } catch {
    setMessages(prev =>
      prev.map(m => m.id === message.id ? { ...m, status: 'failed' } : m)
    );
  }
}

Optimistic update means the message shows in the UI immediately with status ‘sending’. When the API confirms, status becomes ‘sent’. If it fails, status becomes ‘failed’.

Step 3: keyboard handling

function handleKeyDown(e) {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault();
    handleSubmit(e);
  }
  // Shift+Enter inserts a newline naturally (default behavior)
}

Step 4: scroll behavior

The hard requirement: scroll to bottom on new messages, but ONLY if the user was already at the bottom. If the user has scrolled up to read history, don’t yank them down.

const [autoScroll, setAutoScroll] = useState(true);

function handleScroll(e) {
  const el = e.currentTarget;
  const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
  setAutoScroll(atBottom);
}

useEffect(() => {
  if (autoScroll && messagesEndRef.current) {
    messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
  }
}, [messages, autoScroll]);

The 50px threshold accounts for slight scroll offsets — exact bottom matching is brittle.

Step 5: failure retry

async function retry(messageId: string) {
  setMessages(prev =>
    prev.map(m => m.id === messageId ? { ...m, status: 'sending' } : m)
  );
  const message = messages.find(m => m.id === messageId);
  if (!message) return;
  try {
    await sendMessage(message);
    setMessages(prev =>
      prev.map(m => m.id === messageId ? { ...m, status: 'sent' } : m)
    );
  } catch {
    setMessages(prev =>
      prev.map(m => m.id === messageId ? { ...m, status: 'failed' } : m)
    );
  }
}

Common pitfalls

  • No optimistic update. Waiting for the API before showing the message. Feels slow on poor networks.
  • Always scrolling to bottom. Yanks the user down even when they’re reading history.
  • No failure handling. Failed messages disappear silently or stay in ‘sending’ forever.
  • Race condition on out-of-order responses. If message B sends faster than message A, the order can flip without proper handling.
  • No ARIA live region. Screen readers don’t announce new messages.
  • Empty messages send. Trim input and check for empty before submitting.
  • Enter+Shift handling missing. Most chat apps allow newlines via Shift+Enter.

Stretch goals

  • Typing indicators for the other party.
  • Message editing and deletion.
  • Group chat with presence.
  • Read receipts / message status (delivered, read).
  • Markdown or rich-text formatting.
  • File / image upload.
  • Message search.

Time budget for a 45-60 minute round

  • 0-5 min: clarify requirements (optimistic? failure handling? group vs 1:1?).
  • 5-15 min: basic UI structure with message list and input.
  • 15-25 min: optimistic send with status states.
  • 25-35 min: scroll behavior, keyboard handling.
  • 35-45 min: failure retry, accessibility, edge cases.
  • 45-60 min: stretch goals if time.

Frequently Asked Questions

How is this round different from a basic React form?

The chat UI tests state management for asynchronous operations, scroll handling, optimistic UX patterns. A basic form is one input one submission; chat is a list with state, async, and UX considerations.

Should I implement WebSocket simulation?

Generally no — the interviewer provides a mock API. WebSocket integration is too much for a 45-minute round.

What about virtualization for long chat histories?

Stretch goal. Mention if you have time; most interviews don’t expect virtualization in this round.

How important is the optimistic update?

Very. Modern messaging apps all use optimistic updates. Without it, the chat feels slow and dated.

What’s the most-missed accessibility item?

The ARIA live region. Screen readers cannot tell that new content arrived without it.

Scroll to Top