Search code examples
javascriptcssreactjsscroll

scrolling to bottom of container ReactJS


So, ive been stuck on this for awhile now and i've tried several solutions from StackOverflow to make this happen but nothing seems to work. It should be as simple as using the useRef hook and scrolling to that component, but it doesnt work, i've tried targeting the DOM after the component finishes loading, i've tried even scrolling into view the bottom of the container after i add a new message.

so, im creating a chat application, and like most chats i want the most recent chat at the bottom of the container, and i want the scroll bar to scroll to that most recent chat. the way i have the container setup is 3 seperate columns where the middle column holds all the information on the page, and the left most column is the navbar. the chats are in the middle column, and extend to the bottom of the viewport, and from there anything that overfills the container is hidden, and has a scroll bar.

I should be doing this inside the useEffect hook, but it doesnt work there either. can anyone see something i cannot see?

const ChatRoom = () => {
  const dispatch = useDispatch();
  const { id } = useParams();
  const {
    selectedChat: { chat, loading, messages },
  } = useSelector((state) => state.chat);
  const { user } = useSelector((state) => state.auth);
  const [message, setMessage] = React.useState("");
  useEffect(() => {
    // check if the chat is already in the store if not dispatch getChat
    if (!chat || chat.id !== id) {
      dispatch(getChat(id));
    }
    scrollToBottom(false);
  }, [dispatch, id]);

  const updateChatName = () => {
    // displays a prompt which gathers the chat name the user wants to change to
    // dispatches to update chat action to update the chat name
    const newChatName = prompt("Enter new chat name");
    if (newChatName) {
      dispatch(updateChat({ id, chatName: newChatName }));
      window.location.reload();
    } else {
      dispatch(setAlert("Please enter a chat name", "info"));
    }
  };

  const sendMsg = (e, message) => {
    // prevents the page from refreshing when the user presses enter
    e.preventDefault();
    // trim the message to remove whitespace
    const trimmedMessage = message.trim();
    // if the message is not empty
    if (trimmedMessage) {
      // dispatch the send message action
      dispatch(sendMessage(trimmedMessage, chat._id));
      // get the textarea element and clear it
      const textarea = document.getElementsByClassName("messageInput")[0];
      textarea.value = "";
      // scroll to the bottom of the chat
      const c = document.getElementsByClassName("chatContainer")[0];
      c.scrollTop({
        scrollTop: c.scrollHeight,
        animate: true,
        duration: "slow",
        easing: "easeInOutQuad",
      });
      // focus the textarea
      textarea.focus();
      // reset the message
      setMessage("");
    } else {
      // if the message is empty
      dispatch(setAlert("Please enter a message", "info"));
    }
  };

  return (
    <Container fluid className="chatPageContainer">
      <Meta
        title={
          chat
            ? chat.chatName
              ? `Chat | ${chat.chatName}`
              : `Chat | ${chat.users[0].firstName}, ${
                  chat.users[1].firstName
                } ${
                  chat.users.length > 2
                    ? `+ ${chat.users.length - 2} other users`
                    : ""
                }`
            : ``
        }
      />
      <Row>
        <span className="titleContainer">
          <h1>Chat</h1>
        </span>
      </Row>
      <div className="chatTitleBarContainer">
        {chat && (
          <>
            <TitleBar users={chat.users} user={user} />
            <span id="chatName" onClick={updateChatName}>
              {chat.chatName
                ? chat.chatName
                : `${chat.users[0].firstName}, ${chat.users[1].firstName} ${
                    chat.users.length > 3
                      ? `+ ${chat.users.length - 2} other users`
                      : ""
                  }`}
            </span>
          </>
        )}
      </div>
      <div className="mainContentContainer">
        <div className="chatContainer">
          {loading ? (
            <Loader />
          ) : (
            <>
              <div className="chatMessages">
                {messages &&
                  messages.map((message, indx) => {
                    // get the last messasge in the chat, and set the lastSenderId to that message.sender._id
                    const lastMessage = messages[indx - 1];
                    let lastSenderId = lastMessage
                      ? lastMessage.sender._id
                      : null;

                    return (
                      <MessageItem
                        key={message._id}
                        message={message}
                        user={user}
                        nextMessage={messages[indx + 1]}
                        lastSenderId={lastSenderId}
                      />
                    );
                  })}
                {/* dummy div to scroll to bottom of container */}
              </div>
              <div id="bottomChatContainer"></div>
            </>
          )}
          <div className="footer">
            <textarea
              name="message"
              className="messageInput"
              onKeyDown={(e) => {
                if (e.key === "Enter" && !e.shiftKey) {
                  sendMsg(e, message);
                }
              }}
              onChange={(e) => setMessage(e.target.value)}
              placeholder="Type a message..."
            ></textarea>
            <FiSend
              className="sendMessageButton"
              onClick={(e) => sendMsg(e, message)}
            />
          </div>
        </div>
      </div>
    </Container>
  );
};

async function scrollToBottom(animate) {
  const c = document.getElementById("bottomChatContainer");
  if (animate) {
    c.animate({ scrollTop: c.scrollHeight }, "slow");
  } else {
    c.scrollTop = c.scrollHeight;
  }
}

export default ChatRoom;


Solution

  • ---Edit: Revised Solution---

    In the map function of the array of messages that returns a MessageItem component, one can make use of a temporary variable(boolean) that tracks whether or not the current element of the messages array is the last one, at which point the bool becomes true. This bool value should be passed as prop to the messageItem component that renders each message element. The code should look something like this:

    ...
    <div>{ // Make sure this has necessary CSS to allow it to scroll in the y-direction
        messagesArray.map(message, index) => {
            let tempBool = false;
            if(last element) tempBool = true
            return(
                <MessageItem {...props} scrollIntoViewBool={tempBool} />
            )
        }
    }</div>
    ....
    

    In the MessageItem component, one can assign a ref to each container that renders message details, and using the combination of the ref and the scrollIntoViewBool prop, use the .scrollIntoView method on the current value of the ref whenever the prop is true. But this should be done in a useEffect so that the scrolling happens after the messages have been loaded. The code will look like this:

    ...
    const messageRef = useRef(null);
    useEffect(() => {
        if(!scrollIntoViewBool) return;
        const lastMessage = messageRef.current;
        lastMessage.scrollIntoView();
    }, [messageRef, scrollIntoViewBool]);
    ...
    
    return...
        <div ref={messageRef}>Message Contents</div>
    

    For more details you can check out this and this GitHub link to the chat component I made for my app


    one thing you could do is instead of scrolling down to the bottom of the enclosing div, add a sibling div to the mapped-messages and then scroll it into view every time a new message is posted/fetched. The code will look something like this:

    <div className="chatMessages">
         {messages.map().....}
         <div ref={dummyDiv}></div> /* scroll this into view */
    </div>