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>
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