The reason of triggering onEndReached multiple times is because you have not set initialNumToRender properly.
onEndReached is triggered in this _maybeCallOnEndReached in VirtualizedList.
_maybeCallOnEndReached() {
const {
data,
getItemCount,
onEndReached,
onEndReachedThreshold,
} = this.props;
const {contentLength, visibleLength, offset} = this._scrollMetrics;
const distanceFromEnd = contentLength - visibleLength - offset;
if (
onEndReached &&
this.state.last === getItemCount(data) - 1 &&
distanceFromEnd < onEndReachedThreshold * visibleLength &&
(this._hasDataChangedSinceEndReached ||
this._scrollMetrics.contentLength !== this._sentEndForContentLength)
) {
...
if contentLength(the length of content rendered at once) and visibleLength(usually the screen height) is close, distanceFromEnd can be very small, thus distanceFromEnd < onEndReachedThreshold * visibleLength can always be true. By setting initialNumToRender and control the size of contentLength, you can avoid unnecessary onEndReached call.
Here's an example. If you render 10 items(this is the default props of initialNumToRender) of 70 px cells at the initial render, contentLength becomes 700. If the device you are using is iPhoneX then visibleLength is 724. In that case distanceFromEnd is 24 and this will trigger onEndReached unless you set onEndReachedThreshold less than 0.03.