useAutoScroll custom hook in Reactjs

useAutoScroll custom hook in Reactjs

Introduction

You might have seen on streaming platforms such as youtube.com or twitch.com where there are live chat feeds, whenever a new chat comes, mainly two things happen:
1. Auto-scrolls at the bottom
2. If a user scrolls the feed on the top then the auto-scroll behavior stops.

In this blog, we will try to build a react custom hook called useAutoScroll which will help us to achieve the above behavior.

Let's start

So let's start with ChatMessagesList component, which only knows how to render the given chats.

import React, { forwardRef } from "react";

interface ChatMessagesListProps {
  chatMessages: {
    message: string;
    color: string;
    username: string;
  }[];
}

export const ChatMessagesList = forwardRef(
  ({ chatMessages }: ChatMessagesListProps, ref) => {
    return (
      <ul
        ref={ref}
        className="chat-list"
      >
        {chatMessages.length > 0 &&
          chatMessages.map(({ color, username, message }, index) => (
            <li key={index}>
              <span style={{ color, fontWeight: "600" }}>{username}</span>:{" "}
              {message}
            </li>
          ))}
      </ul>
    );
  }
);

You might have noticed forwardRef, is nothing but a way to pass the refs as a prop from the parent component to the current child component in this case from <App/> to <ChatMessagesList/> component.

Now let's see how the <App/> component looks.

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

export default function App() {
  const [chatMessages, setChatMessages] = useState<
    { color: string; message: string; username: string }[]
  >([]);

  useEffect(() => {
   // fetch the chats and update the list
   // using setChatMessages function
  }, []);

  const containerRef = useRef<HTMLElement | unknown>();

  useEffect(() => {
    const container = (containerRef?.current as HTMLElement) ?? {};

    if (container) {
      container.scrollTop = container.scrollHeight;
    }
  }, [chatMessages]);


  return (
    <div className="App">
      <h1>Welcome to live chat</h1>
      <ChatMessagesList ref={containerRef} chatMessages={chatMessages} />
    </div>
  );
}

The above solution is perfect for auto-scrolling the <ChatMessagesList /> component, but we wanted to build a reusable hook that can be used for any container.

So let's extract the reusable logic into our custom hook.

import { useEffect, useRef } from "react";

export const useAutoScroll = <T>(list: T) => {
  const containerRef = useRef<HTMLElement | unknown>();

  useEffect(() => {
    const container = (containerRef?.current as HTMLElement) ?? {};

    if (container) {
      container.scrollTop = container.scrollHeight;
    }
  }, [list]);

  return { containerRef };
};

So let's understand what's happening over here.

If you see carefully then useAutoScroll is just a function that takes a list, whenever that list is updated, the useEffect is triggered, which just takes the HTML element's ref and updates its scrollTop value.

Let's also see what our <App /> component will look like now.

import { useEffect, useState } from "react";
import { ChatMessagesList } from "./ChatMessagesList";
import "./styles.css";

export default function App() {
  const [chatMessages, setChatMessages] = useState<
    { color: string; message: string; username: string }[]
  >([]);

  useEffect(() => {
   // fetch the chats and update the list
   // using setChatMessages function
  }, []);

  // Note here
  const { containerRef } = useAutoScroll(chatMessages);

  return (
    <div className="App">
      <h1>Welcome to live chat</h1>
      <ChatMessagesList ref={containerRef} chatMessages={chatMessages} />
    </div>
  );
}

There is one problem with the above hook, whenever a user scrolls on top, the container still scrolls back to the bottom.

To fix this let's update our useAutoScroll hook.

import { useEffect, useRef } from "react";

export const useAutoScroll = <T>(list: T) => {
  const containerRef = useRef<HTMLElement | unknown>();
  const keepScrolling = useRef<boolean>(true); // note this 

  useEffect(() => {
    const handleScroll = (e: Event) => {
      const container = e.target as HTMLElement;
      if (
        container.scrollTop + container.clientHeight <
        container.scrollHeight
      ) {
        keepScrolling.current = false;
      } else {
        keepScrolling.current = true;
      }
    };

    const container = (containerRef?.current as HTMLElement) ?? {};

    if (container && keepScrolling.current) { // note this
      container.scrollTop = container.scrollHeight;
    }

    container?.addEventListener("scroll", handleScroll); // note this 

    return () => container?.removeEventListener("scroll", handleScroll);
  }, [list]);

  return { containerRef };
};

So to stop the scroll whenever the user is scrolling on top we have kept one more ref which is keepScrolling which tells the useEffect if it should update the scrollTop value or not.

Over here container.scrollTop + container.clientHeight will equal to container.scrollHeight which is the whole height of the overflowed list.

So whenever container.scrollTop + container.clientHeight value becomes less than
container.scrollHeight then it indicates that the user has scrolled the list, so we should stop auto-scrolling.

That's it, thanks for reading, I hope you learned something new, if yes then do share it with your friends.

You can find the whole code below:
https://codesandbox.io/s/useautoscroll-t26fcg?file=/src/hooks/useAutoScroll.ts

References

https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop

https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight

https://developer.mozilla.org/en-US/docs/Web/API/Element/clientHeight

https://react.dev/learn/reusing-logic-with-custom-hooks

Connect with me on Twitter, GitHub, and LinkedIn.