ESC

Type to search the knowledge base.

Chat Message UI

Machine-coding brief for a chat message list — bubbles, grouping, scroll anchoring, composer, and a11y.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • realtime

Problem statement

Build a chat message UI: scrollable message list + composer. Own vs other bubbles, timestamps, send on Enter, and correct scroll behavior when new messages arrive. Interviewers score list state, scroll anchoring, and empty/loading states — not WebSocket infrastructure (unless they expand scope).

Requirements

Must have

  • Message list for one conversation (mock data or prop)
  • Distinguish mine vs theirs (alignment + style)
  • Composer: text input + Send; Enter sends, Shift+Enter newline (clarify)
  • Auto-scroll to bottom on send / when already near bottom on incoming
  • Empty conversation state
  • Accessible names on controls; messages in a live-friendly structure

Should have

  • Group consecutive messages from same author (hide repeated avatars)
  • Relative timestamps (“2m ago”) with full time on hover/title
  • Optimistic send with pending/failed status
  • “New messages” jump button when user scrolled up

Nice to have

  • Image attachments / emoji
  • Virtualize 10k messages
  • Read receipts
  • Day separators (“Today”, “Yesterday”)

Planning (5 minutes out loud)

  1. Message model — id, authorId, body, createdAt, status
  2. Scroll policy — stick to bottom unless user scrolled away
  3. Composer ownership — controlled input in parent or child
  4. MVP — list + send + mine/theirs; then stickiness + grouping
  5. Don’t build sockets unless asked — onSend callback is enough

Architecture

ChatWindow
├── MessageList
│   ├── DaySeparator?
│   └── MessageBubble
├── JumpToLatestButton
└── Composer

Data model

type MessageStatus = "sending" | "sent" | "failed";

type Message = {
  id: string;
  authorId: string;
  body: string;
  createdAt: number;
  status?: MessageStatus;
};

type ChatProps = {
  messages: Message[];
  currentUserId: string;
  onSend: (body: string) => void | Promise<void>;
  isLoadingHistory?: boolean;
};

Implementation sketch

Near-bottom detection

function useStickToBottom(ref: RefObject<HTMLElement | null>, deps: unknown[]) {
  const stick = useRef(true);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    function onScroll() {
      const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
      stick.current = distance < 80;
    }
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, [ref]);

  useLayoutEffect(() => {
    const el = ref.current;
    if (el && stick.current) {
      el.scrollTop = el.scrollHeight;
    }
  }, deps);
}

Composer

function Composer({ onSend }: { onSend: (body: string) => void }) {
  const [text, setText] = useState("");

  function submit() {
    const body = text.trim();
    if (!body) return;
    onSend(body);
    setText("");
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        submit();
      }}
    >
      <label className="sr-only" htmlFor="chat-input">
        Message
      </label>
      <textarea
        id="chat-input"
        rows={1}
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.shiftKey) {
            e.preventDefault();
            submit();
          }
        }}
      />
      <button type="submit" disabled={!text.trim()}>
        Send
      </button>
    </form>
  );
}

Bubble + grouping

function MessageBubble({
  message,
  mine,
  showMeta,
}: {
  message: Message;
  mine: boolean;
  showMeta: boolean;
}) {
  return (
    <div
      className={mine ? "msg mine" : "msg theirs"}
      data-status={message.status}
    >
      {showMeta && !mine && <div className="author">{message.authorId}</div>}
      <p>{message.body}</p>
      {showMeta && (
        <time dateTime={new Date(message.createdAt).toISOString()}>
          {formatTime(message.createdAt)}
        </time>
      )}
      {message.status === "failed" && (
        <button type="button">Retry</button>
      )}
    </div>
  );
}

Group when prev.authorId === curr.authorId and time gap < 5 minutes.

Accessibility essentials

  • Message list: role="log" + aria-live="polite" or announce only the jump control — full aria-live on every message can spam screen readers. Prefer polite live region for “N new messages” and semantic list structure for history.
  • Composer textarea labeled; Send disabled when empty
  • Failed sends exposed as text, not color alone
  • Don’t trap focus inside chat unless it’s a modal overlay

Performance notes

  • For interviews, 200 messages as a flat map is fine
  • If they ask scale: virtualize with fixed or measured heights; keep stick-to-bottom logic using virtualizer scroll API
  • Avoid re-rendering entire list on every keystroke — isolate composer state
  • Images: fixed aspect placeholders to prevent scroll jumps

Footguns

  1. Always force scroll to bottom — user reading history gets yanked
  2. Lost scroll position when prepending history (load older)
  3. Enter vs Shift+Enter inconsistency with product expectations
  4. Duplicate keys when optimistic id replaced by server id
  5. Timezone on timestamps without locale

Loading older messages

When prepending:

const el = listRef.current;
const prevHeight = el.scrollHeight;
// setMessages(older => [...older, ...prev])
requestAnimationFrame(() => {
  el.scrollTop = el.scrollHeight - prevHeight + el.scrollTop;
});

Interview out-loud answer

Chat is a virtual list plus composer. I’d model messages with client ids and status for optimistic UI. Scroll sticks to bottom only if the user was already near bottom; otherwise show a jump control. Composer owns draft text so typing doesn’t re-render every bubble. MVP is bubbles + send + stickiness; grouping and retries next. Realtime transport is a thin messages prop unless they ask for sockets.

Further reading