Is there a way in firebase to have pagination with a listener to document changes

798 views
Skip to first unread message

Nick

unread,
Feb 18, 2022, 7:50:33 PM2/18/22
to Firebase Google Group
Hi!

I was trying to implement Firebase Firestore data pagination with startAfter, endAt and limits in our frontend in React, but I found out that the data gets updated only when I refresh the page, so naturally it seems like there's no listener to these document changes.

Now my question: is there a way, to implement a firestore data pagination in firebase with listeners to document changes, so that if, let's say, documents 3,4 and 5 get changed then the whole paginated data is changed accordingly (e.g in a ladder the positions are moved)?

Best Regards

Tracy Hall

unread,
Feb 19, 2022, 7:19:51 PM2/19/22
to Firebase Google Group
You can do "Paginated Listeners" (I wrote an entire wrapper library that includes them; I could send you the block of code I use) - I'd have to go back and check, but I *believe* the listener is on the query, not the documents.  Code as written uses "limit" rather than "endsAt", but should be a relatively simple change

```
export class PaginatedListener {
/**
* Creates an object to allow for paginating a listener for table
* read from Firestore. REQUIRES a sorting choice; masks some
* subscribe/unsubscribe action for paging forward/backward
* @param {!string} table - a properly formatted string representing the requested collection
* - always an ODD number of elements
* @param {filterObject} [filterArray] - an (optional) 3xn array of filter(i.e. "where") conditions
* @param {!sortObject} [sortArray] - a 2xn array of sort (i.e. "orderBy") conditions
* @param {?refPath} refPath - (optional) allows "table" parameter to reference a sub-collection
* of an existing document reference (I use a LOT of structured collections)
*
* The array is assumed to be sorted in the correct order -
* i.e. filterArray[0] is added first; filterArray[length-1] last
* returns data as an array of objects (not dissimilar to Redux State objects)
* with both the documentID and documentReference added as fields.
* @param {?number} limit - (optional)
* @param {!callback} dataCallback
* @param {!callback} errCallback
* @category Paginator
*/
constructor(
table,
filterArray = null,
sortArray,
refPath = null,
limit = PAGINATE_DEFAULT,
dataCallback = null,
errCallback = null
) {
/**
* table path at base of listener query, relative to original refPath
* @private
* @type {string}
*/
this.table = table;
/**
* array of filter objects for listener query
* @private
* @type {filterObject}
*/
this.filterArray = filterArray;
/**
* array of sort objects for listener query
* @private
* @type {sortObject}
*/
this.sortArray = sortArray;
/**
* refPath as basis for listener query
* @private
* @type {string}
*/
this.refPath = refPath;
/**
* current limit basis for listener query
* @type {number}
*/
this.limit = limit;
this._setQuery();
/**
* current dataCallback of listener query
* @private
* @type {RecordListener}
*/
this.dataCallback = dataCallback;
/**
* current errCallback of listener query
* @private
* @type {callback}
*/
this.errCallback = errCallback;
/**
* current status of listener
* @type {number}
*/
this.status = PAGINATE_INIT;
}

/**
* reconstructs the basis query
* @private
* @method _setQuery
* @returns {Query}
*/
_setQuery() {
const db = this.refPath ? this.refPath : fdb;
/**
* Query that forms basis for listener query
* @private
* @type {Query}
*/
this.Query = sortQuery(
filterQuery(db.collection(this.table), this.filterArray),
this.sortArray
);
/**
* last QuerySnapshot returned for listener query
* @private
* @type {QuerySnapshot}
*/
this.Snapshot = null;
return this.Query;
}

/**
* resets the listener query to the next page of results.
* Unsubscribes from the current listener, constructs a new query, and sets it
* as the new listener
* @async
* @method
* @returns {unsubscribe} returns the unsubscriber function (for lifecycle events)
*/
PageForward() {
const runQuery =
this.unsubscriber && !this.snapshot.empty
? this.Query.startAfter(last(this.snapshot.docs))
: this.Query;

//IF unsubscribe function is set, run it.
this.unsubscriber && this.unsubscriber();

this.status = PAGINATE_PENDING;

this.unsubscriber = runQuery.limit(Number(this.limit)).onSnapshot(
(QuerySnapshot) => {
this.status = PAGINATE_UPDATED;
//*IF* documents (i.e. haven't gone back ebfore start)
if (!QuerySnapshot.empty) {
//then update document set, and execute callback
this.snapshot = QuerySnapshot;
}
this.dataCallback(RecordsFromSnapshot(this.snapshot));
},
(err) => {
this.errCallback(err);
}
);
return this.unsubscriber;
}

/**
* resets the listener query to the next page of results.
* Unsubscribes from the current listener, constructs a new query, and sets it\
* as the new listener
* @async
* @method
* @returns {unsubscribe} returns the unsubscriber function (for lifecycle events)
*/
PageBack() {
const runQuery =
this.unsubscriber && !this.snapshot.empty
? this.Query.endBefore(this.snapshot.docs[0])
: this.Query;

//IF unsubscribe function is set, run it.
this.unsubscriber && this.unsubscriber();

this.status = PAGINATE_PENDING;

this.unsubscriber = runQuery.limitToLast(Number(this.limit)).onSnapshot(
(QuerySnapshot) => {
//acknowledge complete
this.status = PAGINATE_UPDATED;
//*IF* documents (i.e. haven't gone back ebfore start)
if (!QuerySnapshot.empty) {
//then update document set, and execute callback
this.snapshot = QuerySnapshot;
}
this.dataCallback(RecordsFromSnapshot(this.snapshot));
},
(err) => {
this.errCallback(err);
}
);
return this.unsubscriber;
}

/**
* sets page size limit to new value, and restarts the paged listener
* @async
* @method
* @param {number} newLimit
* @returns {unsubscribe} returns the unsubscriber function (for lifecycle events)
*/
ChangeLimit(newLimit) {
const runQuery = this.Query;

//IF unsubscribe function is set, run it.
this.unsubscriber && this.unsubscriber();

this.limit = newLimit;

this.status = PAGINATE_PENDING;

this.unsubscriber = runQuery.limit(Number(this.limit)).onSnapshot(
(QuerySnapshot) => {
this.status = PAGINATE_UPDATED;
//*IF* documents (i.e. haven't gone back ebfore start)
if (!QuerySnapshot.empty) {
//then update document set, and execute callback
this.snapshot = QuerySnapshot;
}
this.dataCallback(RecordsFromSnapshot(this.snapshot));
},
(err) => {
this.errCallback(err);
}
);
return this.unsubscriber;
}

/**
* changes the filter on the subscription
* This has to unsubscribe the current listener,
* create a new query, then apply it as the listener
* @async
* @method
* @param {filterObject} [filterArray] an array of filter descriptors
* @returns {unsubscribe} returns the unsubscriber function (for lifecycle events)
*/
ChangeFilter(filterArray) {
//IF unsubscribe function is set, run it (and clear it)
this.unsubscriber && this.unsubscriber();

this.filterArray = filterArray; // save the new filter array
const runQuery = this._setQuery(); // re-build the query
this.status = PAGINATE_PENDING;

//fetch the first page of the new filtered query
this.unsubscriber = runQuery.limit(Number(this.limit)).onSnapshot(
(QuerySnapshot) => {
this.status = PAGINATE_UPDATED;
//*IF* documents (i.e. haven't gone back ebfore start)
this.snapshot = QuerySnapshot;
this.dataCallback(RecordsFromSnapshot(this.snapshot));
},
(err) => {
this.errCallback(err);
}
);
return this.unsubscriber;
}

/**
* IF unsubscribe function is set, run it.
* @async
* @method
*/
unsubscribe() {
//IF unsubscribe function is set, run it.
this.unsubscriber && this.unsubscriber();
this.unsubscriber = null;
}
}
/**
* ----------------------------------------------------------------------
* returns an array of internal record structures from a
* firestore Query snapshot
* @private
* @function
* @static
* @param {QuerySnapshot} QuerySnapshot
* @returns {RecordArray}
*/
export const RecordsFromSnapshot = (QuerySnapshot) => {
return QuerySnapshot.docs.map((docSnap) => {
return RecordFromSnapshot(docSnap);
});
};

/**
* ----------------------------------------------------------------------
* returns an internal record structure from a firestore
* Document snapshot
* @private
* @function
* @static
* @param {DocumentSnapshot} DocumentSnapshot
* @returns {Record}
*/
export const RecordFromSnapshot = (DocumentSnapshot) => {
return {
...DocumentSnapshot.data(),
Id: DocumentSnapshot.id,
refPath: DocumentSnapshot.ref.path
};
};
```

Nick

unread,
Feb 20, 2022, 2:46:57 PM2/20/22
to Firebase Google Group
Hi!

Thank you! I will definitely try this :)

воскресенье, 20 февраля 2022 г. в 02:19:51 UTC+2, leadd...@gmail.com:

Tracy Hall

unread,
Feb 20, 2022, 9:04:10 PM2/20/22
to Firebase Google Group
I guess I should show it in-use:
```
const [PaginateObject, setPaginateObject] = useState();
const [paginateList, setPaginateList] = useState([]);
const [pageSize, setPageSize] = useState(PAGINATE_DEFAULT);
const [choiceList, setChoiceList] = useState([]);
const [showPopup, setShowPopup] = useState(false);

useEffect(() => {
setPageSize(PAGINATE_DEFAULT);
}, []);

/**
* We want a listener to be responsive to outside changes.
*
*/

/**
* set up a generic paginated listener
*/
useEffect(() => {
const paginator = new PaginatedListener(
table,
null, //filter
[{ fieldRef: "name", dirStr: "asc" }], //sort, required
parent?.refPath, //refPath
pageSize,
(listPage) => {
setPaginateList(listPage);
},
(err) => {
console.log(err, "Paginate Error");
},
PAGINATE_DEFAULT //initial pageSize
);

setPaginateObject(paginator);

return paginator.unsubscribe();
}, [itemType]);

useEffect(() => {
PaginateObject && PaginateObject.PageForward();
}, [PaginateObject]);

useEffect(() => {
let finalPickList = paginateList
? paginateList
.map((item) => {
return {
...item,
name: summarizeItem(item, itemType)
};
})
.sort((item, anotherItem) => {
return compare(item, anotherItem, "name:string");
})
: [];
setChoiceList(finalPickList);
}, [paginateList, itemType]);

const handleOpenPopup = () => {
setShowPopup(true);
};

const handleClosePopup = () => {
setShowPopup(false);
};

const makeSelection = (e) => {
e.stopPropagation();
let itemID = $(e.target).find("option:selected").val();
$(e.target).prop("selectedIndex", -1);
switch (itemID ? itemID : "nobody") {
case "nobody":
case itemType:
break;
case "new":
handleOpenPopup();
break;
case "back":
PaginateObject.PageBack();
break;
case "next":
PaginateObject.PageForward();
break;
default:
let item = choiceList.find((item) => {
return item.Id === itemID;
});

pickItem(item, itemType, itemFor);
const overlay = $(e.target).parents(".popup-overlay-modal")[0];
overlay && overlay.click();
break;
}
};
```
Not complete, but gives the idea

Reply all
Reply to author
Forward
0 new messages