Skip to the solution
HardMachine Coding10 min read

Build a Chat UI

395 views

The problem

Question presented to candidate: "Build the message pane of a support chat. Messages come in over a socket, the user can type and send, and the list should scroll to new messages, but not if the user has scrolled up to read history. Show the message immediately when sent, and handle the case where sending fails."

Requirements:

  • A scrollable conversation with sent and received bubbles and timestamps, plus a composer: Enter sends, Shift+Enter adds a new line, the Send button is disabled for empty input.
  • A simulated socket (no network) that acknowledges sends, shows a typing indicator, replies, and can push unprompted incoming messages.
  • Optimistic send: the message appears at once as "Sending...", becomes "sent" with a server time on ack, or "Failed to send" with a Retry button on error.
  • Smart auto-scroll: stick to the bottom only if the user is already near it; otherwise show an "N new messages" button. Your own send always scrolls down.
  • Stable list keys across the pending to sent transition.

What a strong answer should cover:

  • A message model with a client id, an optional server id and a status (pending, sent, failed).
  • Why the client id stays the key after the server id arrives (no remount, no lost focus or animation).
  • Detecting "at bottom" from scrollHeight - scrollTop - clientHeight with a threshold, stored without causing renders.
  • Scrolling after the DOM has updated (layout effect, post-flush watcher, after-render effect).
  • Accessibility: role="log" for the conversation, a labelled textarea, failures announced, IME composition not sending early.

Clarifying questions expected:

  • "Is message order guaranteed by the server, or do we sort by server timestamp?"
  • "Do we need history loading when scrolling to the top, or only live messages?"
  • "Should a failed message retry automatically, or only when the user asks?"

Code / implementation expected: Yes. A working chat pane with the simulated socket, optimistic send, retry and conditional auto-scroll.

  • React
  • Async
  • UI

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read ยท 9 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useCallback, useEffect, useRef, useState } from "react";
import { createSocket } from "./src/api/fakeSocket";
import { useStickToBottom } from "./src/hooks/useStickToBottom";
import MessageList from "./src/components/MessageList";
import Composer from "./src/components/Composer";

const SEED = [
  {
    id: "m1",
    author: "sam",
    text: "Hi! I am Sam from support.",
    time: "09:38",
    status: "sent",
  },
  {
    id: "m2",
    author: "sam",
    text: "How can I help today?",
    time: "09:39",
    status: "sent",
  },
];

export default function App() {
  const [messages, setMessages] = useState(SEED);
  const [typing, setTyping] = useState(false);
  const [unread, setUnread] = useState(0);
  const socket = useRef(null);
  const nextId = useRef(1);
  const stick = useStickToBottom([messages, typing]);

  useEffect(() => {
    const s = createSocket({
      onMessage: (m) => {
        setMessages((list) => [...list, { ...m, status: "sent" }]);
        // do not yank the reader down
        if (!stick.atBottom.current) setUnread((n) => n + 1);
      },
      onTyping: setTyping,
    });
    socket.current = s;
    // StrictMode mounts twice: the first socket is closed
    return () => s.close();
  }, [stick.atBottom]);

  const patch = (id, changes) =>
    setMessages((list) =>
      list.map((m) => (m.id === id ? { ...m, ...changes } : m)),
    );

  const deliver = useCallback((id, text) => {
    socket.current.send(text, id).then(
      (ack) => patch(id, { status: "sent", time: ack.time, serverId: ack.id }),
      () => patch(id, { status: "failed" }),
    );
  }, []);

  const send = (text) => {
    const id = "c" + nextId.current++;
    stick.force.current = true; // your own message always scrolls into view
    setMessages((list) => [
      ...list,
      { id, author: "me", text, time: null, status: "pending" },
    ]); // optimistic
    setUnread(0);
    deliver(id, text);
  };

  const retry = (m) => {
    patch(m.id, { status: "pending" });
    deliver(m.id, m.text);
  };

  const onScroll = () => {
    if (stick.onScroll()) setUnread(0);
  };

  return (
    <main className="chat">
      <header className="chat-head">
        <h1>Support chat</h1>
        <button
          data-testid="incoming"
          onClick={() =>
            socket.current.pushIncoming("Any update on your side?")
          }
        >
          Simulate incoming
        </button>
      </header>
      <div className="list-wrap">
        <MessageList
          messages={messages}
          typing={typing}
          listRef={stick.ref}
          onScroll={onScroll}
          onRetry={retry}
        />
        {unread > 0 && (
          <button
            className="jump"
            data-testid="jump"
            onClick={() => {
              stick.jump();
              setUnread(0);
            }}
          >
            {unread} new {unread === 1 ? "message" : "messages"} โ†“
          </button>
        )}
      </div>
      <Composer onSend={send} />
    </main>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 96 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

Build a Memory (flip-card) Game

Continue