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;
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>
[...]
</>
);
}
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;
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 ;).