Why React Seems to Scroll Just Not Far Enough

Posted on | 534 words | ~3 mins

The Task

So here is what we’re trying to do: we have a scrollable <div> to display messages.

Take this example (full fiddle here ):

// Reduced excerpt, see full fiddle for a running example

function App() {
  const [messages, setMessages] = useState(["foo", "bar", "baz"]);
  const [newMessage, setNewMessage] = useState("");
  const handleNewMessageTextChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    setNewMessage(e.target.value);
  };

  const handleNewMessageKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
    if (e.key === "Enter") {
      setMessages([...messages, newMessage]);
      setNewMessage("");
    }
  };

  const messageViewListItems = messages.map((m, index) => (
    <li key={index}>{m}</li>
  ));

  return (
    <>
      <div className="message-list">
        <ul>{messageViewListItems}</ul>
      </div>
      <input
        className="new-message-input"
        onChange={handleNewMessageTextChange}
        onKeyDown={handleNewMessageKeyDown}
        value={newMessage}
        placeholder={"Enter Message"}
      />
    </>
  );
}

export default App;

We must scroll down to get to our last entries

The user can enter a message in the input field and push it to the message list by pressing enter.

As you can see, the messages appear at the bottom of the list. A typical pattern in many applications is to scroll to the end of the list whenever a new entry appears. This behavior can often be seen in chat applications. New messages appear at the bottom of the message list and push older messages up.

The most straightforward approach might be to add a reference to the bottom of the list and call .scrollIntoView() on it whenever we update the list.

The Problem

But there is also a major pitfall you can overlook easily.

Let’s make some changes to our application (full fiddle ):

function App() {

  // [...]

  // Create the reference
  const endOfMessagesReference = useRef<null | HTMLDivElement>(null);

  // [...]

  const handleNewMessageKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
    if (e.key === "Enter") {
      setMessages([...messages, newMessage]);
      setNewMessage("");
      // scroll element into view, after updating our message list
      endOfMessagesReference.current?.scrollIntoView();
    }
  };

  // [...]

  return (
    <>
      <div className="message-list">
        <ul>{messageViewListItems}</ul>
        <div ref={endOfMessagesReference} />
      </div>
      [...]
    </>
  );
}

We’re scrolling down, but it’s just not enough

Adding a new message does scroll down, but it’s just not far enough. There is still a bit left to scroll, and it seems like we need to add some kind of offset.

The Solution

But we don’t. We made a mistake. By putting the .scrollIntoView() in line 30, we scroll after updating the state but before the changed state was rendered. That means that we’re actually scrolling to where the list ended before we added our new element.

This is a common beginner mistake. It is easy to make and can be hard to figure out. It looks like an offset problem, and one might be inclined to try to fix it with CSS.

So the question is: how can we scroll after React is done rendering? It’s actually quite easy: we use an effect hook .

Take a look at this example (full fiddle here ):

function App() {
  // [...]

  const endOfMessagesReference = useRef<null | HTMLDivElement>(null);

  useEffect(() => {
      endOfMessagesReference.current?.scrollIntoView(true);
    },
    [messages] // the message list is our _dependency_
  );

  // [...]

  const handleNewMessageKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
    if (e.key === "Enter") {
      setMessages([...messages, newMessage]);
      setNewMessage("");
    }
  };

  // [...]

  return (
    <>
      <div className="message-list">
        <ul>{messageViewListItems}</ul>
        <div ref={endOfMessagesReference} />
      </div>
      [...]
    </>
  );
}

export default App;

Now we scroll down to the end of the div whenever we add a message

Adding messages as a dependency makes React run our effect whenever messages changes and rendering is done.

.scrollIntoView() now works exactly as intended. No need for any offsets ;).