Building Complex LWC Tables with GraphQL Instead of Apex
Introduction
When you work with LWC long enough, you start to notice a repeating scenario. At first, it's simple: you need a list of records, so write @wire, get the data, and output it to a template. Beautiful. Then filters appear. Then sorting. Then the business says, "Let's add pagination, but not the standard one." And that's when the code starts to bloat. Where it starts to hurt.
In the classic Salesforce approach, you usually choose one of two things: either pull data through Apex controllers and SOQL or use LDS and standard wire adapters.
Both options work. The problem is that they don't scale well when the interface becomes complex. Let's say you have a table:
- several filters that depend on each other;
- custom pagination;
- inline editing;
- plus business logic that influences which fields need to be loaded.
In an Apex approach, this almost always turns into a set of compromises:
- One method returns too much data "just in case";
- Another is almost a duplicate of the first, but with filters;
- A third is only for pagination.
What GraphQL Offers.
GraphQL approaches this from a different angle. The idea is simple and human-readable: the client specifies what data it needs. In the context of LWC, this looks like this:
- A component describes a single request;
- The request explicitly lists the fields;
- Filters, sorting, and pagination are part of the same request;
- The response comes in a strictly predictable form.
There's no feeling of "guessing" what data you might need next. You request exactly what you need now.
Why GraphQL Isn't a Silver Bullet
It's important to say upfront: GraphQL in LWC is not a replacement for everything else. It's not a "new standard that needs to be implemented everywhere immediately." It adds: new syntax, a new thinking model, and its own limitations. But in exchange, you get: less hand-crafted state, less Apex code "for UI maintenance," more transparent communication between the interface and the data.
Classic Implementation of a Complex Table in LWC
Before talking about GraphQL, it's useful to look at what a complex table in LWC typically looks like without it. Not in theory, but in a real project.
A typical set of requirements:
- a table with data (Sensor__c);
- filters by status, model, and base station;
- custom pagination;
- the ability to scroll forward and backward.
On the frontend, this usually means state, events, and filter serialization. On the backend, an Apex service handles almost all the data retrieval logic.
Apex service as the center of all logic
In the classic approach, most of the complexity ends up in Apex. For example, a table service might look like this:
public with sharing class SensorTableService {
@AuraEnabled
public static Integer countSensors(String payload) {
ParkingCloudTypes.PageFilters filters = parseFilterPayload(payload);
String status = ('All'.equalsIgnoreCase(filters?.statusFilter))
? null
: filters?.statusFilter;
List<String> models = (filters?.models == null)
? new List<String>()
: filters.models;
List<Id> stationIds = (filters?.stationIds == null)
? new List<Id>()
: filters.stationIds;
List<String> whereParts = new List<String>{ 'Id != null' };
if (status != null) {
whereParts.add('Status__c = :status');
}
if (!models.isEmpty()) {
whereParts.add('Sensor_Model__c IN :models');
}
if (!stationIds.isEmpty()) {
whereParts.add('Base_Station__c IN :stationIds');
}
String soql =
'SELECT COUNT() FROM Sensor__c WHERE ' + String.join(whereParts, ' AND ');
return (Integer) Database.countQuery(soql);
}
Even in this shortened fragment, several characteristic points are evident:
- filters arrive serialized;
- they need to be parsed;
- a WHERE clause is manually compiled for each filter.
This is normal. Almost everyone writes like this.
But pagination: A Separate Story
Pagination almost always complicates code more than expected. This example uses keyset pagination by ID, with NEXT and PREV support.
Id cursor = request?.cursorId;
Boolean goPrev = ('PREV'.equalsIgnoreCase(request?.direction));
String cursorDirection = (goPrev) ? '<' : '>';
if (cursor != null) {
whereParts.add('Id ' + cursorDirection + ' :cursor');
}
String orderDirection = (goPrev) ? 'DESC' : 'ASC';
At this point, it becomes clear: a table is no longer just a SELECT ... LIMIT statement, but a mini-system with its own rules.
Forming a Response for the Frontend
Data almost always needs to be formatted in a way that's convenient for LWC:
private static ParkingCloudTypes.PageResponse buildPageResponse(
List<Sensor__c> records
) {
ParkingCloudTypes.PageResponse response = new
ParkingCloudTypes.PageResponse();
response.rows = new List<ParkingCloudTypes.Row>();
for (
Sensor__c sensor : (records == null ? new List<Sensor__c>() : records)
) {
ParkingCloudTypes.Row row = new ParkingCloudTypes.Row();
row.id = sensor.Id;
row.sensorName = sensor.Name;
row.sensorModel = String.valueOf(sensor.Sensor_Model__c);
row.status = String.valueOf(sensor.Status__c);
row.baseStationId = sensor.Base_Station__c;
row.baseStationName = (sensor.Base_Station__c == null)
? null
: sensor.Base_Station__r.Name;
response.rows.add(row);
}
if (!response.rows.isEmpty()) {
response.prevCursor = response.rows[0].id;
response.nextCursor = response.rows[response.rows.size() - 1].id;
}
return response;
}
There's nothing unusual here, but it's another layer of code that needs to be: maintained, tested, extended as requirements change. It's important to note that this implementation is functional, performant, and controllable.
But it requires a large amount of Apex code, manual filter management, custom pagination logic, and an additional contract between the frontend and the backend. Against the backdrop of these difficulties, GraphQL begins to shine, because it takes on the lion's share of the logic.
GraphQL in LWC
After the classic implementation with Apex services, GraphQL in LWC seems unusual at first. But terminology aside, the idea is quite simple: a single query describes data, filters, and pagination. There are no separate methods for counting records, retrieving pages and creating cursors.
Connecting GraphQL to LWC
In LWC, GraphQL is used via the lightning/graphql wire adapter. The component describes the query and passes variables to it—the platform does the rest. A basic query example:
import { LightningElement, wire } from "lwc";
import { gql, graphql } from "lightning/graphql";
export default class SensorTable extends LightningElement {
sensors;
error;
@wire(graphql, {
query: gql`
query Sensors($first: Int!) {
uiapi {
query {
Sensor__c(first: $first) {
edges {
node {
Id
Name { value }
Status__c { value }
Sensor_Model__c { value }
}
}
}
}
}
}
`,
variables: { first: 10 }
})
wiredSensors({ data, errors }) {
if (data) {
this.sensors = data.uiapi.query.Sensor__c.edges;
}
if (errors) {
this.error = errors;
}
}
}
Even at this stage, the difference is noticeable:
- no Apex controller;
- no DTO classes;
- fields are selected directly in the query.
Filters: part of the query, not separate logic
In the classic implementation, filters arrive serialized and are collected in the WHERE clause. In GraphQL, they are described declaratively. An example of filtering by status and model:
query Sensors(
$first: Int!
$status: Picklist
$models: [Picklist]
) {
uiapi {
query {
Sensor__c(
first: $first
where: {
Status__c: { eq: $status }
Sensor_Model__c: { in: $models }
}
) {
edges {
node {
Id
Name { value }
}
}
}
}
}
}
On the front end, this becomes regular component variables, not SOQL build logic.
variables() {
return {
first: 10,
status: this.selectedStatus,
models: this.selectedModels
};
}
The filter changes, the query is updated, the data is re-rendered. No manual state control.
Pagination: Cursors Instead of Pages
The most noticeable difference is pagination. GraphQL doesn't work with page numbers and doesn't understand what getLastPage is. Instead, cursor-based pagination is used:
- first
- after
- pageInfo
Example query with pagination:
query SensorsPage($first: Int!, $after: String) {
uiapi {
query {
Sensor__c(first: $first, after: $after) {
edges {
node {
Id
Name { value }
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
In a classic implementation, inline editing almost always means:
- a separate Apex method;
- a DTO or wrapper;
- reloading data after saving.
Since Spring '26, GraphQL in LWC has received support for mutations, allowing you to update records directly from a component without an Apex middleware.
import { executeMutation, gql } from "lightning/graphql";
const UPDATE_SENSOR_MUTATION = gql`
mutation UpdateSensor($input: Sensor__cUpdateInput!) {
uiapi {
Sensor__cUpdate(input: $input) {
record {
Id
Status__c { value }
Sensor_Model__c { value }
}
}
}
}
`;
Calling a mutation from a component:
async handleInlineSave(sensorId, newStatus) {
const variables = {
input: {
Sensor__c: {
Id: sensorId,
Status__c: newStatus
}
}
};
await executeMutation({
mutation: UPDATE_SENSOR_MUTATION,
variables
});
}
GraphQL doesn't make a table simple. But it removes a layer of infrastructure code that previously had to be written every time.
Conclusion
After looking at both options, it's easier to talk about the classic implementation via Apex and the GraphQL approach in LWC. I can't say one approach is "correct" and the other "obsolete."
For the classic approach from the chapters above, we can summarize what the classic scheme provides:
- Apex as the center of all logic: filters, sorting, pagination, response generation tailored to the UI.
- Clear control over SOQL and performance.
- A large amount of glue code between the Apex and LWC.
This approach works well as long as the interface is simple, requirements are few, and changes are infrequent. But as soon as the UI takes on a life of its own, the code quickly grows. Methods for counting, cursors, filter serialization, and separate response models appear. All of this needs to be maintained and kept in mind.
What changes with GraphQL
GraphQL doesn't completely remove complexity, but it redistributes it. What goes away:
- manual WHERE clause assembly;
- separate methods for COUNT();
- custom pagination logic;
- DTOs and mapping for each new use case.
What remains:
- UI state;
- error handling;
- UX logic (buttons, loading, validation).
What adds:
- declarative queries;
- clear connection between the UI and the data;
- less "technical" Apex code.
This is especially noticeable in tables with filters and pagination there, GraphQL really reduces the amount of code and the number of places where errors can occur.
Inline Editing and Spring '26
The changes in Spring '26 are worth mentioning separately. GraphQL mutation support doesn't automatically enable inline editing, but it does remove another layer of infrastructure. For tables with editing, this is a strong simplification, not a revolution, but a step forward.
To avoid creating false expectations, it's important to note:
- GraphQL in LWC is still an evolving tool;
- Not all scenarios are easily expressed via a query;
- Complex business logic still thrives in Apex;
- Backward pagination and a "last page" are not included out of the box.
GraphQL doesn't replace architectural decisions — it only reduces their cost in UI-centric scenarios.
Final conclusion
- For simple screens, GraphQL is too much.
- For complex tables and dynamic UIs, it really simplifies work.
- For projects with actively evolving requirements, GraphQL reduces the cost of change.
Having tried both options in the project, after implementing the project using GraphQL, I felt like some of the code existed previously simply because "there was no other way." Now it can.

