This pagination approach isn’t novel or uncommon, yet it’s seldom thoroughly discussed. What I’m presenting differs from the methods typically available via a basic Google search.
What is Pagination?
Put simply, it’s page-by-page navigation. It’s a way to display a large amount of homogeneous information by dividing the content into pages. Many Salesforce developers, myself included, often encounter pagination when displaying a significant volume of data on the user interface. On one of my projects, we were presenting phone numbers in a data table. However, in certain cases, the data wouldn’t display, as the information retrieval took too long. Users were unable to access any data. So, why did this happen?
- Contacts were selected through several nested database queries.
We were dealing with multiple levels of parent-child relationships between Contacts and child objects. Due to business logic requirements, we needed to filter contacts based on filters applied both to the contacts themselves and to filters applied to the child objects of the contacts.
Example of Parent-Child Relationship
2. A vast number of records in Contacts (several hundred thousand) and their child objects.
To elucidate the necessity of the pagination approach I’ve chosen, I’ll list and compare four other methods proposed by the Salesforce platform:
- Pagination using the List Controller for Visualforce pages.
- Pagination using the Database.getQueryLocator class.
- Pagination employing SOQL query and the OFFSET operator.
- Pagination via Apex code to retrieve all parent records into a single list using an SOQL query. Subsequently, the necessary records can be selected from this list according to the page.
The first three tools didn’t suit me for the following reasons:
- The List Controller for Visualforce pages is not applicable for LWC components and has a limited number of records it can process – 10,000 records.
- getQueryLocator also has a limit of 10,000 records and isn’t compatible with the task’s requirements.
- The SOQL query with the OFFSET operator is limited to 2,000 records that can be provided with an OFFSET in the query. Therefore, it can’t be used for pagination in the case of a large amount of data.
Let’s take a closer look at the 4th pagination method. And let’s highlight an important detail upfront – we don’t have the ability to fetch all data, as there’s a limit of 50,000 records that we can retrieve across all queries within a single transaction.
What does this lead to? If we query child records, based on which we subsequently query contacts, we might obtain, for example, 47,000 total child records. But then we can only retrieve 3,000 contacts. Even if there are actually more, let’s say 6,000. Essentially, we’re providing the user with knowingly inaccurate data. They won’t know how many records they can actually get in the data table. They won’t see part of the Contacts and won’t interact with them, assuming they don’t have all the data. I call this the “User’s Data Iceberg.
User Data Iceberg
In this way, the user receives distorted and incomplete information, which will negatively impact their data work.
The second point is that aggregating data into lists from nested database queries takes a lot of time. This leads to exceeding the CPU Time limit. As a result, the user doesn’t receive any data at all. We can’t reduce the processing time for nested queries or overcome the limits on records in a single transaction. We are constrained by the limitations imposed by the Salesforce database (I will explain how these limits can be circumvented later). Therefore, I decided to reduce the number of records in the query for the contacts themselves to at least shorten the time for data retrieval and processing. So, if I previously wrote a query for contacts:
SELECT Id FROM Contact WHERE Id IN :ids ORDER BY Next_Contact_Date_Time__c LIMIT 50000
where ids is a list including the Ids of child records from the nested query, now I need to write:
SELECT Id FROM Contact WHERE Id IN :ids ORDER BY Next_Contact_Date_Time__c LIMIT 50
limiting the number of contacts per page to 50.
This allowed me to reduce the overall time the code works on retrieving the necessary records for the user. It also allows me to fetch either all Contact records or a significantly larger portion than in the first query.However, this is only the first 50 contacts, and I need all contacts for the data table…
What’s Next?
On some resources, they suggest ordering records by a specific field, like ID. Then pagination is done by comparing the field’s value with the value of the last or first record on the previous page. It all depends on the direction of pagination.
The ideas I came across were either not fully developed or not sufficiently elaborated for a more general application. Moreover, such a method (as far as I can judge from personal experience) is quite rarely used. The majority of recommendations online concern the application of one of the 4 pagination tools mentioned earlier. In my opinion, this is unjustified. Therefore, I took this idea into consideration and practically developed it for many cases of code implementation.
How Does it Work?
- For “paging” from the previous to the next page, it is necessary to order the records in each database query by one of the fields (e.g., ID) in ascending order. In the query condition, it is specified that the value of this field for N records on the next page should be greater than the value of the field for the last record on the previous page.
- For “paging” back, it is necessary to order the records in each database query by one of the fields in descending order. Now, in the query condition, the value of the field for N records on the next page should be smaller than the value of the field for the first record on the previous page.
The first and last pages somewhat deviate from this concept. The first page lacks a previous page because there isn’t one, and the last page lacks a next page. Also, the last page almost always contains fewer records than all the previous ones.
How Does this Concept Look in Code?
I’ll provide an example below. Please note that the methods below have a fixed page size of 50 records. If you need a different size, you can replace 50 with the value you need. Alternatively, you can introduce an additional parameter into the methods where you pass the required page size.
I used two methods related to an LWC component that contains a data table. The filters intended for querying contacts from the database were stored in records of a separate object. However, you can use a JSON object generated by you in the LWC component’s code instead.The first method ‘getFirstPage’ of the LWC component is designed to retrieve records for the first page during the initial load of the table:
/** LWC component method that initially retrieves the data for the first page */ getFirstPage(event){ /** Imported method to component @salesforce/apex/ApexClassName.getContacts * Parameters: * recId: filterRecordId - ID of the record that contains filters for retrieving contacts * pageRecords: null - array of the records IDs from the page to pass them into the Apex methods as the previous page IDs, it has the NULL value when the first page is loaded first time as we have no previous page yet * comparingSign: null - the symbol for the copmarison of the records by the field which is used for ordering the records(contacts), it has the NULL value when the first page is loaded the first time * order: ordering symbol which can be 'ASC' or 'DESC' depending on your wishes */ /* The variable that defines in ASC or DESC order records should be sorted. You can use some drop-down menu on the UI for selecting it. */ let currentSorting = event.detail.sortingOrder; getContacts({recId: filterRecordId, pageRecords: null, comparingSign: null, order: currentSorting, currentPage: 1, sortingOrder: currentSorting}) .then((result) => { if(result.contacts.length > 0){ //the first page contacts this.contacts = [...this.result.contacts]; //the total pages count this.totalPages = this.result.totalPages; /** The returned number of the first page. * You can modify and assign the first page directly if you want.*/ this.currentPage = this.result.currentPage; /* Your code that processes the data */ } else{ this.contacts = []; this.totalPages = 0; this.currentPage = 0; } } .catch((error) => { console.log(error); console.log('error due to request' + error); }); }
The provided method of the component calls the Apex class method getContacts, which returns an object with contacts for the initial page.
The second method, handlePageChange, of the LWC component is intended for event handling. Specifically, it handles user clicks on control buttons to navigate to the next, previous, last, and first pages after the initial first page has been retrieved.
/** LWC method that retrieves the data when on of the pagination buttons (next page/previous page/last page/fist page) is clicked */ handlePageChange = (message) =>{ /* The variable that defines in ASC or DESC order records should be sorted. You can use some drop-down menu on the UI for selecting it. */ let currentSorting = message.sortingOrder; /* Selecting the comparison sign and ordering direction depending on the pagination direction */ let compSign; let sortOrder; if(currentSorting = 'ASC'){ compSign = this.currentPage > message.currentPage ? '<' : '>'; sortOrder = this.currentPage > message.currentPage ? 'DESC' : 'ASC'; } else{ compSign = this.currentPage > message.currentPage ? '>' : '<'; sortOrder = this.currentPage > message.currentPage ? 'ASC' : 'DESC'; } /* Combining the record IDs from the page to pass them into the Apex methods as the previous page IDs */ let contIds = []; this.contacts.forEach(cont => { contIds.push(cont.contactId); }); let conditions = {}; conditions.recId = this.dialListId; /** Selecting the comparison sign and ordering direction depending on the pagination direction and checking if the next page is the first/last page */ if(message.currentPage == 1 || message.currentPage == this.totalPages){ conditions.pageRecords = null; conditions.comparingSign = null; if(currentSorting = 'ASC'){ conditions.order = message.currentPage == 1 ? 'ASC' : 'DESC'; } else{ conditions.order = message.currentPage == 1 ? 'DESC' : 'ASC'; } } else{ conditions.pageRecords = contIds; if(currentSorting = 'ASC'){ conditions.comparingSign = this.currentPage > message.currentPage ? '<' : '>'; conditions.order = this.currentPage > message.currentPage ? 'DESC' : 'ASC'; } else{ conditions.comparingSign = this.currentPage > message.currentPage ? '>' : '<'; conditions.order = this.currentPage > message.currentPage ? 'ASC' : 'DESC'; } } this.currentPage = message.currentPage; /* Imported method to component @salesforce/apex/ApexClassName.getContacts */ getContacts({recId: conditions.recId, pageRecords: conditions.pageRecords, comparingSign: conditions.comparingSign, order: conditions.order, currentPage: message.currentPage, sortingOrder: currentSorting}) .then((result) => { this.contacts = this.formatContacts(result.contacts); this.totalPages = result.totalPages; /* < Your code that processes the data > */ }) .catch((error) => { console.log(error); console.log('error due to request' + error); }); }
The handlePageChange method of the component also calls the Apex class method getContacts, which returns an object with contacts for the initial first page.
It’s worth noting that due to the existing code and to maintain similarity between different interfaces, I’ve used two methods. However, you can slightly modify the second handlePageChange method and use only that method to load the initial page.
The Apex class mentioned in the code of the LWC component as ApexClassName has the following methods:
- getContacts:
/** LWC method that retrieves the data when on of the pagination buttons (next page/previous page/last page/fist page) is clicked */ handlePageChange = (message) =>{ /* The variable that defines in ASC or DESC order records should be sorted. You can use some drop-down menu on the UI for selecting it. */ let currentSorting = message.sortingOrder; /* Selecting the comparison sign and ordering direction depending on the pagination direction */ let compSign; let sortOrder; if(currentSorting = 'ASC'){ compSign = this.currentPage > message.currentPage ? '<' : '>'; sortOrder = this.currentPage > message.currentPage ? 'DESC' : 'ASC'; } else{ compSign = this.currentPage > message.currentPage ? '>' : '<'; sortOrder = this.currentPage > message.currentPage ? 'ASC' : 'DESC'; } /* Combining the record IDs from the page to pass them into the Apex methods as the previous page IDs */ let contIds = []; this.contacts.forEach(cont => { contIds.push(cont.contactId); }); let conditions = {}; conditions.recId = this.dialListId; /** Selecting the comparison sign and ordering direction depending on the pagination direction and checking if the next page is the first/last page */ if(message.currentPage == 1 || message.currentPage == this.totalPages){ conditions.pageRecords = null; conditions.comparingSign = null; if(currentSorting = 'ASC'){ conditions.order = message.currentPage == 1 ? 'ASC' : 'DESC'; } else{ conditions.order = message.currentPage == 1 ? 'DESC' : 'ASC'; } } else{ conditions.pageRecords = contIds; if(currentSorting = 'ASC'){ conditions.comparingSign = this.currentPage > message.currentPage ? '<' : '>'; conditions.order = this.currentPage > message.currentPage ? 'DESC' : 'ASC'; } else{ conditions.comparingSign = this.currentPage > message.currentPage ? '>' : '<'; conditions.order = this.currentPage > message.currentPage ? 'ASC' : 'DESC'; } } this.currentPage = message.currentPage; /* Imported method to component @salesforce/apex/ApexClassName.getContacts */ getContacts({recId: conditions.recId, pageRecords: conditions.pageRecords, comparingSign: conditions.comparingSign, order: conditions.order, currentPage: message.currentPage, sortingOrder: currentSorting}) .then((result) => { this.contacts = this.formatContacts(result.contacts); this.totalPages = result.totalPages; /* < Your code that processes the data > */ }) .catch((error) => { console.log(error); console.log('error due to request' + error); }); }
This method simply passes parameters to the getData method and returns the processed result to the LWC component as an object of the PayLoad class.
- getData:
/** The method that retrieves the IDs of the child records and prepares the part of the input parameters for the method getRecords that retrieves contacts */ public static List<Contact> getData(String recId, List<String> pageRecords, String comparingSign, String order, Integer pageNumber, String sortingOrder){ List <Id> ids = new List <Id>(); /* Some custom code that retrieves the IDs of the child records using the filtering logic saved in the record with the id='recId' and adds them to the 'ids' variable*/ /** Instead of the 'recId' and saved logic in the record of some SObject you can pass filtering logic inside JSON object for example. It depends on how you want to build your application */ String idAsString = idSetAsSting(ids); //This is the conversion of the list of IDs into a string /* the countQuery string is to define the total scope of the contacts that are corresponding to our condition. Here you can use your own condition for the definition of the records total count */ String countQuery = 'SELECT count() FROM Contact WHERE Id IN ' + idAsString; totalRecordsCount = database.countQuery(countQuery); /** The queryLimit is the required parameter for specifying the number of records per page. This is required because the last page may have a different quantity of records than the other pages have */ Integer queryLimit = findCurrentLimit(totalRecordsCount, pageNumber); String query = 'SELECT Id, ' + CONTACT_FIELDS + ' FROM Contact' + ' WHERE Id IN ' + idAsString; /** The previous page Contacts are required to compare the last or the first record ID depending on pagination direction */ String queryPreviousPage = 'SELECT ID,Next_Contact_Date_Time__c FROM Contact WHERE ID IN :pageRecords ORDER BY Id ' + sortingOrder; List<Contact> previousContacts = database.query(queryPreviousPage); /** The next string is the contacts retrieved for the page */ List<Contact> contacts = getRecords(previousContacts, comparingSign, order, queryLimit, query, 'Id', sortingOrder); return contacts; }
The getData method is used for:
- Executing all nested queries in the database (if necessary, along with associated logic).Generating a list of contact IDs that satisfy the search results within the executed nested queries (this list is denoted by the variable ‘ids’).Formulating parameters for retrieving records of the current page, namely: queryLimit — the number of records displayed per page; previousContacts — a list of records (in my case, a list of contacts).
Obtaining the number of records displayed per page is necessary to limit the records for the last page. This ensures that the sequence of records is not disrupted when paging from the previous to the next.Obtaining records of the previous page, previousContacts, through a SOQL query is not mandatory. This is convenient when you are working with relatively static data that doesn’t change too frequently. Additionally, this slightly reduces the amount of information transmitted to the server for further processing. In other cases, it’s better to directly pass the data list from the page. It’s important to consider the possibility of changing the position of records on the page or moving records to other pages when modifying data within the records. By the way, this consideration also applies to pagination using other methods.
- getRecords:
/** The method that retrieves contacts for the current page. * Its input parameters are: * pageRecords - the list of the records from the previous page * comparingSign - one the signs '>' or '<' * order - the order in which the records are ordered in the particular request * newLimit - the quantity of the records for the current page * query - the query with filters that will be modified to get the records for the current page * orderBy - the name of the object field according to which the records are ordered * sortingOrder - the order in which the records are ordered for the pages */ public static List<SObject> getRecords(List<SObject> pageRecords, String comparingSign, String order, Integer newLimit, String query, String orderBy, String sortingOrder){ String lastId; //the variable that stores the ID that will be used in the query for comparison String orderByString = orderBy; //the necessity of the orderByString variable will be explained further String firstQuery = query; //the necessity of the firstQuery variable will be explained further if(pageRecords != null && !pageRecords.isEmpty()){ if(order == sortingOrder){ //if records are ordered in ascending order the lastId equals to the ID of the last record from the previous page lastId = String.valueOf(pageRecords[pageRecords.size() - 1].get(orderByString)); } else{ //if records are ordered in descending order the lastId equals to the ID of the first record from the previous page lastId = String.valueOf(pageRecords[0].get(orderByString)); } lastId = '\'' + lastId + '\''; } //if the current page is not the first or the last then we need to add a comparison substring to the query if(lastId != null && comparingSign != null){ //but first we need to check that query contains keyword WHERE if(query.toLowerCase().substringAfterLast('from').contains('where')){ query = query + ' AND ' + orderByString + ' ' + comparingSign + ' ' + lastId; } else{ query = query + ' WHERE ' + orderByString + ' ' + comparingSign + ' ' + lastId; } } //adding the ordering by the field to the query query = query + ' ORDER BY ' + orderByString + ' ' + order + ' LIMIT ' + newLimit; //querying the records Map<Id, SObject> records = new Map<Id,SObject>((List<SObject>)Database.query(query)); List<SObject> recordsToReturn = new List<SObject>(); //if there are queried records then sorting them in ascending order if(records.size() > 0) recordsToReturn.addAll(sortByIdAndSortingOrder(records, orderByString, sortingOrder)); return recordsToReturn; //the returned records }
The getRecords method fetches records of one page and presents them in the order specified by the user. In the first and last pages, the variable ‘lastId’ is ignored. The query doesn’t perform a comparison but simply constructs the records in ascending (ASC) or descending (DESC) order. It always displays the first page, but with records ordered in ASC or DESC. The ‘sortByIdAndSortingOrder’ method needs to be invoked to ensure the delivery of records in the desired order.
- sortByIdAndSortingOrder:
/** The method that guaranteed returns the records in the ascending order ordering them by orderBy field */ public static List<SObject> sortByIdAndSortingOrder(Map<Id, SObject> pageRecords, String orderBy, String sortingOrder){ Set<ID> idSet = new Set<ID>(); for (ID recId : pageRecords.keySet()) { idSet.add(recId); } String sObjName = pageRecords.values()[0].Id.getSObjectType().getDescribe().getName(); String rightOrderQuery = 'SELECT Id FROM ' + sObjName + ' WHERE Id in :idSet ORDER BY ' + orderBy + ' ' + sortingOrder; List<SObject> records = Database.query(rightOrderQuery); List<SObject> recordsToReturn = new List<SObject>(); for (SObject obj : records) { recordsToReturn.add(pageRecords.get(obj.Id)); } return recordsToReturn; }
The ‘sortByIdAndSortingOrder’ method is purely utilitarian. Its purpose is to ensure the delivery of records gathered in the required order.
- idSetAsSting
/** The method transforms the list of IDs into a string with quotes and brackets */ public static String idSetAsSting(List<String> ids){ String stringSet = '('; if(!ids.isEmpty()){ for(String id : ids) { stringSet = stringSet + '\'' + id + '\'' + ','; } } else{ stringSet = stringSet + '\'' + '\''; } stringSet = stringSet.removeEnd(',') + ')'; return stringSet; }
This method is also utilitarian. It is necessary to convert the list of IDs into a string for querying the database.
- findCurrentLimit:
/** The method defines the limit of the records for the current page request */ public static Integer findCurrentLimit(Integer totalRecords, Integer pageNum) Double pagesCount = Double.valueOf(totalRecords); Double totalPages = Decimal.valueOf(pagesCount/50).round(System.RoundingMode.UP); Integer queryLimit = pageNum == Integer.valueOf(totalPages) ? totalRecords - ((Integer.valueOf(totalPages) - 1) * 50): 50; return queryLimit; }