Programação Por Equipe DevBras

Capa do artigo: Atualizar parte da página com fetch sem deixar leitor de tela e voltar perdidos

Atualizar parte da página com fetch sem deixar leitor de tela e voltar perdidos

Troque só a região que mudou com fetch, anuncie o resultado, cancele requisições velhas, cuide do foco e dê um endereço ao novo estado da lista.

No painel da loja, a gerente escolhe "Pagos" no filtro e a lista de pedidos muda instantaneamente. Parece ótimo. Para o atendente que usa leitor de tela, nada aconteceu: ninguém avisou que a lista mudou. Para quem copia o endereço e manda no grupo, o link abre a lista sem filtro. E quando a conexão está lenta e a gerente troca o filtro duas vezes, às vezes aparece o resultado do primeiro. A resposta curta: busque só o fragmento, troque só a região combinada, anuncie o resultado numa região de status, cancele a requisição anterior com AbortController e atualize o endereço com history.pushState. O formulário continua funcionando sem JavaScript.

O exemplo é exatamente esse painel: uma página /pedidos com filtro por status e uma lista renderizada pelo servidor em PHP.

Por que a troca silenciosa confunde

Quando a página recarrega, o navegador faz um monte de coisas de graça: o leitor de tela anuncia a página nova, o foco volta para o início, o endereço muda e o botão voltar desfaz o passo. Quando você troca um pedaço do DOM por script, nada disso acontece. Você ganhou velocidade e perdeu todas as pistas.

A corrida entre requisições é o problema menos óbvio. A gerente escolhe "Pagos" e depois "Cancelados". A primeira requisição é lenta, a segunda é rápida. A resposta de "Cancelados" chega, a lista é trocada, e um segundo depois chega "Pagos" e sobrescreve. A tela mostra pedidos pagos com o filtro em "Cancelados".

Como montar a atualização parcial

  1. Comece com um formulário GET normal, que funciona sem JavaScript e já gera um endereço com o filtro.
  2. Crie um endereço separado que devolve só o fragmento da lista, usando o mesmo template da página completa.
  3. No envio do formulário, cancele a requisição anterior e faça o fetch com um novo AbortController.
  4. Marque a região com aria-busy enquanto carrega e troque só ela quando a resposta chegar.
  5. Anuncie o resultado ("12 pedidos encontrados") numa região com role="status".
  6. Atualize o endereço com pushState e trate popstate para o botão voltar.
  7. Em caso de erro, mostre a mensagem na mesma região de status e mantenha a lista anterior.

O HTML e o servidor

<form id="filtro" action="/pedidos" method="get">
  <label for="status">Status</label>
  <select id="status" name="status">
    <option value="todos">Todos</option>
    <option value="pago">Pagos</option>
    <option value="cancelado">Cancelados</option>
  </select>
  <button type="submit">Filtrar</button>
</form>

<p id="aviso" role="status"></p>

<section aria-labelledby="titulo-lista">
  <h2 id="titulo-lista" tabindex="-1">Pedidos</h2>
  <div id="lista">
    <?php include __DIR__ . '/partes/lista-pedidos.php'; ?>
  </div>
</section>
<?php
// pedidos-lista.php: devolve só o fragmento
require __DIR__ . '/conexao.php';
require __DIR__ . '/pedidos-consulta.php';

$status = (string) ($_GET['status'] ?? 'todos');
$pedidos = buscarPedidos($pdo, $status); // consulta com parâmetro, nunca concatenada
header('Content-Type: text/html; charset=utf-8');
include __DIR__ . '/partes/lista-pedidos.php';

A página completa e o fragmento usam o mesmo partes/lista-pedidos.php, com cada valor escapado por htmlspecialchars. Assim, a lista que chega por script é idêntica à que chega no carregamento normal, e ninguém mantém dois templates. Usar um endereço diferente para o fragmento também evita um bug clássico: quando a mesma URL devolve página inteira ou pedaço dependendo de um cabeçalho, o cache do navegador pode mostrar o pedaço solto ao apertar voltar.

O script

const form = document.getElementById('filtro');
const lista = document.getElementById('lista');
const aviso = document.getElementById('aviso');
const titulo = document.getElementById('titulo-lista');
let controle = null;

async function carregar(params, opcoes) {
  if (controle) controle.abort();
  const meu = new AbortController();
  controle = meu;

  lista.setAttribute('aria-busy', 'true');
  aviso.textContent = 'Carregando pedidos...';
  try {
    const resp = await fetch('/pedidos-lista.php?' + params.toString(), { signal: meu.signal });
    if (!resp.ok) throw new Error('HTTP ' + resp.status);
    lista.innerHTML = await resp.text();

    const total = lista.querySelectorAll('li').length;
    aviso.textContent = total + (total === 1 ? ' pedido encontrado.' : ' pedidos encontrados.');
    if (opcoes.empilhar) history.pushState(null, '', '/pedidos?' + params.toString());
    if (opcoes.focarTitulo) titulo.focus();
  } catch (erro) {
    if (erro.name === 'AbortError') return;
    aviso.textContent = 'Não foi possível carregar os pedidos. Tente de novo.';
  } finally {
    if (controle === meu) lista.removeAttribute('aria-busy');
  }
}

form.addEventListener('submit', (e) => {
  e.preventDefault();
  carregar(new URLSearchParams(new FormData(form)), { empilhar: true, focarTitulo: false });
});

window.addEventListener('popstate', () => {
  const params = new URLSearchParams(location.search);
  document.getElementById('status').value = params.get('status') || 'todos';
  carregar(params, { empilhar: false, focarTitulo: true });
});

Cada chamada cria seu próprio controlador e cancela o anterior. A resposta velha nunca sobrescreve a nova, porque o fetch abortado rejeita com AbortError e o código simplesmente sai. A checagem controle === meu no finally evita que uma requisição cancelada tire o aria-busy da que ainda está em andamento.

Para onde vai o foco

Depois de filtrar, o foco fica no formulário: a pessoa provavelmente vai ajustar o filtro de novo, e o role="status" já anunciou o total. Quando o botão que disparou a troca desaparece, como numa paginação em que "próxima página" é substituído, ou quando o estado volta pelo histórico, o foco vai para o título da região, que tem tabindex="-1" para poder recebê-lo por script. Deixar o foco num elemento que sumiu joga a pessoa de volta para o topo do documento. Mais sobre isso em foco no campo certo.

Quando recarregar a página é melhor

Atualização parcial serve para filtrar, ordenar, paginar e buscar sugestões, ações que só leem dados. Para pagar, excluir ou confirmar um pedido, a navegação completa com resposta do servidor costuma ser mais segura: o resultado tem endereço próprio, recarregar não reenvia a ação sem aviso e o histórico fica coerente. O detalhamento do histórico está em trocar o miolo da página e o botão voltar funcionar, e o problema de substituir o HTML inteiro está em AJAX que substitui o HTML inteiro.

Erros comuns

  • Trocar a região sem anunciar. Quem usa leitor de tela não sabe que a lista mudou nem quantos itens vieram.
  • Não cancelar a requisição anterior. A resposta lenta chega depois da rápida e a tela mostra um resultado que não corresponde ao filtro.
  • Não atualizar o endereço. O link compartilhado abre o estado inicial e o botão voltar sai da página em vez de desfazer o filtro.
  • Buscar a página inteira e recortar no cliente. O servidor gera menus, rodapé e scripts à toa, e o recorte quebra na primeira mudança de layout.
  • Inserir dado de usuário com innerHTML no cliente. Um nome de cliente com <script> vira XSS; só use innerHTML com HTML escapado pelo servidor.
  • Apagar a lista quando a requisição falha. A pessoa perde o que estava vendo e recebe uma área vazia sem explicação.

Checklist

  • O formulário funciona com JavaScript desligado.
  • O fragmento vem de um endereço próprio e usa o mesmo template da página.
  • Cada nova requisição cancela a anterior com AbortController.
  • O total ou o erro é anunciado numa região role="status".
  • O endereço muda com o filtro, e voltar desfaz o filtro.
  • O foco nunca fica num elemento que foi removido.
  • Ações que gravam dados usam navegação completa.

Abra a tela com filtro mais usada do seu sistema, aplique um filtro e copie o endereço numa aba anônima. A lista aparece filtrada? E, com o leitor de tela ligado, alguém ficaria sabendo que a lista mudou?

Avalie este artigo

Referência: https://webvisao.wordpress.com/2012/06/17/tutorial-como-fazer-uma-div-se-atualizar-sem-refresh-ou-reload-da-pagina/