According to the latest statistics by statista , the average time spent on social media is 145 minutes, or 2 hours and 25 minutes every day. Infinite scrolling is key factor to make users stay longer on social apps which result in increase revenue and users retention.
What Is Infinite Scroll?
A design technique where, as the user scrolls down a page, more content automatically and continuously loads at the bottom, eliminating the user's need to click to the next page. The idea behind infinite scroll is that it allows people to enjoy a frictionless scrolling experience.
In this tutorial we will implement this design pattern with Firebase's Firestore database and Expo .
Despite relational databases like PostgreSQL, MySQL and others. Firestore is a document database and saves data in JSON-like format.
Firestore collection contains documents, the same SQL table contain records.
/**
* Firestore collection which documents data structure
*/
{
"xyrt023": {
"id": "xyrt02",
"fullName": "Leonard M. Adleman",
"knownFor": "Computational Complexity Theory, Cryptography",
"bio": "Adleman was born in San Francisco...",
"avatar": "https://res.cloudinary.com/highereducation/image/upload/h_300,w_180,c_scale,f_auto,q_auto:eco,/v1/TheBestSchools.org/leonard-adleman"
},
"y7rt0bb": {
"id": "y7rt0bb",
"fullName": " Frances E. Allen",
"knownFor": "Compilers, Program optimization, Parallel computing",
"bio": "Allen was born in the town of Peru....",
"avatar": "https://res.cloudinary.com/highereducation/image/upload/h_300,w_180,c_scale,f_auto,q_auto:eco,/v1/TheBestSchools.org/frances-allen"
},
"qoft080": {
"id": "qoft080",
"fullName": "โTimothy J. Berners-Lee",
"knownFor": "Network design, World Wide Web, HTTP",
"bio": "Berners-Lee was born in London in ....",
"avatar": "https://res.cloudinary.com/highereducation/image/upload/h_300,w_180,c_scale,f_auto,q_auto:eco,/v1/TheBestSchools.org/timothy-berners-lee-1"
}
}
With that knowledge, It's time to build a simple mobile app listing the most influential computer scientists.
Batching Stream of Content
Continuously stream content require fetching data as multiple batches with limited size. Ideally, each content batch has at least 10 items
When the app is initialized, we will fetch the initial batch includes 10 documents, and save the last document ID from the initial batch to use it as the starting point for the next batch and recursively for all next batches.
To make our life easier, Let write a function with the following responsibilities:
When the last document ID is not provided, it starts from the first document in the collection, otherwise starts after the last document from the previous batch.
For each batch, the function will return an object contains :
docs : array of documents in current batch.
lastDocId : last document ID from previous batch to be used as starting point for next batch.
status : asynchronous loading status which should be UNDETERMINED
, PENDING
,SUCCEEDED
or FAILED
.
error : returned by Firestore when something went wrong.
import firebase from "firebase";
const collection = firebase.firestore().collection("[COLLECTION_NAME_HERE]");
/**
* Utilities function to extract documents in snapshots
*/
const extractSnapshots = (snapshots) => {
let extracts = [];
snapshots.forEach((documentSnapshot) => {
extracts.push(documentSnapshot.data());
});
return extracts;
};
/**
* Retrieve documents in batches of specified limit.
* when last document ID provided, fetch documents after that
* document (pagination query fetching)
* @param {String} options.lastDocId - ID of last document in previous batch
* @param {Number} options.limit - limit of documents per batch
*
* @returns - promise which will resolve into object contains `docs`,`lastDoc`,`status`,`error`
*
*/
const getDocs = async ({ lastDocId, limit = 10 }) => {
let docs = []; // Array of docs in current bath
let newLastDocId = null; // Last document ID in this batch
let error = null;
let batch;
/***
* Fetching documents is asynchronous operation, It's good practice to
* to monitor each status of operation. Status should be UNDETERMINED, PENDING, SUCCEEDED
* or FAILED.
*/
let status = "undetermined";
try {
/***
* In case lastDocId provided, start after that document, otherwise
* start on first document.
*/
if (lastDocId) {
const lastDoc = await collection.doc(lastDocId).get();
/**
* Read more about Firestore paginated query here
* https://firebase.google.com/docs/firestore/query-data/query-cursors#paginate_a_query
*/
batch = collection
.orderBy("createdAt", "desc")
.startAfter(lastDoc)
.limit(limit);
} else {
/**
* The {lastDocId} not provided. Start on first document in collection
*/
batch = collection.orderBy("createdAt", "desc").limit(limit);
}
status = "pending";
const snapshots = await batch.get();
/**
* For current batch, keep lastDocId to be used in next batch
* as starting point.
*/
newLastDocId =
snapshots.docs[snapshots.docs.length - 1]?.data()?.id || null;
docs = extractSnapshots(snapshots);
status = "succeeded";
return {
status,
error,
docs,
lastDocId: newLastDocId,
};
} catch (error) {
status = "failed";
return {
status,
error: error,
docs,
lastDocId: newLastDocId,
};
}
};
Fetch Initial Batch
When app initialized or main component mounted, by using useEffect
hook, we fetch initial batch documents and save last document ID for this batch to be used as the start point for next batch.
/** Fetch initial batch docs and save last document ID */
const getInitialData = async () => {
setData({ initialBatchStatus: "pending", error: null });
const {
docs,
error,
lastDocId,
status: initialBatchStatus,
} = await getDocs({ limit: 10 });
if (error) {
return setData({ initialBatchStatus, error });
}
return setData({ initialBatchStatus, docs, lastDocId });
};
useEffect(() => {
// Load initial batch documents when main component mounted.
getInitialData();
}, []);
Fetch next batches
Before we proceed with fetching the next batch, let us examine how to render the content.
We use 2 components.
<ListItem>
: Re-usable component to render document information, in our context, it's information for each scientist.<List>
: By using React Native built-in FlatList. It renders the list of<ListItem/>
components.
Interesting things here are props provided by FlatList, which help us to determine how far user reach scrolling content then the app can fetch the next batch. Those props are onEndReachedThreshold and onEndReached.
onEndReachThreshold
set to 0.5
which translate to the half of scrollable height, it simply means that whole scrollable height equal 1
. You can set to any value you want in range between 0 to 1.
When user scroll until half of content, this indicate that, she has interest to view more content and FlatList fires onEndReached
event which trigger function to fetch next batch of documents then append new fetched documents to existing ones.
/*
* Fetch next batch of documents start from {lastDocId}
*/
const getNextData = async () => {
// Discard next API call when there's pending request
if (data.nextBatchStatus === "pending" || !data.lastDocId) return;
setData({ ...data, nextBatchStatus: "pending", error: null });
const {
docs,
error,
lastDocId,
status: nextBatchStatus,
} = await getDocs({ limit: 3, lastDocId: data.lastDocId });
if (error) {
return setData({ nextBatchStatus, error });
}
const newDocs = [...data.docs].concat(docs);
return setData({ ...data, nextBatchStatus, docs: newDocs, lastDocId });
};
Fetching documents is an asynchronous operation that should take a while depending on user device network speed or server availability, the app will show the Activity Indicator component when the request is pending by listening to nextBatchStatus
when equal to pending
.
Debouncing Server Calls
Debounce is fancy way to say that we want to trigger a function, but only once per use case.
Let's say that we want to show suggestions for a search query, but only after a visitor has finished typing it.
Or we want to save changes on a form, but only when the user is not actively working on those changes, as every "save" costs us a database read.
When user scroll and reach threshold we trigger new documents fetch, but when user is scrolling quickly we don't have to trigger more unnecessary requests.
By debouncing the getNextData
function, we can delay it for a certain period like 1000
ms and save database cost while optimizing the app for performance.
Here simple debounce function
function debounce(func, timeout = 300){
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { func.apply(this, args); }, timeout);
};
}
Top comments (2)
Dude, this took my code to another level! Thanks for sharing.
That's great. Glad it is helpful.