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

1

13

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.

Maracaibo answered 10/7, 2019 at 10:12 Comment(2)
Did you ever settle on a good module? Thanks.Glasgow
No, I didn't. I ended up creating a deadly simple infinite scroll component my own and used React.PureComponent for the chat messages/events to avoid re-rendering unnecessarily. Please, check my answer! I hope it can help you too. Anyway, It will be very interesting to know how Facebook did it. I guess they don't compute the height of each message and set the container height accordingly, if you inspect their DOM you will see that their chat keeps all the rendered messages/events in the DOM without removing them, so their chat messages list is not virtualized, I guess.Maracaibo
M
5

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>

Maracaibo answered 1/4, 2020 at 6:5 Comment(7)
Would it be possible for you to share the js-utls directory and other helper functions, I've been trying to implement the same.Effulgence
@Effulgence Are you trying to use this infinite scroll component? I can publish this component for you my friend, if you wantMaracaibo
yes that would be really helpfulEffulgence
OK, I will let you know as soon as I do it here.Maracaibo
Hi @Prhyme. I apologize for the delay, but it's better late than never. You can install this infinite scroll component with npm install --save react-really-simple-infinite-scroll now. I have published it to NPM and you can use it in your projects with this import statement import { ReallySimpleInfiniteScroll } from "react-really-simple-infinite-scroll"; Check out the README here: github.com/tonix-tuft/react-really-simple-infinite-scroll/blob/… I wish you a Happy New Year!Maracaibo
Hi @Maracaibo thanks a tonne for this!!!. I can't express how much helpful will it be and I really really appreciate it, and a happy new year to you as well (:Effulgence
@Effulgence Thank you for your kind words! It was a pleasure! :DMaracaibo

© 2022 - 2024 — McMap. All rights reserved.