When displaying large datasets in Salesforce, showing hundreds of records on a single page degrades browser performance and overwhelms users. Pairing Apex's native StandardSetController with a custom Wrapper Class allows you to build clean, paginated tables with interactive elements like checkboxes—without running into governor limits.
StandardSetController manages chunking large record sets into manageable pages (e.g., 10 or 20 records per view), while a wrapper class pairs each Salesforce record with temporary UI controls—such as a "Selected" checkbox—so users can perform bulk actions across pages.
Why Combine StandardSetController and Wrapper Classes?
Standard list controllers natively provide built-in methods like next(), previous(), first(), and last(). However, raw sObjects lack UI-only variables. A wrapper class acts as a data container that binds a Salesforce record (Account, Contact, etc.) to custom boolean flags.
- Automatic Page Offsets: Handles queries up to 10,000 records without custom offset SOQL calculations.
- Selection State Memory: Keeps track of records selected by the user as they navigate back and forth between pages.
- Optimized Performance: Renders only the active page subset in the DOM for fast loading.
Step 1: Apex Custom Controller Implementation
Below is the complete Apex controller demonstrating how to instantiate ApexPages.StandardSetController and wrap the record list for display.
This Apex controller queries accounts, sets the page size to 5 records, and converts each page's record set into wrapped objects.
public with sharing class AccountPaginationWrapperController {
// StandardSetController declaration
public ApexPages.StandardSetController setCon {
get {
if(setCon == null) {
setCon = new ApexPages.StandardSetController(Database.getQueryLocator(
[SELECT Id, Name, AccountNumber, Industry, Phone FROM Account ORDER BY Name ASC LIMIT 10000]
));
// Set the number of records per page
setCon.setPageSize(5);
}
return setCon;
}
set;
}
// Initialize list of wrapper objects for the current page
public List<AccountWrapper> getAccountList() {
List<AccountWrapper> categories = new List<AccountWrapper>();
for (Account acc : (List<Account>) setCon.getRecords()) {
categories.add(new AccountWrapper(acc));
}
return categories;
}
// Process selected records across pages
public void processSelected() {
List<Account> selectedAccounts = new List<Account>();
for (AccountWrapper wrap : getAccountList()) {
if (wrap.isSelected == true) {
selectedAccounts.add(wrap.accRecord);
}
}
// Perform actions on selected records (e.g., Update, Export, Delete)
}
// 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;
}
}
}
Step 2: Visualforce Page Frontend Markup
Render the wrapped data inside a data table along with pagination navigation controls (First, Previous, Next, Last).
<apex:page controller="AccountPaginationWrapperController">
<apex:form >
<apex:pageBlock title="Paginated Accounts with Selection">
<apex:pageBlockButtons location="top">
<apex:commandButton value="Process Selected" action="{!processSelected}" reRender="table" />
</apex:pageBlockButtons>
<!-- Account Table -->
<apex:pageBlockTable value="{!accountList}" var="accWrap" id="table">
<apex:column >
<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:panelGrid columns="4" style="margin-top:10px;">
<apex:commandButton value="First" action="{!setCon.first}" disabled="{!NOT(setCon.hasPrevious)}" reRender="table,controls" />
<apex:commandButton value="Previous" action="{!setCon.previous}" disabled="{!NOT(setCon.hasPrevious)}" reRender="table,controls" />
<apex:commandButton value="Next" action="{!setCon.next}" disabled="{!NOT(setCon.hasNext)}" reRender="table,controls" />
<apex:commandButton value="Last" action="{!setCon.last}" disabled="{!NOT(setCon.hasNext)}" reRender="table,controls" />
</apex:panelGrid>
</apex:pageBlock>
</apex:form>
</apex:page>
- State Loss on Page Navigation: Re-instantiating wrappers inside getter methods without preserving a master selection map will clear checkbox states when users navigate between pages. To persist selections globally across pages, store selected record IDs in a controller-level
Map<Id, Boolean>. - Query Governor Limits: Passing standard list queries without
Database.getQueryLocator()caps queries at 50,000 records. UsinggetQueryLocatorextends the query cap up to 10,000 records specifically suited for set controller pagination. - Modern Architecture Migration: If you are building new components in Lightning Experience, prefer Lightning Web Components (LWC) with client-side pagination or wire adapters over legacy Visualforce pages.
- Class:
ApexPages.StandardSetController - Max Query Size: 10,000 Records via
Database.getQueryLocator() - Key Methods:
setPageSize(),next(),previous(),getRecords() - Primary Use Case: Bulk record selection and paginated data display in custom interfaces.