Visualforce and the StandardSetController served us well for years, but the Salesforce ecosystem has firmly moved to Lightning Web Components (LWC). While Visualforce required complex Apex wrapper classes to handle simple UI checkboxes, LWC separates the data from the interface beautifully. Migrating your paginated tables to LWC drastically improves page load speeds, reduces server calls, and provides a much sleeker user experience.
lightning-datatable. We tell Apex exactly which "page" of data we want using SOQL OFFSET, and JavaScript handles remembering your selected rows instantly behind the scenes.
Key Points for LWC Pagination
- No Wrapper Classes Needed: The
lightning-datatablecomponent natively supports row selection checkboxes. You do not need an Apex wrapper class just to track a boolean flag. - Server-Side vs. Client-Side: If you have under 2,000 records, you can query them all at once and use JavaScript to paginate (Client-Side). If you have more, you should use SOQL
LIMITandOFFSET(Server-Side). - State Management: The hardest part of custom pagination is remembering selections across pages. LWC handles this effortlessly using the
selected-rowsattribute.
Step 1: The Modern Apex Controller
Our Apex controller needs to be incredibly lightweight. It only does two things: tells us the total number of records (so we can calculate total pages) and fetches the specific chunk of records we request.
Notice the
@AuraEnabled(cacheable=true) tags. This allows Lightning Data Service to cache the query results, making pagination lighting fast when users click "Previous".
public with sharing class AccountLWCPaginationController {
// 1. Get the total count of records for math
@AuraEnabled(cacheable=true)
public static Integer getTotalAccountCount() {
return [SELECT COUNT() FROM Account];
}
// 2. Fetch only the records for the current page
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts(Integer pageSize, Integer pageNumber) {
// Calculate the SOQL offset based on the page number
Integer offset = (pageNumber - 1) * pageSize;
return [SELECT Id, Name, AccountNumber, Industry, Phone
FROM Account
ORDER BY Name ASC
LIMIT :pageSize
OFFSET :offset];
}
}
Step 2: The LWC HTML Template
The standard lightning-datatable supports infinite scrolling, but standard page-by-page navigation requires custom buttons. We will pair the datatable with "Previous" and "Next" buttons.
<template>
<lightning-card title="Paginated Accounts (LWC)" icon-name="standard:account">
<div class="slds-m-around_medium">
<!-- Data Table -->
<lightning-datatable
key-field="Id"
data={accounts}
columns={columns}
selected-rows={selectedRowIds}
onrowselection={handleRowSelection}>
</lightning-datatable>
</div>
<!-- Pagination Controls -->
<div class="slds-m-top_medium slds-text-align_center">
<lightning-button
label="Previous"
onclick={handlePrevious}
disabled={isFirstPage}>
</lightning-button>
<span class="slds-m-horizontal_medium">
Page {pageNumber} of {totalPages}
</span>
<lightning-button
label="Next"
onclick={handleNext}
disabled={isLastPage}>
</lightning-button>
</div>
</lightning-card>
</template>
Step 3: The LWC JavaScript Controller
This is where the magic happens. We wire our Apex methods to automatically refresh data whenever the pageNumber variable changes.
import { LightningElement, wire, track } from 'lwc';
import getAccounts from '@salesforce/apex/AccountLWCPaginationController.getAccounts';
import getTotalAccountCount from '@salesforce/apex/AccountLWCPaginationController.getTotalAccountCount';
const COLUMNS = [
{ label: 'Account Name', fieldName: 'Name' },
{ label: 'Account Number', fieldName: 'AccountNumber' },
{ label: 'Industry', fieldName: 'Industry' },
{ label: 'Phone', fieldName: 'Phone', type: 'phone' }
];
export default class AccountPaginationLWC extends LightningElement {
columns = COLUMNS;
@track accounts = [];
// Pagination variables
pageNumber = 1;
pageSize = 5;
totalRecords = 0;
totalPages = 0;
// Persist selected rows across pages
@track selectedRowIds = [];
// Get Total Count
@wire(getTotalAccountCount)
wiredCount({ error, data }) {
if (data) {
this.totalRecords = data;
this.totalPages = Math.ceil(this.totalRecords / this.pageSize);
}
}
// Get Records dynamically based on pageNumber
@wire(getAccounts, { pageSize: '$pageSize', pageNumber: '$pageNumber' })
wiredAccounts({ error, data }) {
if (data) {
this.accounts = data;
} else if (error) {
console.error('Error loading accounts', error);
}
}
// Capture row selections and add them to our running list
handleRowSelection(event) {
const selectedRows = event.detail.selectedRows;
// Logic to merge current page selections with existing global selections
// (Simplified here: lightning-datatable can auto-manage this if fed the correct selectedRowIds)
const currentIds = selectedRows.map(row => row.Id);
this.selectedRowIds = [...new Set([...this.selectedRowIds, ...currentIds])];
}
// Navigation logic
handlePrevious() {
if (this.pageNumber > 1) {
this.pageNumber -= 1;
}
}
handleNext() {
if (this.pageNumber < this.totalPages) {
this.pageNumber += 1;
}
}
get isFirstPage() {
return this.pageNumber === 1;
}
get isLastPage() {
return this.pageNumber >= this.totalPages;
}
}
- The OFFSET Limit Trap: SOQL
OFFSEThas a strict maximum of 2,000 records. If you try to paginate to record 2,001, your component will crash. For massive datasets exceeding 2,000 records, you must use standard list views, infinite scrolling, or boundary pagination (e.g.,WHERE Id > :lastId). - Forgetting to use '$' in Wire Parameters: Notice we used
'$pageNumber'in our@wiredecorator. The dollar sign makes the parameter reactive. When the JS variable updates, the Apex method automatically fires again. - Overwriting State: If you reset your
selectedRowIdsarray every time the data reloads, you will recreate the Visualforce "Amnesia" bug. Ensure you are pushing IDs into a global Set or Array.
totalPages dynamically. Hardcoding page limits will break if users add or delete records while viewing the component.
Frequently Asked Questions
Can I still use the StandardSetController with LWC?
No. The StandardSetController is intrinsically tied to Visualforce state management. In LWC, Apex is stateless, and your JavaScript handles the application state.
Does lightning-datatable support built-in pagination?
It natively supports "Infinite Scrolling" (loading more records as you scroll down) via the enable-infinite-loading attribute, but it does not support numbered, page-by-page pagination natively. You have to build the Previous/Next buttons as shown in this tutorial.
How do I handle datasets larger than 2,000 records if OFFSET fails?
For large datasets, you must switch from OFFSET pagination to "Keyset" or "Cursor" pagination. Instead of asking for "Page 5", your query asks for SELECT ... WHERE Name > :lastRecordNameFromPage4 ORDER BY Name ASC LIMIT 10. This completely bypasses the 2,000 record limit.
Can I perform actions on the selected rows?
Yes. Because all selected IDs are stored in the selectedRowIds array, you can create a new button (e.g., "Mass Update") that passes this array of IDs to an imperative Apex method for processing in a single transaction.
- UI Component:
lightning-datatable - Backend Strategy: SOQL
LIMITandOFFSET - Apex Modifiers:
@AuraEnabled(cacheable=true) - Major Limitation:
OFFSETcannot exceed 2,000 records. - Key Benefit: Lightning fast, no wrapper classes required, decoupled architecture.