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
- Comece com um formulário
GETnormal, que funciona sem JavaScript e já gera um endereço com o filtro. - Crie um endereço separado que devolve só o fragmento da lista, usando o mesmo template da página completa.
- No envio do formulário, cancele a requisição anterior e faça o
fetchcom um novoAbortController. - Marque a região com
aria-busyenquanto carrega e troque só ela quando a resposta chegar. - Anuncie o resultado ("12 pedidos encontrados") numa região com
role="status". - Atualize o endereço com
pushStatee tratepopstatepara o botão voltar. - 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
innerHTMLno cliente. Um nome de cliente com<script>vira XSS; só useinnerHTMLcom 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?