Search code examples
javascriptreactjschat

How to implement a React infinite scroll component for a chat app like Facebook Messenger?


I know there are many questions about this topic (React infinite scroll), my question aims to go more in-depth in order to identify the best currently available solution to implement such a component.

I am working on a chat app and I have created a component similar to the Facebook's Messenger chat window which you can see on desktop browsers.

Facebook:

enter image description here

Mine (so far):

enter image description here enter image description here

Implementing the infinite scroll with infinite loading turns out to be tricky. From a UX perspective, I need to always satisfy at least the following properties:

  1. The height of each row message should be dynamically computed just-in-time because I do not know the height of the message in advance as they do not have a fixed height;
  2. Whenever a user types a new message, the scroll must automatically reach the bottom of the scrollable component to the last just sent message. The scrollable component itself has a top and bottom padding (or I can also use a margin) in order to leave some space between the top and the first and the bottom and the last message of the chat (look at the above images);
  3. The chat is inside a popover element which opens with a fade-in animation and it can be closed and opened by the user while they are using the page;

Now, in order to do that, I have already tried several libraries:

  • react-infinite: my first attempt, abandoned because it needs to know the heights of all the elements in advance;
  • react-list: I found it really powerful, the thing is that if I close my popover and reopen it after sometimes it loses some already rendered messages and it seems to me that it could be a bug of the react-list component. Also, the component does not allow me to display the scrolling bottom upwards (see https://github.com/coderiety/react-list/issues/50);
  • react-virtualized: very powerful, but I found it tricky to use List with an InfiniteLoader together with AutoSizer, CellMeasurer and CellMeasurerCache. Also, as I send a message if I call List.scrollToIndex(lastIndex) to scroll automatically the container to the bottom the scroll does not reach the bottom completely, as the scrollable container has top and bottom padding. I couldn't achieve a satisfiable result with this component.
  • react-infinite-any-height: I would like to give it a try, but currently it seems that it hasn't been ported to React 16 yet if I install it NPM warns me about an unsatisfied peer dependency of React 15, but I use React 16.

So my question is more a way to confront each other: have someone of you ever had to implement a React chat component with the 3 requirements I have written above? What library did you use? As Facebook Messenger handles this pretty well and they use React, do someone of you know how did they implement such a component? If I inspect the chat messages of the Facebook chat window it seems that it keeps all the already rendered messages in the DOM. But, if so, couldn't this affect performance?

So I have more questions than answers for now. I would really like to find a component that suits my needs. The other option would be to implement my own.


Solution

  • UPDATE 2022

    I have created an infinite scroll React component called react-really-simple-infinite-scroll, you can find it on GitHub (https://github.com/tonix-tuft/react-really-simple-infinite-scroll) and install it with npm (https://www.npmjs.com/package/react-really-simple-infinite-scroll):

    npm install --save react-really-simple-infinite-scroll
    npm install --save react react-dom # install React peer deps
    

    Usage:

    import React, { useState, useCallback, useEffect } from "react";
    import { ReallySimpleInfiniteScroll } from "react-really-simple-infinite-scroll";
    
    // You can use any loading component you want. This is just an example using a spinner from "react-spinners-kit".
    import { CircleSpinner } from "react-spinners-kit";
    
    /**
     * @type {number}
     */
    let itemId = 0;
    
    /**
     * @type {Function}
     */
    const generateMoreItems = numberOfItemsToGenerate => {
      const items = [];
      for (let i = 0; i < numberOfItemsToGenerate; i++) {
        itemId++;
        items.push({
          id: itemId,
          label: `Item ${itemId}`,
        });
      }
      return items;
    };
    
    export default function App() {
      const [displayInverse, setDisplayInverse] = useState(false);
      const [hasMore, setHasMore] = useState(true);
      const [isInfiniteLoading, setIsInfiniteLoading] = useState(true);
      const [items, setItems] = useState([]);
    
      const onInfiniteLoadCallback = useCallback(() => {
        setIsInfiniteLoading(true);
        setTimeout(() => {
          const moreItems = generateMoreItems(25);
          setItems(items => items.concat(moreItems));
          setIsInfiniteLoading(false);
        }, 1000);
      }, []);
    
      useEffect(() => {
        onInfiniteLoadCallback();
      }, [onInfiniteLoadCallback]);
    
      useEffect(() => {
        if (items.length >= 200) {
          setHasMore(false);
        }
      }, [items.length]);
    
      return (
        <div className="app">
          <ReallySimpleInfiniteScroll
            key={displayInverse}
            className={`infinite-scroll ${
              items.length && displayInverse
                ? "display-inverse"
                : "display-not-inverse"
            }`}
            hasMore={hasMore}
            length={items.length}
            loadingComponent={
              <div className="loading-component">
                <div className="spinner">
                  <CircleSpinner size={20} />
                </div>{" "}
                <span className="loading-label">Loading...</span>
              </div>
            }
            isInfiniteLoading={isInfiniteLoading}
            onInfiniteLoad={onInfiniteLoadCallback}
            displayInverse={displayInverse}
          >
            {(displayInverse ? items.slice().reverse() : items).map(item => (
              <div key={item.id} className="item">
                {item.label}
              </div>
            ))}
          </ReallySimpleInfiniteScroll>
          <div>
            <button
              onClick={() => setDisplayInverse(displayInverse => !displayInverse)}
            >
              Toggle displayInverse
            </button>
          </div>
        </div>
      );
    }
    
    

    ORIGINAL ANSWER:

    I ended up implementing my own very simple infinite scroll component (didn't refactor it to use hooks yet, though):

    
    import React from "react";
    import {
        isUndefined,
        hasVerticalScrollbar,
        hasHorizontalScrollbar,
        isInt,
        debounce
    } from "js-utl";
    import { classNames } from "react-js-utl/utils";
    
    export default class SimpleInfiniteScroll extends React.Component {
        constructor(props) {
            super(props);
    
            this.handleScroll = this.handleScroll.bind(this);
            this.onScrollStop = debounce(this.onScrollStop.bind(this), 100);
    
            this.itemsIdsRefsMap = {};
            this.isLoading = false;
            this.isScrolling = false;
            this.lastScrollStopPromise = null;
            this.lastScrollStopPromiseResolve = null;
    
            this.node = React.createRef();
        }
    
        componentDidMount() {
            this.scrollToStart();
        }
    
        getNode() {
            return this.node && this.node.current;
        }
    
        getSnapshotBeforeUpdate(prevProps) {
            if (prevProps.children.length < this.props.children.length) {
                const list = this.node.current;
                const axis = this.axis();
                const scrollDimProperty = this.scrollDimProperty(axis);
                const scrollProperty = this.scrollProperty(axis);
                const scrollDelta = list[scrollDimProperty] - list[scrollProperty];
    
                return {
                    scrollDelta
                };
            }
            return null;
        }
    
        componentDidUpdate(prevProps, prevState, snapshot) {
            if (
                this.isLoading &&
                ((prevProps.isInfiniteLoading && !this.props.isInfiniteLoading) ||
                    ((this.props.hasMore || prevProps.hasMore) &&
                        prevProps.children.length !==
                            this.props.children.length)) &&
                snapshot
            ) {
                if (this.props.displayInverse) {
                    const list = this.node.current;
                    const axis = this.axis();
                    const scrollDimProperty = this.scrollDimProperty(axis);
                    const scrollProperty = this.scrollProperty(axis);
                    const scrollDelta = snapshot.scrollDelta;
                    const scrollTo = list[scrollDimProperty] - scrollDelta;
    
                    this.scrollTo(scrollProperty, scrollTo);
                }
                this.isLoading = false;
            }
        }
    
        loadingComponentRenderer() {
            const { loadingComponent } = this.props;
    
            return (
                <div
                    className="simple-infinite-scroll-loading-component"
                    key={-2}
                >
                    {loadingComponent}
                </div>
            );
        }
    
        axis() {
            return this.props.axis === "x" ? "x" : "y";
        }
    
        scrollProperty(axis) {
            return axis === "y" ? "scrollTop" : "scrollLeft";
        }
    
        offsetProperty(axis) {
            return axis === "y" ? "offsetHeight" : "offsetWidth";
        }
    
        clientDimProperty(axis) {
            return axis === "y" ? "clientHeight" : "clientWidth";
        }
    
        scrollDimProperty(axis) {
            return axis === "y" ? "scrollHeight" : "scrollWidth";
        }
    
        hasScrollbarFunction(axis) {
            return axis === "y" ? hasVerticalScrollbar : hasHorizontalScrollbar;
        }
    
        scrollToStart() {
            const axis = this.axis();
            this.scrollTo(
                this.scrollProperty(axis),
                !this.props.displayInverse ? 0 : this.scrollDimProperty(axis)
            );
        }
    
        scrollToEnd() {
            const axis = this.axis();
            this.scrollTo(
                this.scrollProperty(axis),
                !this.props.displayInverse ? this.scrollDimProperty(axis) : 0
            );
        }
    
        scrollTo(scrollProperty, scrollPositionOrPropertyOfScrollable) {
            const scrollableContentNode = this.node.current;
            if (scrollableContentNode) {
                scrollableContentNode[scrollProperty] = isInt(
                    scrollPositionOrPropertyOfScrollable
                )
                    ? scrollPositionOrPropertyOfScrollable
                    : scrollableContentNode[scrollPositionOrPropertyOfScrollable];
            }
        }
    
        scrollToId(id) {
            if (this.itemsIdsRefsMap[id] && this.itemsIdsRefsMap[id].current) {
                this.itemsIdsRefsMap[id].current.scrollIntoView();
            }
        }
    
        scrollStopPromise() {
            return (
                (this.isScrolling && this.lastScrollStopPromise) ||
                Promise.resolve()
            );
        }
    
        onScrollStop(callback) {
            callback();
            this.isScrolling = false;
            this.lastScrollStopPromise = null;
            this.lastScrollStopPromiseResolve = null;
        }
    
        handleScroll(e) {
            const {
                isInfiniteLoading,
                hasMore,
                infiniteLoadBeginEdgeOffset,
                displayInverse
            } = this.props;
    
            this.isScrolling = true;
            this.lastScrollStopPromise =
                this.lastScrollStopPromise ||
                new Promise(resolve => {
                    this.lastScrollStopPromiseResolve = resolve;
                });
            this.onScrollStop(() => {
                this.lastScrollStopPromiseResolve &&
                    this.lastScrollStopPromiseResolve();
            });
    
            this.props.onScroll && this.props.onScroll(e);
    
            if (
                this.props.onInfiniteLoad &&
                (!isUndefined(hasMore) ? hasMore : !isInfiniteLoading) &&
                this.node.current &&
                !this.isLoading
            ) {
                const axis = this.axis();
                const scrollableContentNode = this.node.current;
                const scrollProperty = this.scrollProperty(axis);
                const offsetProperty = this.offsetProperty(axis);
                const scrollDimProperty = this.scrollDimProperty(axis);
                const currentScroll = scrollableContentNode[scrollProperty];
                const currentDim = scrollableContentNode[offsetProperty];
                const scrollDim = scrollableContentNode[scrollDimProperty];
    
                const finalInfiniteLoadBeginEdgeOffset = !isUndefined(
                    infiniteLoadBeginEdgeOffset
                )
                    ? infiniteLoadBeginEdgeOffset
                    : currentDim / 2;
    
                let thresoldWasReached = false;
                if (!displayInverse) {
                    const clientDimProperty = this.clientDimProperty(axis);
                    const clientDim = scrollableContentNode[clientDimProperty];
                    thresoldWasReached =
                        currentScroll +
                            clientDim +
                            finalInfiniteLoadBeginEdgeOffset >=
                        scrollDim;
                } else {
                    thresoldWasReached =
                        currentScroll <= finalInfiniteLoadBeginEdgeOffset;
                }
                if (thresoldWasReached) {
                    this.isLoading = true;
                    this.props.onInfiniteLoad();
                }
            }
        }
    
        render() {
            const {
                children,
                displayInverse,
                isInfiniteLoading,
                className,
                hasMore
            } = this.props;
    
            return (
                <div
                    className={classNames("simple-infinite-scroll", className)}
                    ref={this.node}
                    onScroll={this.handleScroll}
                    onMouseOver={this.props.onInfiniteScrollMouseOver}
                    onMouseOut={this.props.onInfiniteScrollMouseOut}
                    onMouseEnter={this.props.onInfiniteScrollMouseEnter}
                    onMouseLeave={this.props.onInfiniteScrollMouseLeave}
                >
                    {(hasMore || isInfiniteLoading) &&
                        displayInverse &&
                        this.loadingComponentRenderer()}
                    {children}
                    {(hasMore || isInfiniteLoading) &&
                        !displayInverse &&
                        this.loadingComponentRenderer()}
                </div>
            );
        }
    }
    
    

    And in this.props.children I pass it an array of React elements of the following component's class which extends React.PureComponent:

    ...
    
    export default class ChatMessage extends React.PureComponent {
        ...
    }
    
    

    This way, when re-rendering, only the components that have changed since the last render are re-rendered.

    I have also used an immutable data structure to store the collection of the chat messages, in particularly immutable-linked-ordered-map (https://github.com/tonix-tuft/immutable-linked-ordered-map) which allows me to achieve O(1) time complexity for insertions, removals and updates of a message as well as almost O(1) time complexity for lookups. Essentially, ImmutableLinkedOrderedMap is an ordered immutable map, like associative arrays in PHP, but immutable:

    
    const map = new ImmutableLinkedOrderedMap({
        mode: ImmutableLinkedOrderedMap.MODE.MULTIWAY,
        initialItems: [
            {
                id: 1, // <--- "[keyPropName] === 'id'"
                text: "Message text",
                // ...
            },
            {
                id: 2,
                text: "Another message text",
                // ...
            },
            // ...
        ]
    })
    map.get(2) // Will return: { id: 2, text: "Another message text", /* ... */ }
    const newMessage = { id: 3, text: "Yet another message text", /* ... */ };
    const newMap = map.set(newMessage);
    
    console.log(map !== newMap); // true
    console.log(map.length); // 2
    console.log(newMap.length); // 3
    
    let messages = newMap.replace(3, newMessage)
    console.log(messages === newMap); // true, because newMessage with ID 3 didn't change
    messages = newMap.replace(3, { ...newMessage, read: true })
    console.log(messages === newMap); // false
    
    
    

    Then, when I render the messages stored in the map, I simply call its .values() method which returns an array and I map that array to render the messages, e.g.:

    
    <SimpleInfiniteScroll>
        {messages.values().map((message) => <ChatMessage ... />)}
    </SimpleInfiniteScroll>