Início › Desafios › Desafio 06 › Gabarito
Gabarito: paginar 5 mil registros num LWC
Esta página destrincha a solução do sexto desafio. Se você ainda não tentou, vale voltar e passar pelo
menos meia hora resolvendo: paginar parece simples até você bater no teto do OFFSET e
perceber que a primeira ideia não escala. Se já tentou, vamos comparar sua abordagem com esta.
Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio
O problema em uma frase
A tela trazia o objeto inteiro e mandava o navegador montar cinco mil linhas de uma vez. A correção não é otimizar o front: é não trazer o que não cabe na tela. O usuário enxerga algumas dezenas de linhas por vez, então o servidor devolve algumas dezenas por vez, e só busca mais quando ele pede.
A parte que separa a solução ingênua da que escala é o como paginar. A primeira ideia é
LIMIT mais OFFSET, e ela funciona nas primeiras páginas. Só que o SOQL trava o
OFFSET em 2000: passar disso devolve System.QueryException: maximum SOQL offset
allowed is 2000. Com cinco mil registros e páginas de 50, você quebra na página 41. A saída é
paginar por cursor: em vez de pular N registros, pedir os que vêm depois do último Id já mostrado.
A solução completa
O controlador Apex, que devolve uma página por vez a partir de um cursor:
public with sharing class OportunidadeController {
// Tamanho de seguranca caso o cliente mande algo invalido.
private static final Integer TAMANHO_PADRAO = 50;
@AuraEnabled(cacheable=true)
public static List<Opportunity> buscarPagina(Id ultimoId, Integer tamanho) {
Integer limite = (tamanho == null || tamanho <= 0) ? TAMANHO_PADRAO : tamanho;
// Primeira pagina: nao ha cursor ainda.
if (ultimoId == null) {
return [
SELECT Id, Name, Amount, StageName
FROM Opportunity
ORDER BY Id ASC
LIMIT :limite
];
}
// Paginas seguintes: so o que vem depois do ultimo Id ja mostrado.
return [
SELECT Id, Name, Amount, StageName
FROM Opportunity
WHERE Id > :ultimoId
ORDER BY Id ASC
LIMIT :limite
];
}
}
O componente, com o estado da paginação e o tratamento de erro:
import { LightningElement } from 'lwc';
import buscarPagina from '@salesforce/apex/OportunidadeController.buscarPagina';
const TAMANHO = 50;
export default class ListaOportunidades extends LightningElement {
registros = [];
ultimoId = null;
temMais = true;
carregando = false;
erro;
connectedCallback() {
this.carregarMais();
}
async carregarMais() {
// Guarda contra clique duplo e contra pedir depois do fim.
if (this.carregando || !this.temMais) {
return;
}
this.carregando = true;
this.erro = undefined;
try {
const pagina = await buscarPagina({ ultimoId: this.ultimoId, tamanho: TAMANHO });
this.registros = [...this.registros, ...pagina];
if (pagina.length > 0) {
this.ultimoId = pagina[pagina.length - 1].Id;
}
if (pagina.length < TAMANHO) {
this.temMais = false;
}
} catch (e) {
this.erro = this.mensagemDeErro(e);
} finally {
this.carregando = false;
}
}
mensagemDeErro(e) {
if (e && e.body && e.body.message) {
return e.body.message;
}
return 'Nao foi possivel carregar os registros. Tente novamente.';
}
get vazio() {
return !this.carregando && !this.erro && this.registros.length === 0;
}
}
E o template, que reage ao estado sem nunca ficar em branco:
<template>
<lightning-card title="Oportunidades" icon-name="standard:opportunity">
<div class="slds-p-horizontal_medium">
<template if:true={erro}>
<div class="slds-text-color_error slds-p-around_small" role="alert">
{erro}
</div>
</template>
<template if:true={vazio}>
<p class="slds-p-around_small">Nenhuma oportunidade encontrada.</p>
</template>
<ul class="slds-has-dividers_bottom">
<template for:each={registros} for:item="opp">
<li key={opp.Id} class="slds-item slds-p-vertical_x-small">{opp.Name}</li>
</template>
</ul>
<div class="slds-align_absolute-center slds-p-vertical_small">
<template if:true={temMais}>
<lightning-button label="Carregar mais" onclick={carregarMais}
disabled={carregando}></lightning-button>
</template>
<template if:true={carregando}>
<lightning-spinner alternative-text="Carregando"></lightning-spinner>
</template>
</div>
</div>
</lightning-card>
</template>
Destrinchando parte por parte
1. O cursor em vez do OFFSET
if (ultimoId == null) {
return [SELECT Id, Name, Amount, StageName FROM Opportunity ORDER BY Id ASC LIMIT :limite];
}
return [
SELECT Id, Name, Amount, StageName
FROM Opportunity
WHERE Id > :ultimoId
ORDER BY Id ASC
LIMIT :limite
];
Este é o coração da solução. Em vez de dizer "pule os primeiros 2050 e me traga 50", que o SOQL não deixa, a consulta diz "me traga os 50 primeiros cujo Id é maior que o último que eu já mostrei". O cursor é o próprio Id. Como o Id é único e a consulta ordena por ele, cada página começa exatamente onde a anterior terminou: nada repete, nada some no meio.
Repare que o ORDER BY Id ASC não é decoração. É ele que dá sentido ao Id > :ultimoId.
Sem uma ordem estável e alinhada ao campo do cursor, a fatia "depois do último Id" não tem significado, e
você volta a repetir ou pular registros. Cursor e ordenação são um par: mudou um, muda o outro.
2. O tamanho com rede de segurança
Integer limite = (tamanho == null || tamanho <= 0) ? TAMANHO_PADRAO : tamanho;
O tamanho vem do cliente, e cliente manda o que quiser. Se chegar null ou zero, a
consulta com LIMIT :tamanho ou quebraria ou traria nada. Tratar isso no servidor é barato e
evita que um bug no front vire uma exceção feia para o usuário. É a mesma disciplina de sempre: não confie
no que atravessa a fronteira do método.
3. Anexar, não substituir
const pagina = await buscarPagina({ ultimoId: this.ultimoId, tamanho: TAMANHO });
this.registros = [...this.registros, ...pagina];
if (pagina.length > 0) {
this.ultimoId = pagina[pagina.length - 1].Id;
}
O botão se chama "Carregar mais", então cada carga acrescenta à lista, não a troca. O
spread [...this.registros, ...pagina] cria um novo array, o que é importante em LWC: reatribuir
a propriedade é o que dispara o re-render. Se você fizesse this.registros.push(...), mutaria o
array no lugar e o template poderia não atualizar.
Logo depois, o cursor avança para o Id do último registro que veio. É essa linha que faz a próxima chamada
pedir a fatia seguinte. Ela só roda se veio pelo menos um registro, senão pagina[pagina.length - 1]
estouraria num array vazio.
4. Saber que chegou ao fim (a borda que a maioria esquece)
if (pagina.length < TAMANHO) {
this.temMais = false;
}
Como o componente sabe que não há mais páginas? Comparando o que pediu com o que veio. Se você pediu 50 e
vieram 50, provavelmente há mais. Se vieram menos que 50, aquela foi a última fatia. Quando
temMais vira false, o template esconde o botão e a guarda no topo do método
(if (this.carregando || !this.temMais) return;) impede qualquer chamada extra.
Esse é o par que satisfaz a regra de borda: lista que termina sem que o usuário consiga disparar mais uma
consulta inútil. E cobre também a org vazia: se a primeira carga vier com zero registros, temMais
cai na hora, o getter vazio fica verdadeiro e o template mostra o estado vazio em vez de uma
tela em branco.
5. O erro que não vira tela branca
} catch (e) {
this.erro = this.mensagemDeErro(e);
} finally {
this.carregando = false;
}
Toda chamada imperativa de Apex devolve uma promise. Sem catch, uma falha vira uma promise
rejeitada que ninguém tratou: o console acusa Unhandled promise rejection e o usuário fica
olhando para o nada. O catch captura o erro e o getter mensagemDeErro puxa o
texto de e.body.message, que é onde o Apex coloca a mensagem da exceção.
O finally é tão importante quanto o catch: ele desliga o carregando
em qualquer desfecho. Sem isso, um erro deixaria o spinner girando para sempre e o botão travado em
desabilitado. O caminho de erro precisa devolver o componente a um estado utilizável.
Erros comuns
- Paginar com
LIMITeOFFSET. Funciona nas primeiras páginas e quebra na primeira que passa deOFFSET2000. É o erro que o próprio cenário provoca: cinco mil registros garantem que você bata no teto. - Trazer tudo e paginar no JavaScript. Cortar o array no cliente com
slicemascara o problema: você ainda buscou os cinco mil e ainda pagou o custo de transferência e memória. A regra pede paginação no servidor justamente para não cair nessa. - Usar
pushem vez de reatribuir o array. Mutarthis.registrosno lugar não avisa o LWC de que houve mudança, e a lista na tela não cresce apesar de os dados chegarem. - Esquecer o
finally. Tratar o erro mas deixar ocarregandoligado congela o componente num spinner eterno. O estado precisa se recompor depois da falha. - Não desligar o botão no fim. Sem a checagem
pagina.length < TAMANHO, o usuário continua clicando "Carregar mais" no fim da lista e disparando consultas que voltam vazias.
Como saber se a sua solução passou
O script de teste do desafio prova o servidor por Execute Anonymous: a primeira página volta com o tamanho pedido, a segunda não repete nenhum Id da primeira e o consumo de consultas fica baixo. Esse é o critério objetivo do controlador.
System.assertEquals(0, repetidos, 'A pagina seguinte nao pode repetir registro da anterior');
System.debug('Consultas na transacao: ' + Limits.getQueries());
A parte da tela você confere subindo o componente numa página Lightning: role até o fim e veja o botão sumir, force um erro (por exemplo, renomeando o método Apex temporariamente) e veja a mensagem aparecer em vez de a tela ficar branca. Se as duas bordas se comportam, a solução está completa.
Um passo além
Se quiser levar a solução para o nível que se espera em produção, três evoluções:
-
Trocar a lista por
lightning-datatablecom scroll infinito. Olightning-datatabletem o eventoonloadmore, que dispara quando o usuário rola até o fim. Ligado ao mesmocarregarMais, ele troca o botão por carregamento contínuo, mais próximo do que o usuário espera de uma lista grande. -
Respeitar as permissões de quem abriu a tela. A consulta acima roda com o acesso da
classe. Acrescente
WITH USER_MODEpara que o componente só mostre oportunidades que o usuário poderia ver, e campos que ele pode ler. O raciocínio completo está em segurança em Apex: CRUD, FLS e sharing. - Entender o custo de cada consulta na transação. Paginar bem é, no fundo, respeitar os limites da plataforma sob volume. O panorama de por que cada query e cada linha importam está em governor limits em Apex.
O que fica deste desafio: performance de tela com volume não se resolve no navegador,
e sim decidindo no servidor o que sequer sai do banco. Paginação por cursor é a ferramenta que faz isso
escalar sem esbarrar no teto do OFFSET. Em entrevista de LWC, "como você pagina uma lista de
dez mil registros" é uma das perguntas que mais separa quem já sofreu em produção de quem só viu o caminho
feliz.