React e TypeScript
Busca em React com debounce e AbortController: evite respostas antigas
Implemente uma busca em React e TypeScript com debounce, cancelamento de fetch, validação da resposta e estados de carregamento, erro e lista vazia.
Debounce reduz a frequência das buscas; AbortController cancela a operação anterior; a identificação da consulta evita mostrar resultados de outro termo. Esses recursos resolvem problemas diferentes e podem trabalhar juntos.
Imagine digitar “react” e logo depois “typescript”. Se a primeira requisição terminar por último, uma implementação sem controle pode mostrar resultados de React com TypeScript escrito no campo. Essa inconsistência é uma condição de corrida.
Vamos criar uma busca com espera de 350 milissegundos após a última alteração, cancelamento de requisições e estados explícitos. O exemplo usa um endpoint do seu projeto; o contrato esperado está definido abaixo.
Defina o contrato antes do componente
A rota GET /api/search?q=termo deve devolver status 200 e um objeto neste formato:
{
"items": [
{ "id": "1", "title": "Introdução ao React" },
{ "id": "2", "title": "Formulários acessíveis" }
]
}
Uma busca sem resultados deve retornar uma lista vazia. Falhas do serviço devem usar um status de erro. Esse endpoint não é criado automaticamente pelo componente: você precisa implementá-lo no backend ou adaptar o endereço para sua API.
Componente completo em TypeScript
'use client';
import { useEffect, useId, useState } from 'react';
type Item = { id: string; title: string };
type SearchState = {
query: string;
status: 'idle' | 'loading' | 'success' | 'error';
items: Item[];
};
function isItem(value: unknown): value is Item {
return typeof value === 'object' && value !== null &&
'id' in value && typeof value.id === 'string' &&
'title' in value && typeof value.title === 'string';
}
function parseItems(data: unknown): Item[] {
if (typeof data !== 'object' || data === null || !('items' in data)) {
throw new Error('Resposta inválida');
}
const items: unknown = data.items;
if (!Array.isArray(items) || !items.every(isItem)) {
throw new Error('Lista inválida');
}
return items;
}
export default function Search() {
const id = useId();
const [input, setInput] = useState('');
const query = input.trim();
const [state, setState] = useState<SearchState>({
query: '', status: 'idle', items: [],
});
useEffect(() => {
if (query.length < 2) return;
const controller = new AbortController();
let active = true;
const timer = setTimeout(async () => {
setState({ query, status: 'loading', items: [] });
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error('Falha na busca');
const items = parseItems(await response.json());
if (active) setState({ query, status: 'success', items });
} catch {
if (active && !controller.signal.aborted) {
setState({ query, status: 'error', items: [] });
}
}
}, 350);
return () => {
active = false;
clearTimeout(timer);
controller.abort();
};
}, [query]);
const current = state.query === query;
const items = current && state.status === 'success' ? state.items : [];
let message = 'Digite pelo menos dois caracteres.';
if (query.length >= 2) {
if (!current || state.status === 'loading') message = 'Buscando…';
else if (state.status === 'error') message = 'Não foi possível buscar. Altere o termo para tentar novamente.';
else message = items.length ? `${items.length} resultado(s).` : 'Nenhum resultado encontrado.';
}
return (
<section aria-label="Busca de artigos">
<label htmlFor={id}>Buscar artigos</label>
<input id={id} type="search" value={input}
onChange={event => setInput(event.target.value)}
aria-describedby={`${id}-status`} />
<p id={`${id}-status`} role="status">{message}</p>
{query.length >= 2 && (
<ul>{items.map(item => <li key={item.id}>{item.title}</li>)}</ul>
)}
</section>
);
}
A lista apresenta títulos, não links, porque o contrato não fornece uma URL. Se você adicionar navegação, defina também quais destinos a API pode retornar e como serão validados.
O que acontece a cada alteração?
O efeito depende do termo normalizado. Ao mudar a consulta, o React executa a limpeza anterior antes de iniciar a nova configuração. Isso permite cancelar tanto a espera quanto a operação em andamento. O ciclo está documentado em useEffect.
A flag active impede que uma operação de um efeito encerrado altere o estado. O AbortController sinaliza o cancelamento do fetch e da leitura da resposta; ele não garante que o servidor deixe de processar o pedido. Veja o comportamento na referência de AbortController.
Guardar a consulta junto dos resultados também tem uma função visual. Quando a pessoa modifica o campo, a interface deixa imediatamente de apresentar a lista como se ela pertencesse ao novo termo. A espera do debounce não é um motivo para rotular dados antigos como atuais.
Por que validar o JSON em tempo de execução?
Declarar um tipo TypeScript não transforma a resposta da rede. Uma API pode falhar, mudar seu formato ou devolver um campo inesperado. O parseItems verifica os dados antes de utilizá-los.
Para contratos maiores, um validador de esquema pode reduzir repetição. O importante é manter a distinção: o tipo ajuda durante o desenvolvimento; a verificação protege o limite entre sistemas em execução.
Debounce, throttle e useDeferredValue são a mesma coisa?
| Recurso | Objetivo |
|---|---|
| Debounce | Executar depois de um intervalo sem novas alterações |
| Throttle | Limitar a frequência de execução durante uma sequência de eventos |
| AbortController | Sinalizar o cancelamento de uma operação compatível |
| useDeferredValue | Adiar a atualização de uma parte da interface, sem ser um temporizador de requisições |
- Recurso
- Debounce
- Objetivo
- Executar depois de um intervalo sem novas alterações
- Recurso
- Throttle
- Objetivo
- Limitar a frequência de execução durante uma sequência de eventos
- Recurso
- AbortController
- Objetivo
- Sinalizar o cancelamento de uma operação compatível
- Recurso
- useDeferredValue
- Objetivo
- Adiar a atualização de uma parte da interface, sem ser um temporizador de requisições
Neste fluxo, o debounce é uma escolha porque queremos aguardar uma pausa na digitação. Os 350 milissegundos são um parâmetro inicial, não uma duração ideal para todos os produtos. Considere latência, custo da API e sensação de resposta ao ajustá-lo.
Como testar a condição de corrida
Configure a API de teste para responder lentamente ao termo “react” e rapidamente a “typescript”. Digite o primeiro, espere a requisição começar e então substitua pelo segundo. A lista final deve corresponder a TypeScript.
Teste também estas situações:
- Digitação rápida: só a última pausa deve iniciar uma busca.
- Campo limpo durante uma requisição: a lista deve desaparecer.
- HTTP 500: a interface deve mostrar falha, sem interpretar como lista vazia.
- JSON fora do contrato: deve seguir o mesmo caminho de erro.
- Saída da página: a operação anterior deve ser cancelada.
- Strict Mode no desenvolvimento: configurar, limpar e configurar novamente deve continuar funcionando.
Quando esse exemplo deixa de ser suficiente?
Uma busca de produção pode precisar de cache, paginação, repetição da mesma consulta, histórico na URL e sincronização com outros componentes. Nessa situação, avalie a camada de dados do framework ou uma biblioteca de consultas. Não copie um efeito isolado para vários lugares sem discutir essas necessidades.
Este componente é uma lista de resultados, não um autocomplete. Um autocomplete com seleção pelo teclado exige outros comportamentos, incluindo gerenciamento da opção ativa. Não basta adicionar atributos ARIA para implementar esse padrão.
Perguntas frequentes
AbortController sozinho resolve toda condição de corrida?
Não é prudente depender só dele. O exemplo também limita atualizações ao efeito ativo e verifica a consulta associada aos resultados.
Preciso aplicar debounce em uma lista local pequena?
Nem sempre. Filtrar poucos dados em memória pode responder imediatamente. Adicionar espera nesse cenário pode piorar a sensação de velocidade.
Por que o erro aparece mesmo quando fetch não lançou uma exceção?
O fetch pode resolver normalmente com um status HTTP de erro. Por isso o exemplo verifica response.ok antes de ler e aceitar os dados.
Continue com formulários acessíveis em React para cuidar também das mensagens e da navegação nos campos da interface.

