Dia 12 — Listas com dado remoto: paginação e cache

Informatica · Conteudo · publicado em 05/10/2026
Dia 12 de 16

Listas com dado remoto: paginação e cache

Aula 1

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
Aula 2

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