DEV Community

Cover image for Frontend Coding Challenge — Chat-Like Interface (Part 2)
Tiago
Tiago

Posted on

Frontend Coding Challenge — Chat-Like Interface (Part 2)

This is Part 2 of a two-part series. Part 1 covered requirements analysis, architecture decisions, and sending messages.

Now we need to wire up the jump links: clicking "Jump to First" or "Jump to Last" should scroll that message into view and highlight it for 1 second.

Scrolling to a Specific Message

We already implemented messageRefs, a Map storing refs to each message element. The same Map that powers auto-scroll also enables jumping to any message:

const jumpToMessage = (id: number) => {
  messageRefs.current.get(id)?.scrollIntoView({ behavior: "smooth" });
};
Enter fullscreen mode Exit fullscreen mode

Since we're using 0-based IDs (matching array indices), the first message has ID 0 and the last has ID messages.length - 1:

<div className="jump-links">
  <button onClick={() => jumpToMessage(0)}>Jump to First</button>
  <button onClick={() => jumpToMessage(messages.length - 1)}>Jump to Last</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Adding the Highlight

When we jump to a message, we also need to highlight it:

const jumpToMessage = (id: number) => {
  messageRefs.current.get(id)?.scrollIntoView({ behavior: "smooth" });
  setHighlightedId(id);
};
Enter fullscreen mode Exit fullscreen mode

The CSS from Part 1 already handles the visual:

.message.highlighted {
  background: #fff3cd;
}
Enter fullscreen mode Exit fullscreen mode

The Timeout

The highlight should disappear after 1 second. A first attempt:

const jumpToMessage = (id: number) => {
  messageRefs.current.get(id)?.scrollIntoView({ behavior: "smooth" });
  setHighlightedId(id);

  setTimeout(() => {
    setHighlightedId(null);
  }, 1000);
};
Enter fullscreen mode Exit fullscreen mode

This works for a single click, but has a problem: if the user clicks "Jump to First" and then "Jump to Last" within a second, both timeouts are scheduled. The first timeout will clear the highlight prematurely.

We need to cancel the previous timeout when starting a new one. A ref can hold the timeout ID:

const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const jumpToMessage = (id: number) => {
  messageRefs.current.get(id)?.scrollIntoView({ behavior: "smooth" });
  setHighlightedId(id);

  if (highlightTimeoutRef.current !== null) {
    clearTimeout(highlightTimeoutRef.current);
  }

  highlightTimeoutRef.current = setTimeout(() => {
    setHighlightedId(null);
  }, 1000);
};
Enter fullscreen mode Exit fullscreen mode

Now clicking a new jump link cancels the previous timeout, and only the latest highlight will clear after 1 second.

Cleanup on Unmount

If the component unmounts while a timeout is pending, the callback would still fire and call setHighlightedId(null) on an unmounted component. In React 18+ this is silently ignored, but it's still a wasted call — and in older React versions it produced a warning. A cleanup effect prevents this:

useEffect(() => {
  return () => {
    if (highlightTimeoutRef.current !== null) {
      clearTimeout(highlightTimeoutRef.current);
    }
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

The empty dependency array means the effect body runs once after the initial render, and the returned cleanup function runs on unmount.


Requirements Checklist

All requirements are now implemented:

  • ✓ Display 9 hardcoded messages on load
  • ✓ Send messages via button click
  • ✓ Empty messages cannot be sent
  • ✓ List scrolls to bottom on new message
  • ✓ Jump links scroll to first or last message
  • ✓ Jumped-to message is highlighted
  • ✓ Highlight disappears after 1 second
  • ✓ Only one message highlighted at a time
  • ✓ Auto-focus input on load (nice-to-have)
  • ✓ Send with Enter key (nice-to-have)

What We'd Improve

We could consider the following improvement points in order to deliver more robust, production-ready code:

Extract components — Separate concerns into focused components with clear props.

Handle empty state — If this component were extended to support message deletion, we'd want to show an empty state and disable the jump buttons when there are no messages. The messageRefs Map already handles deletion cleanly via the callback ref's null branch.

Accessibility — Move focus to the target message after jumping, and use an aria-live region to announce new messages to screen readers.


Final Code

App.tsx

import { useState, useRef, useEffect } from "react";
import "./App.css";

const initialMessages = Array.from({ length: 9 }, (_, i) => ({
  id: i,
  text: `Message ${i + 1}`,
}));

function App() {
  const [messages, setMessages] = useState(initialMessages);
  const [highlightedId, setHighlightedId] = useState<number | null>(null);
  const [inputValue, setInputValue] = useState("");

  const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
  const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    const lastId = messages.length - 1;
    messageRefs.current.get(lastId)?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  useEffect(() => {
    return () => {
      if (highlightTimeoutRef.current !== null) {
        clearTimeout(highlightTimeoutRef.current);
      }
    };
  }, []);

  const handleSend = () => {
    const trimmed = inputValue.trim();

    if (!trimmed) {
      return;
    }

    setMessages((prev) => [...prev, { id: prev.length, text: trimmed }]);
    setInputValue("");
  };

  const jumpToMessage = (id: number) => {
    messageRefs.current.get(id)?.scrollIntoView({ behavior: "smooth" });
    setHighlightedId(id);

    if (highlightTimeoutRef.current !== null) {
      clearTimeout(highlightTimeoutRef.current);
    }

    highlightTimeoutRef.current = setTimeout(() => {
      setHighlightedId(null);
    }, 1000);
  };

  return (
    <div className="app">
      <div className="jump-links">
        <button onClick={() => jumpToMessage(0)}>Jump to First</button>
        <button onClick={() => jumpToMessage(messages.length - 1)}>Jump to Last</button>
      </div>

      <div className="message-list">
        {messages.map((message) => (
          <div
            key={message.id}
            ref={(node) => {
              if (node) {
                messageRefs.current.set(message.id, node);
              } else {
                messageRefs.current.delete(message.id);
              }
            }}
            className={highlightedId === message.id ? "message highlighted" : "message"}
          >
            {message.text}
          </div>
        ))}
      </div>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleSend();
        }}
        className="input-area"
      >
        <input
          autoFocus
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

App.css

.app {
  max-width: 400px;
  margin: 0 auto;
  padding: 16px;
}

.jump-links {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.message-list {
  height: 300px;
  overflow-y: auto;
  border: 1px solid #ccc;
  padding: 8px;
}

.message {
  padding: 8px;
  margin-bottom: 8px;
  background: #f5f5f5;
  border-radius: 4px;
  transition: background-color 0.3s ease;
}

.message.highlighted {
  background: #fff3cd;
}

.input-area {
  display: flex;
  gap: 8px;
  margin-top: 16px;
}

.input-area input {
  flex: 1;
  padding: 8px;
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

This exercise demonstrates a common pattern in live coding challenges: balancing correctness with pragmatism. The shortcuts we took were deliberate, and we can articulate why we'd do things differently given more time. The key is knowing the difference between "good enough for a demo" and "production-ready" — and being able to explain both.


Previous: Part 1 — Understanding the Requirements and Basic Structure

Top comments (0)