StandardSetController together with a custom Wrapper Class lets you display a subset of records per page while allowing users to select rows with checkboxes for mass actions.
Displaying hundreds or thousands of records on a single page causes slow load times, high memory consumption, and poor user experiences. Combining ApexPages.StandardSetController with an Apex wrapper class provides a robust solution for table pagination with row-level selection capabilities.
1. Apex Controller Implementation
The controller creates a StandardSetController instance via a Database Query Locator, configures the page size, provides pagination controls (next(), previous(), first(), last()), and wraps each sObject inside an inner wrapper class for checkbox binding.
QuoteLineItemPaginationController.cls)
public with sharing class QuoteLineItemPaginationController {
public Integer noOfRecords { get; set; }
public Integer pageSize { get; set; }
public List<SelectedWrapper> categories { get; set; }
public ApexPages.StandardSetController setCon {
get {
if (setCon == null) {
pageSize = 5;
setCon = new ApexPages.StandardSetController(
Database.getQueryLocator([
SELECT Id, LineNumber, PricebookEntry.Product2.Name, Product2Id, Quantity, UnitPrice, QuoteId
FROM QuoteLineItem
WITH USER_MODE
])
);
setCon.setPageSize(pageSize);
noOfRecords = setCon.getResultSize();
}
return setCon;
}
set;
}
public List<SelectedWrapper> getLineItems() {
categories = new List<SelectedWrapper>();
for (QuoteLineItem item : (List<QuoteLineItem>) setCon.getRecords()) {
categories.add(new SelectedWrapper(item));
}
return categories;
}
public PageReference executeCustomAction() {
List<QuoteLineItem> selectedItems = new List<QuoteLineItem>();
if (categories != null) {
for (SelectedWrapper wrap : categories) {
if (wrap.isChecked) {
selectedItems.add(wrap.itemRecord);
}
}
}
ApexPages.addMessage(new ApexPages.Message(
ApexPages.Severity.CONFIRM,
'Selected records count on current page: ' + selectedItems.size()
));
return null;
}
public PageReference refresh() {
setCon = null;
getLineItems();
setCon.setPageNumber(1);
return null;
}
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(); }
// Inner Wrapper Class for Checkbox Selection
public class SelectedWrapper {
public Boolean isChecked { get; set; }
public QuoteLineItem itemRecord { get; set; }
public SelectedWrapper(QuoteLineItem item) {
this.itemRecord = item;
this.isChecked = false;
}
}
}
2. Visualforce Page Implementation
The page renders the collection inside an <apex:pageBlockTable> with action status loaders, pagination links, and real-time checkbox selection.
QuoteLineItemPagination.page)
<apex:page controller="QuoteLineItemPaginationController" sidebar="false">
<apex:form>
<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: 10px 0;
font-weight: bold;
}
.pagination-panel a {
margin-right: 15px;
text-decoration: none;
}
</style>
<apex:actionStatus id="statusProcessing" startStyleClass="Processing" />
<apex:outputPanel id="msgPanel">
<apex:pageMessages />
</apex:outputPanel>
<apex:pageBlock title="{!$ObjectType.QuoteLineItem.LabelPlural} - Page #{!pageNumber}" id="mainBlock">
<apex:pageBlockButtons location="top" rendered="{!lineItems.size > 0}">
<apex:commandButton value="Process Selected" action="{!executeCustomAction}" 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="{!lineItems.size == 0}" />
<apex:pageBlockTable value="{!lineItems}" var="row" id="recordTable" rendered="{!lineItems.size > 0}">
<apex:column width="40px">
<apex:facet name="header">Select</apex:facet>
<apex:inputCheckbox value="{!row.isChecked}" />
</apex:column>
<apex:column value="{!row.itemRecord.LineNumber}" headerValue="Line #" />
<apex:column value="{!row.itemRecord.PricebookEntry.Product2.Name}" headerValue="Product Name" />
<apex:column value="{!row.itemRecord.Quantity}" headerValue="Quantity" />
<apex:column value="{!row.itemRecord.UnitPrice}" headerValue="Sales Price" />
</apex:pageBlockTable>
<apex:outputPanel layout="block" styleClass="pagination-panel" rendered="{!lineItems.size > 0}">
<apex:panelGrid columns="4">
<apex:commandLink action="{!first}" reRender="mainBlock" status="statusProcessing">First</apex:commandLink>
<apex:commandLink action="{!previous}" rendered="{!hasPrevious}" reRender="mainBlock" status="statusProcessing">Previous</apex:commandLink>
<apex:commandLink action="{!next}" rendered="{!hasNext}" reRender="mainBlock" status="statusProcessing">Next</apex:commandLink>
<apex:commandLink action="{!last}" reRender="mainBlock" status="statusProcessing">Last</apex:commandLink>
</apex:panelGrid>
</apex:outputPanel>
</apex:pageBlock>
</apex:form>
</apex:page>
- Query Locator Capacity:
Database.getQueryLocator()supports up to 10,000 records in standard controllers. - Navigation API: Built-in
getHasNext(),getHasPrevious(),first(), andlast()simplify UI logic. - Wrapper Pattern: Pairs raw database sObjects with transient UI state attributes (like
isChecked). - User Mode Security: Query enforces field and object security natively via
WITH USER_MODE.
3. Critical Traps & Best Practices
Because
getLineItems() instantiates new wrapper instances whenever pagination changes, checkboxes checked on Page 1 are reset when navigating to Page 2. If cross-page selection persistence is required, maintain a state Map of Map<Id, Boolean> inside the controller to preserve selected IDs across page transitions.
Database.getQueryLocator inside StandardSetController to paginate up to 10,000 records without hitting the standard 50,000 SOQL row heap limits in Visualforce controllers.
- Partial DOM Rerendering: Always specify
reRender="mainBlock"andstatus="statusProcessing"on pagination links to prevent full page reloads. - Maintain Secure Class Declarations: Declare controllers as
with sharingto enforce org-wide sharing rules. - Modern Alternative: For modern applications, implement pagination declaratively or via Lightning Web Components using the base
<lightning-datatable>with client-side or server-side slicing.
Summary
Using StandardSetController with a custom wrapper class provides an efficient, standardized approach to paginating records in Salesforce Visualforce pages. This architecture keeps heap sizes lean, improves rendering performance, and delivers seamless record selection workflows for end users.