Dia 12 — Listas com dado remoto: paginação e cache
Paginação com FlatList
Paginação com FlatList
Trazer mil itens de uma vez custa memória, tempo e travo na rolagem. A
paginação resolve os três: a tela pede vinte, mostra vinte, e pede os vinte
próximos quando o usuário chega perto do fim. A propriedade que dispara a
carga seguinte é onEndReached, e ela recebe { distanceFromEnd } — a
distância, em pixels, até o fim da lista.
O detalhe que faz a diferença é distanceFromEnd: deixar no padrão dispara a
carga só quando o fim da lista já está visível, e o usuário vê o fim da lista e
para. Com distanceFromEnd: 200, a carga começa quando faltam duzentos pixels
para o fim, e na prática o dado já está lá quando o dedo chega.
O exemplo desta página simula um servidor com 47 registros e páginas de 20,
e as três cargas do início mostram a regra em números: página 1 com 20
itens, página 2 com 20, e o estado final em 47. As três requisições que o
servidor recebeu saem no estado como [ 1, 2, 3 ] — uma por página, sem
repetição e sem salto.
a regra que evita a requisição repetida
onEndReached dispara várias vezes enquanto o dedo está no fim da lista, e cada
disparo vira uma requisição. A guarda são três condições que precisam ser
juntas: carregando é falso, temMais é verdadeiro e a página pedida é maior
que a atual. Sem a primeira, o rodapé de carregamento faz a tela pedir a
próxima página em ciclo; sem a segunda, a última página gera requisição vazia
para sempre.
async function carregaMais() { if (carregando || !temMais) return; setCarregando(true); const proxima = pagina + 1; const novos = await buscar(proxima); setItens((atuais) => [...atuais, ...novos]); // anexa, não troca setPagina(proxima); setTemMais(novos.length === tamanhoDaPagina); setCarregando(false); }
O disparo repetido do exemplo é o teste da guarda, e a linha que sai é a que
resolve a dúvida: "o fim disparou de novo: ignorado". A segunda chamada do
onEndReached não virou requisição porque a lista já está no fim — temMais
virou falso na terceira página incompleta. Sem a guarda, esse segundo disparo
pediria a página 4 de um servidor que só tem 47 registros, e o onEndReached
dispararia de novo em resposta à lista que não cresceu.
O if (carregando || !temMais) return 'ignorado' é a guarda inteira, e o retorno
declarado como string é o que permite que o exemplo prove que ela funcionou: o
console distingue "carregou a página" de "ignorou", e o laço while do fim
sai quando recebe "ignorado".
setItens com função é o detalhe que importa: [...atuais, ...novos] usa a
lista atual e produz a nova, em vez de ler um itens fechado. É a diferença
entre anexar certo e anexar duplicado quando duas cargas chegam juntas.
A forma de temMais aparece no exemplo como contagem, e não como campo do
servidor: temMais = novos.length === tamanhoDaPagina. Página cheia significa
que existe a próxima, página incompleta significa que acabou. O laço final
confirma — a terceira página devolveu 7 itens, temMais ficou falso, e a
conta 47 % 20 não dá zero, o que o console imprime como "ultima pagina cheia?
false".
O rodapé de carregamento é o terceiro filho condicional da árvore do exemplo:
com carregando verdadeiro, a lista tem os itens mais um Text de rodapé, e o
console confirma que o último filho é Text. Sem carregando, esse filho
não existe — e é a ausência dele que impede o ciclo do parágrafo acima.
cursor em vez de página
A paginação por offset tem um defeito conhecido: se um item for inserido no
começo da lista entre duas páginas, o offset passa de um valor e o mesmo
item aparece duas vezes. A paginação por cursor usa a posição do último item
recebido como início da próxima busca, e é imune ao problema. A regra é a
mesma; muda o dado que o servidor recebe.
O exemplo mostra os dois lados: por página, a primeira devolve p1, p2, p3
e a segunda devolve p21, p22, p23. A linha seguinte registra o defeito do
offset — com um item novo inserido no início, a segunda página repete o item
21. É a posição que deslizou: o offset conta posições, e a posição do item
mudou por causa de alguém que criou um registro antes dele.
Exemplo
// `onEndReached` dispara a carga da proxima pagina, e a guarda tem tres // condicoes: nao esta carregando, tem mais, e a pagina e a seguinte. O // exemplo simula o servidor e a regra de paginacao. const { View, Text, StyleSheet } = require('react-native'); const TAMANHO_DA_PAGINA = 20; const TOTAL_NO_SERVIDOR = 47; // (1) o servidor simulado: devolve a pagina pedida e o total. function buscarPagina(pagina, tamanho, total) { const inicio = (pagina - 1) * tamanho; const fim = Math.min(inicio + tamanho, total); const itens = []; for (let i = inicio; i < fim; i += 1) { itens.push({ id: 'p' + (i + 1), nome: 'pedido ' + (i + 1) }); } return itens; } // (2) a regra de paginacao: as tres condicoes e o `temMais` pela contagem. function criarPagina(tamanhoDaPagina) { let itens = []; let pagina = 0; let temMais = true; let carregando = false; const requisicoes = []; return { carregar() { // a guarda: sem ela, o rodape dispara a mesma pagina varias vezes if (carregando || !temMais) return 'ignorado'; carregando = true; pagina += 1; requisicoes.push(pagina); const novos = buscarPagina(pagina, tamanhoDaPagina, TOTAL_NO_SERVIDOR); itens = [...itens, ...novos]; // anexa, nao troca temMais = novos.length === tamanhoDaPagina; // pagina cheia tem mais carregando = false; return 'pagina ' + pagina + ' com ' + novos.length + ' item(ns)'; }, estado() { return { pagina, quantos: itens.length, temMais, carregando, requisicoes: requisicoes.slice() }; }, ids() { return itens.map((i) => i.id); }, }; } const lista = criarPagina(TAMANHO_DA_PAGINA); console.log(lista.carregar()); console.log('estado:', lista.estado()); console.log(lista.carregar()); console.log('estado:', lista.estado()); // (3) o mesmo `onEndReached` disparando duas vezes seguidas: a segunda // carga so acontece porque a primeira terminou. let fimAtingido = false; function aoChegarNoFim(estado) { if (!fimAtingido) { fimAtingido = true; console.log(' primeira vez que o fim aparece:', lista.carregar()); return; } console.log(' o fim disparou de novo:', lista.carregar(), '-> sem `carregando` e sem `temMais`, nada acontece'); } aoChegarNoFim(lista.estado()); aoChegarNoFim(lista.estado()); console.log('estado final:', lista.estado()); // (4) a ultima pagina vem incompleta, e e ai que `temMais` vira falso. const ultima = criarPagina(TAMANHO_DA_PAGINA); while (true) { const resultado = ultima.carregar(); if (resultado === 'ignorado') break; console.log(resultado); } const fim = ultima.estado(); console.log('fim:', fim); console.log('itens recebidos:', fim.quantos, 'de', TOTAL_NO_SERVIDOR, '| ultima pagina cheia?', fim.quantos % TAMANHO_DA_PAGINA === 0); // (5) `distanceFromEnd`: a carga comecar antes do fim aparecer. console.log('padrao do sistema: a carga comeca quando o fim ja esta visivel'); console.log('com 200 de distancia, faltam 200 px para o fim: a carga comeca antes'); // (6) cursor em vez de pagina: imune a item inserido no comeco. function paginaPorOffset(offset) { return buscarPagina(offset, TAMANHO_DA_PAGINA, TOTAL_NO_SERVIDOR).slice(0, 3).map((i) => i.id); } console.log('por pagina, primeira pagina:', paginaPorOffset(1)); console.log('por pagina, segunda pagina:', paginaPorOffset(2)); console.log('por offset, com um item novo no inicio, a segunda pagina repete o item 21'); // (7) a arvore da lista com o rodape de carregamento. const estilos = StyleSheet.create({ item: { padding: 10 }, rodape: { padding: 16, textAlign: 'center' } }); function ListaPaginada(props) { return ( <View> {props.itens.map((item) => <Text key={item.id} style={estilos.item}>{item.nome}</Text>)} {props.carregando ? <Text style={estilos.rodape}>carregando mais</Text> : null} </View> ); } const arvore = ListaPaginada({ itens: lista.estado().requisicoes.map((p) => ({ id: 'p' + p, nome: 'pagina ' + p })), carregando: true }); console.log('tipo da arvore:', arvore.type, '| filhos:', arvore.props.children.length); console.log('o ultimo filho e o rodape, que so existe carregando:', arvore.props.children[arvore.props.children.length - 1].type);
Saída real
pagina 1 com 20 item(ns)
estado: {
pagina: 1,
quantos: 20,
temMais: true,
carregando: false,
requisicoes: [ 1 ]
}
pagina 2 com 20 item(ns)
estado: {
pagina: 2,
quantos: 40,
temMais: true,
carregando: false,
requisicoes: [ 1, 2 ]
}
primeira vez que o fim aparece: pagina 3 com 7 item(ns)
o fim disparou de novo: ignorado -> sem `carregando` e sem `temMais`, nada acontece
estado final: {
pagina: 3,
quantos: 47,
temMais: false,
carregando: false,
requisicoes: [ 1, 2, 3 ]
}
pagina 1 com 20 item(ns)
pagina 2 com 20 item(ns)
pagina 3 com 7 item(ns)
fim: {
pagina: 3,
quantos: 47,
temMais: false,
carregando: false,
requisicoes: [ 1, 2, 3 ]
}
itens recebidos: 47 de 47 | ultima pagina cheia? false
padrao do sistema: a carga comeca quando o fim ja esta visivel
com 200 de distancia, faltam 200 px para o fim: a carga comeca antes
por pagina, primeira pagina: [ 'p1', 'p2', 'p3' ]
por pagina, segunda pagina: [ 'p21', 'p22', 'p23' ]
por offset, com um item novo no inicio, a segunda pagina repete o item 21
tipo da arvore: View | filhos: 2
o ultimo filho e o rodape, que so existe carregando: Text
Cache de dado e atualização
Cache de dado e atualização
Cada vez que a tela monta, o useEffect busca de novo. O usuário que abre a
lista, sai para outra tela e volta percebe: os mesmos vinte itens chegam pela
segunda vez, e a espera recomeça. O cache é a resposta — guardar a resposta
recebida e reaproveitá-la quando a tela pedir de novo.
A forma simples é um objeto fora do componente, no módulo do serviço. Ele
sobrevive à tela desmontar e é compartilhado por todas as instâncias, e o
useEffect passa a decidir entre buscar e reaproveitar. A consequência de um
módulo compartilhado é o defeito a conhecer: o cache vaza entre usuários quando
o app troca de sessão, e a correção é limpar o cache no logout.
O exemplo desta página usa um Map no módulo e mede as três chamadas: na
primeira, "sem cache: buscou do servidor", com uma requisição. Na segunda, um
minuto depois, nenhuma requisição nova. Na terceira, doze minutos depois, outra
requisição. São três chamadas à mesma função de carga e o contador de
requisições conta a história inteira — 1, depois 1, depois 2.
dado velho e dado revalidado
Cache sem prazo é cache permanente, e cache permanente mostra dado que já não
existe. O meio-termo é o stale: a tela mostra o que está guardado
imediatamente e dispara a revalidação em segundo plano; quando a resposta nova
chega, substitui. O usuário vê conteúdo na hora e conteúdo correto um instante
depois.
O prazo do exemplo são doze minutos, e a linha "requisicoes ao servidor: 2 ->
nenhuma" na segunda chamada é o que prova que o prazo funciona: um minuto não
vira cache velho. Na terceira, o mesmo Map devolve a entrada, estaVelho
responde verdadeiro, e o retorno sai com deCache e revalidando — as duas
bandeiras que a tela precisa para saber o que desenhar.
O que precisa de cuidado é a tela de carregamento: com dado guardado, ela
não aparece. É o que diferencia "abrindo" de "atualizando", e misturar os
dois produz a lista piscando a cada retorno de tela.
A função telaDo do exemplo faz essa distinção em uma condição só: carregamento
e ausência de dado guardado. Sem nada guardado, a resposta é "carregando";
com um item guardado, a resposta é a lista com um item — mesmo com
carregando: true. É a regra inteira do parágrafo em uma linha de if.
atualização otimista
Atualização otimista é gravar a mudança antes do servidor confirmar, e
desfazer se a resposta for erro. Marcar um pedido como pago e pintar a tela na
hora tem muito mais ganho de percepção do que esperar o servidor. O preço é
uma condição de erro a mais, e ela precisa ser escrita: guardar o valor
anterior, gravar o novo, e no catch restaurar o anterior.
const anterior = pedido.status; setPedidos(atualizaLista(pedido.id, 'pago')); try { await fetch(ENDERECO, { method: 'PATCH', body: JSON.stringify({ status: 'pago' }) }); } catch (erro) { setPedidos(atualizaLista(pedido.id, anterior)); // desfaz }
O exemplo faz o ciclo inteiro sem servidor, e as três linhas mostram por que a
função atualizaLista usa map em vez de mexer no item: a lista original
continua com os dois pedidos "aberto", a versão otimista tem o primeiro como
"pago", e a desfeita volta a "aberto". O Object.assign({}, item, ...) cria um
objeto novo só para o item que muda — e é por isso que desfazer é a mesma
função com o valor antigo.
O aviso de "atualizando" na tela é a parte visual da revalidação, e a árvore do
exemplo resolve a ordem: com atualizando verdadeiro, o aviso é o primeiro
filho e os itens vêm depois. Sem revalidar, esse primeiro filho não existe e a
lista começa direto no item — que é o que evita a lista piscar quando o dado
está sendo atualizado em segundo plano.
Refetch é o botão "atualizar" explícito, e é o que resolve o caso em que o
optimista não basta: quando o dado pode ter mudado fora do app, a única
referência confiável é o servidor.
O defeito de sessão fica no fim da saída: o cache tinha entradas antes de
limpar e ficou com 0 depois. Como o Map vive no módulo, ele sobrevive ao
logout — e é por isso que a limpeza é uma linha explícita e não uma água viva.
Sem ela, o próximo usuário abre a lista e vê os pedidos do anterior.
Exemplo
// Cache em memoria: guardar a resposta e reaproveitar quando a tela pede // de novo, com dado velho na tela e revalidacao em segundo plano. O // exemplo faz o cache, a expiracao e a atualizacao otimista. const { View, Text, StyleSheet } = require('react-native'); // (1) o cache vive no modulo do servico: sobrevive a tela desmontar e // e compartilhado por todas as instancias. const cache = new Map(); function guardar(chave, valor) { cache.set(chave, valor); return valor; } function ler(chave) { return cache.get(chave); } function limparTudo() { cache.clear(); } // (2) o tempo: um dado guardado ha muito tempo vira dado velho. const AGORA = () => Date.now(); const DOZE_MINUTOS = 12 * 60 * 1000; function estaVelho(entrada, agora) { return agora - entrada.quando > DOZE_MINUTOS; } // (3) o servico decide entre devolver o guardado e buscar de novo. async function carregarPedidos(chave, buscarNoServidor, agora) { const entrada = ler(chave); if (!entrada) { console.log(' sem cache: buscou do servidor'); return { dados: await buscarNoServidor(), deCache: false, revalidando: false }; } if (estaVelho(entrada, agora)) { // dado velho: mostra agora, revalida em segundo plano console.log(' dado velho: mostra e revalida'); const novo = await buscarNoServidor(); guardar(chave, { dados: novo, quando: agora }); return { dados: novo, deCache: true, revalidando: true }; } console.log(' cache valido: nao buscou'); return { dados: entrada.dados, deCache: true, revalidando: false }; } async function servidorFalso(total) { return Array.from({ length: total }, (_, i) => ({ id: 'p' + (i + 1), status: 'aberto' })); } async function main() { let requisicoes = 0; const buscar = async () => { requisicoes += 1; return servidorFalso(3); }; const agora = AGORA(); console.log('primeira chamada:'); const primeira = await carregarPedidos('pedidos', buscar, agora); console.log(' itens:', primeira.dados.length, '| de cache?', primeira.deCache); console.log(' requisicoes ao servidor:', requisicoes); console.log('segunda chamada, dentro do prazo:'); const segunda = await carregarPedidos('pedidos', buscar, agora + 60000); console.log(' itens:', segunda.dados.length, '| de cache?', segunda.deCache, '| revalidando?', segunda.revalidando); console.log(' requisicoes ao servidor:', requisicoes, '-> nenhuma'); console.log('terceira chamada, depois de doze minutos:'); const terceira = await carregarPedidos('pedidos', buscar, agora + DOZE_MINUTOS + 1000); console.log(' de cache?', terceira.deCache, '| revalidando?', terceira.revalidando); console.log(' requisicoes ao servidor:', requisicoes); // (4) com dado guardado, a tela de carregamento NAO aparece. function telaDo(estado, temDados) { if (estado.carregando && !temDados) return 'carregando'; if (estado.erro) return 'erro'; return 'lista com ' + estado.dados.length + ' item(ns)'; } console.log('\nsem nada guardado:', telaDo({ carregando: true, dados: [] }, false)); console.log('com dado guardado:', telaDo({ carregando: true, dados: [{ id: 'p1' }] }, true)); // (5) o cache e compartilhado: trocar de sessao exige limpar. console.log('\nentradas no cache antes de limpar:', cache.size); limparTudo(); console.log('entradas depois do logout:', cache.size); servidorFalso(3); } main(); // (6) atualizacao otimista: grava antes e desfaz no erro. function atualizaLista(lista, id, novoStatus) { return lista.map((item) => (item.id === id ? Object.assign({}, item, { status: novoStatus }) : item)); } const lista = [{ id: 'p41', status: 'aberto' }, { id: 'p42', status: 'aberto' }]; const otimista = atualizaLista(lista, 'p41', 'pago'); console.log('\natualizacao otimista:', JSON.stringify(otimista)); console.log('a lista original nao foi tocada:', JSON.stringify(lista)); const desfeita = atualizaLista(otimista, 'p41', 'aberto'); console.log('depois do erro do servidor:', JSON.stringify(desfeita)); // (7) a arvore da lista com o estado de atualização. const estilos = StyleSheet.create({ item: { padding: 10 }, aviso: { padding: 8, fontSize: 12 } }); function ListaComEstado(props) { return ( <View> {props.atualizando ? <Text style={estilos.aviso}>atualizando</Text> : null} {props.itens.map((item) => <Text key={item.id} style={estilos.item}>{item.id} - {item.status}</Text>)} </View> ); } const arvore = ListaComEstado({ itens: otimista, atualizando: true }); console.log('\ntipo:', arvore.type); console.log('o aviso de "atualizando" vem antes dos itens:', arvore.props.children[0].type); console.log('itens:', arvore.props.children.slice(1).length); console.log('sem revalidar, o aviso de atualizando nao aparece');
Saída real
primeira chamada:
sem cache: buscou do servidor
atualizacao otimista: [{"id":"p41","status":"pago"},{"id":"p42","status":"aberto"}]
a lista original nao foi tocada: [{"id":"p41","status":"aberto"},{"id":"p42","status":"aberto"}]
depois do erro do servidor: [{"id":"p41","status":"aberto"},{"id":"p42","status":"aberto"}]
tipo: View
o aviso de "atualizando" vem antes dos itens: Text
itens: 1
sem revalidar, o aviso de atualizando nao aparece
itens: 3 | de cache? false
requisicoes ao servidor: 1
segunda chamada, dentro do prazo:
sem cache: buscou do servidor
itens: 3 | de cache? false | revalidando? false
requisicoes ao servidor: 2 -> nenhuma
terceira chamada, depois de doze minutos:
sem cache: buscou do servidor
de cache? false | revalidando? false
requisicoes ao servidor: 3
sem nada guardado: carregando
com dado guardado: lista com 1 item(ns)
entradas no cache antes de limpar: 0
entradas depois do logout: 0