StandardSetController (which handles the math for "Next" and "Previous" pages) with a custom Wrapper Class, you can display these smaller lists while letting users check boxes next to rows to perform bulk actions.
Displaying hundreds or thousands of records on a single page causes slow load times, high memory consumption, and a terrible user experience. Furthermore, standard Salesforce sObjects (like Account) do not have a built-in "isSelected" checkbox field for the UI. To solve both problems simultaneously, we pair the ApexPages.StandardSetController with a custom Apex Wrapper Class.
📌 Key Benefits of This Architecture
- Automatic Page Offsets: Handles queries up to 10,000 records effortlessly without you having to write complex SOQL
OFFSETcalculations. - Selection State Memory: The wrapper class binds a Salesforce record to a temporary UI boolean flag, so you know exactly which rows the user selected.
- Optimized Performance: Renders only the active page subset in the DOM, keeping the browser fast and responsive.
⚙️ 1. Apex Custom Controller Implementation
The controller creates a StandardSetController instance using a Database Query Locator. It sets the page size, provides pagination navigation controls, and wraps each returned sObject inside an inner wrapper class for checkbox binding.
AccountPaginationController.cls)
public with sharing class AccountPaginationController {
public Integer noOfRecords { get; set; }
public Integer pageSize { get; set; }
public List<AccountWrapper> accountWrappers { get; set; }
// 1. Initialize the StandardSetController
public ApexPages.StandardSetController setCon {
get {
if (setCon == null) {
pageSize = 5; // Display 5 records per page
setCon = new ApexPages.StandardSetController(
Database.getQueryLocator([
SELECT Id, Name, AccountNumber, Industry, Phone
FROM Account
ORDER BY Name ASC
WITH USER_MODE
])
);
setCon.setPageSize(pageSize);
noOfRecords = setCon.getResultSize();
}
return setCon;
}
set;
}
// 2. Wrap the records for the CURRENT page
public List<AccountWrapper> getAccountList() {
accountWrappers = new List<AccountWrapper>();
for (Account acc : (List<Account>) setCon.getRecords()) {
accountWrappers.add(new AccountWrapper(acc));
}
return accountWrappers;
}
// 3. Process the selected records
public PageReference processSelected() {
List<Account> selectedAccounts = new List<Account>();
if (accountWrappers != null) {
for (AccountWrapper wrap : accountWrappers) {
if (wrap.isSelected) {
selectedAccounts.add(wrap.accRecord);
}
}
}
ApexPages.addMessage(new ApexPages.Message(
ApexPages.Severity.CONFIRM,
'Selected records count on this page: ' + selectedAccounts.size()
));
return null;
}
// 4. Pagination Navigation Methods
public Boolean hasNext { get { return setCon.getHasNext(); } set; }
public Boolean hasPrevious { get { return setCon.getHasPrevious(); } set; }
public Integer pageNumber { get { return setCon.getPageNumber(); } set; }
public void first() { setCon.first(); }
public void last() { setCon.last(); }
public void previous() { setCon.previous(); }
public void next() { setCon.next(); }
public PageReference refresh() {
setCon = null;
getAccountList();
setCon.setPageNumber(1);
return null;
}
// 5. Inner Wrapper Class
public class AccountWrapper {
public Account accRecord { get; set; }
public Boolean isSelected { get; set; }
public AccountWrapper(Account acc) {
this.accRecord = acc;
this.isSelected = false; // Checkbox defaults to unchecked
}
}
}
🖥️ 2. Visualforce Page Frontend Markup
The page renders the collection inside an <apex:pageBlockTable>. We include an actionStatus to show a loading spinner, pagination links to flip through the data, and checkboxes tied to our wrapper class.
AccountPagination.page)
<apex:page controller="AccountPaginationController" sidebar="false">
<apex:form>
<!-- CSS for Loading Spinner -->
<style>
.Processing {
position: fixed;
background: rgba(255, 255, 255, 0.7) url('/img/loading32.gif') no-repeat center;
width: 100%; height: 100%; z-index: 1004; left: 0; top: 0;
}
.pagination-panel { margin: 15px 0; font-weight: bold; text-align: center; }
.pagination-panel a { margin: 0 10px; text-decoration: none; color: #015ba7; }
.pagination-panel a.disabled { color: #ccc; pointer-events: none; }
</style>
<apex:actionStatus id="statusProcessing" startStyleClass="Processing" />
<apex:outputPanel id="msgPanel">
<apex:pageMessages />
</apex:outputPanel>
<apex:pageBlock title="Accounts - Page #{!pageNumber}" id="mainBlock">
<apex:pageBlockButtons location="top" rendered="{!accountList.size > 0}">
<apex:commandButton value="Process Selected" action="{!processSelected}" reRender="mainBlock,msgPanel" status="statusProcessing" />
<apex:commandButton value="Refresh" action="{!refresh}" reRender="mainBlock,msgPanel" status="statusProcessing" />
</apex:pageBlockButtons>
<apex:pageMessage severity="info" summary="No records found." rendered="{!accountList.size == 0}" />
<!-- Data Table -->
<apex:pageBlockTable value="{!accountList}" var="accWrap" rendered="{!accountList.size > 0}">
<apex:column width="40px">
<apex:facet name="header">Select</apex:facet>
<apex:inputCheckbox value="{!accWrap.isSelected}" />
</apex:column>
<apex:column value="{!accWrap.accRecord.Name}" />
<apex:column value="{!accWrap.accRecord.AccountNumber}" />
<apex:column value="{!accWrap.accRecord.Industry}" />
<apex:column value="{!accWrap.accRecord.Phone}" />
</apex:pageBlockTable>
<!-- Pagination Controls -->
<apex:outputPanel layout="block" styleClass="pagination-panel" rendered="{!accountList.size > 0}">
<apex:commandLink action="{!first}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasPrevious, '', 'disabled')}">« First</apex:commandLink>
<apex:commandLink action="{!previous}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasPrevious, '', 'disabled')}">< Previous</apex:commandLink>
<apex:commandLink action="{!next}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasNext, '', 'disabled')}">Next ></apex:commandLink>
<apex:commandLink action="{!last}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasNext, '', 'disabled')}">Last »</apex:commandLink>
</apex:outputPanel>
</apex:pageBlock>
</apex:form>
</apex:page>
🚨 3. Critical Traps & Best Practices
In the basic code above, because
getAccountList() creates entirely new wrapper instances whenever the page changes, any checkboxes checked on Page 1 will be instantly erased when the user clicks "Next" to go to Page 2. If your business requirement demands persisting selections globally across all pages, you must create a Map<Id, Boolean> inside the controller to remember which specific Record IDs were checked before flipping the page.
Database.getQueryLocator() instead of a standard List query. A standard query maxes out at 50,000 rows, but a QueryLocator inside a StandardSetController allows you to paginate up to 10,000 records highly efficiently.
- Class:
ApexPages.StandardSetController - Max Query Size: 10,000 Records via
Database.getQueryLocator(). - Key Methods:
setPageSize(),next(),previous(),getRecords(). - Performance Boost: Always specify
reRender="mainBlock"andstatus="statusProcessing"on your pagination links to prevent full, clunky page reloads.
🎤 4. Core Interview Q&A
Q: What happens if a user clicks the "Next" button on the very last page?
setCon.getHasNext() and setCon.getHasPrevious() boolean methods to selectively disable or hide the pagination links in your Visualforce page (as seen in the code with the disabled CSS class).
Q: I have a massive dataset of 500,000 records. Will StandardSetController work for this?
Answer: No. StandardSetController is strictly capped at 10,000 records when used with Database.getQueryLocator(). If your data exceeds this, you must switch to an API-based pagination approach using SOQL OFFSET (up to 2,000) or cursor-based pagination (querying where ID > lastId), typically surfaced through Lightning Web Components (LWC).
🔗 5. Follow-Up Question
Q: How does this Visualforce pattern translate to modern Lightning Web Components (LWC)?
Answer: In modern LWC architecture, you generally replace this entire pattern with the base <lightning-datatable> component. LWC datatables natively handle row-level checkbox selection out of the box without needing an Apex Wrapper Class. For pagination in LWC, you would fetch smaller chunks of data using an Apex controller that receives an offset/limit parameter, or you would handle pagination purely client-side in JavaScript if the total dataset is small enough.
Comments
Questions, corrections and your own experience are all welcome.
Post a Comment