Painel React com TypeScript e Vite: projeto completo
Crie um painel de pedidos com React 19, TypeScript e Vite, incluindo formulário, filtros, fetch, localStorage, acessibilidade e testes executados.
Você vai construir o Passa Prato, um painel React com TypeScript e Vite que cadastra pedidos, filtra a fila, avança o preparo, busca dados e continua útil quando a API demora, falha ou devolve uma lista vazia. No final, seis testes automatizados e o build de produção comprovam o comportamento.
Não é uma coleção de componentes soltos. É uma pequena aplicação com decisões
que aparecem no trabalho: estado imutável, formulário controlado, validação,
fetch, persistência local, acessibilidade e testes orientados pelo que a pessoa
faz na tela. O código completo está em blog/examples/painel-react/ e foi
executado com Node 24.16.0.
Se você nunca abriu um projeto React, faça primeiro a lição de criar um projeto React com Vite. Aqui a gente parte do princípio de que você já reconhece um componente e sabe rodar um comando no terminal.
O painel funciona como o trilho de comandas de uma cozinha
Imagine a janela entre o salão e a cozinha de um restaurante. Existe um trilho com todas as comandas; cada estação enxerga a parte de que precisa; quando alguém muda um pedido, a comanda central recebe a atualização. Esse é o nosso modelo mental para o React:
- o array
pedidosé o trilho central; - cada componente é uma estação com uma responsabilidade;
- props são as informações entregues de uma estação para outra;
- callbacks como
aoAdicionarsão recados que voltam para quem controla o trilho; - o
fetché a entrega inicial de comandas pela API; - o
localStorageé uma gaveta local que preserva o turno neste navegador.
A analogia ajuda a separar responsabilidades, mas tem um limite importante. O React não transporta papéis físicos: ele compara valores na memória e renderiza a interface outra vez quando o estado muda. É por isso que a gente cria um novo array em vez de riscar o array anterior por baixo dos panos.
O fluxo técnico fica assim:
pedidos.json ou localStorage
↓
usePedidos
↙ ↓ ↘
formulário resumo filtros → lista
↘ ↙
callbacks de atualizaçãoEssa arquitetura tem uma fonte de verdade: o hook usePedidos. Busca e
filtro vivem no App, porque são controles da tela. A lista filtrada é derivada
do array original e nunca vira uma segunda cópia de estado. Essa diferença evita
que duas versões da mesma fila se desencontrem.
Prepare o projeto com versões que foram testadas
Crie uma pasta vazia. Se você estiver acompanhando dentro deste repositório, o
exemplo já está pronto em blog/examples/painel-react; os comandos abaixo
mostram como reproduzir a preparação em outro lugar.
mkdir painel-react
cd painel-react
npm init -yO package.json usa versões exatas para que seu laboratório não mude sozinho
entre hoje e a próxima execução. dependencies entram no aplicativo;
devDependencies ajudam a desenvolver, checar e testar.
{
"name": "painel-react-devclub",
"private": true,
"type": "module",
"engines": { "node": "24.x" },
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"check": "tsc -b --pretty false",
"test": "vitest run"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.6",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.1.0",
"jsdom": "30.0.1",
"typescript": "7.0.2",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}O exemplo completo já traz package-lock.json; nele, instale exatamente o que
foi registrado:
npm ciSe você estiver recriando apenas o package.json numa pasta nova, rode
npm install uma vez para gerar o lockfile e use npm ci nas próximas
instalações. A saída abaixo veio do lockfile que acompanha este tutorial.
26 packages are looking for funding found 0 vulnerabilities
O projeto completo traz tsconfig.json, tsconfig.app.json e
tsconfig.node.json. A parte mais importante para a interface é o modo
strict: ele faz o TypeScript apontar valores que talvez não existam antes de
esses valores chegarem ao navegador.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}O mesmo vite.config.ts liga o plugin do React e prepara o Vitest para simular
um navegador com jsdom:
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
clearMocks: true,
restoreMocks: true,
},
});Antes de seguir, crie também a referência de tipos que ensina ao TypeScript como o Vite trata CSS, variáveis de ambiente e arquivos do cliente:
/// <reference types="vite/client" />Ela fica em src/vite-env.d.ts. Na primeira checagem real, esse arquivo estava
faltando e o TypeScript 7 recusou o import do CSS:
O erro não dizia que o CSS estava ausente. Ele dizia que o sistema de tipos não sabia interpretar aquele import com efeito colateral. Adicionar a referência do Vite resolveu a causa; desativar a checagem esconderia o sinal sem ensinar a ferramenta sobre o arquivo.
Modele pedidos e estados impossíveis antes da tela
Um tipo é um contrato verificável. Em vez de deixar qualquer texto representar
uma etapa, limitamos status a três valores conhecidos. E, em vez de combinar
carregando, erro e pedidos em variáveis que poderiam se contradizer,
criamos uma união discriminada: o campo status identifica uma situação por
vez.
export const statusPedido = ['novo', 'preparo', 'pronto'] as const;
export type StatusPedido = (typeof statusPedido)[number];
export type FiltroStatus = 'todos' | StatusPedido;
export type Pedido = {
id: string;
cliente: string;
item: string;
quantidade: number;
status: StatusPedido;
criadoEm: string;
};
export type NovoPedido = Pick<Pedido, 'cliente' | 'item' | 'quantidade'>;
export type EstadoPedidos =
| { status: 'loading' }
| { status: 'error'; mensagem: string }
| { status: 'success'; pedidos: Pedido[] };Leia o último tipo como três portas: se você entrou pela porta loading, ainda
não há array; pela error, existe uma mensagem; pela success, existem pedidos,
mesmo que o array esteja vazio. Isso impede, por exemplo, a tela de prometer que
está carregando enquanto mostra um erro antigo.
Microprática: force um erro de tipo útil
Troque temporariamente status: 'novo' por status: 'entregue' ao criar um
pedido e rode:
npm run checkO critério de sucesso desta prática é a checagem recusar entregue. Desfaça a
mudança e confirme que o comando termina sem diagnóstico. Você acabou de provar
que o contrato protege o fluxo, não apenas decorar o nome “union type”.
Busque dados sem confiar cegamente na resposta
O arquivo public/pedidos.json simula a primeira resposta da API. Ele é estático
para o tutorial ser reproduzível, mas passa pelo mesmo fetch usado numa URL
real.
[
{
"id": "pedido-1042",
"cliente": "Ana Silva",
"item": "Bowl da Horta",
"quantidade": 2,
"status": "novo",
"criadoEm": "2026-08-22T18:05:00.000Z"
},
{
"id": "pedido-1043",
"cliente": "Bruno Lima",
"item": "Smash da Casa",
"quantidade": 1,
"status": "preparo",
"criadoEm": "2026-08-22T18:12:00.000Z"
}
]TypeScript checa nosso código durante o desenvolvimento; ele não controla o que
um servidor manda pela rede. Por isso a resposta começa como unknown e passa
por uma função de validação em tempo de execução. A explicação detalhada dos
quatro estados está na lição de consumir API no React.
const ehObjeto = (valor: unknown): valor is Record<string, unknown> =>
typeof valor === 'object' && valor !== null;
export function ehPedido(valor: unknown): valor is Pedido {
if (!ehObjeto(valor)) return false;
return (
typeof valor.id === 'string' &&
typeof valor.cliente === 'string' &&
typeof valor.item === 'string' &&
typeof valor.quantidade === 'number' &&
Number.isInteger(valor.quantidade) &&
valor.quantidade > 0 &&
statusPedido.includes(valor.status as StatusPedido) &&
typeof valor.criadoEm === 'string' &&
!Number.isNaN(Date.parse(valor.criadoEm))
);
}Agora fazemos a requisição, distinguimos falha de rede de resposta HTTP ruim e
validamos o JSON. fetch não lança erro só porque recebeu 404 ou 503; é nossa
responsabilidade conferir resposta.ok.
export async function buscarPedidos(signal?: AbortSignal): Promise<Pedido[]> {
let resposta: Response;
try {
resposta = await fetch(`${import.meta.env.BASE_URL}pedidos.json`, { signal });
} catch (erro) {
if (erro instanceof DOMException && erro.name === 'AbortError') throw erro;
throw new Error(
'Não foi possível acessar a lista. Verifique sua conexão e tente novamente.',
);
}
if (!resposta.ok) {
throw new Error(
`Não foi possível carregar os pedidos (HTTP ${resposta.status}).`,
);
}
const dados: unknown = await resposta.json();
if (!Array.isArray(dados) || !dados.every(ehPedido)) {
throw new Error('A API retornou pedidos em um formato inválido.');
}
return dados;
}import.meta.env.BASE_URL mantém o caminho válido se o app for publicado numa
subpasta. O AbortSignal cancela a requisição quando o componente sai da tela;
assim uma resposta atrasada não tenta atualizar um componente que já foi embora.
Use um hook como coordenador da fila
Um custom hook reúne estado e efeitos relacionados. Em linguagem simples,
usePedidos é o coordenador que conhece a fonte inicial e as regras de mudança;
os componentes só pedem ações. O primeiro efeito prefere uma cópia local válida
e, se ela não existir, consulta a API.
export function usePedidos() {
const [estado, setEstado] = useState<EstadoPedidos>({ status: 'loading' });
const carregarDaApi = useCallback(async (signal?: AbortSignal) => {
setEstado({ status: 'loading' });
try {
const pedidos = await buscarPedidos(signal);
setEstado({ status: 'success', pedidos });
} catch (erro) {
if (erro instanceof DOMException && erro.name === 'AbortError') return;
setEstado({
status: 'error',
mensagem:
erro instanceof Error
? erro.message
: 'Não foi possível carregar os pedidos.',
});
}
}, []);
useEffect(() => {
const salvos = lerPedidosSalvos();
if (salvos) {
setEstado({ status: 'success', pedidos: salvos });
return;
}
const controller = new AbortController();
void carregarDaApi(controller.signal);
return () => controller.abort();
}, [carregarDaApi]);Persistência local com versão e saída de emergência
localStorage só armazena texto, é síncrono e pertence a este navegador. A
chave recebe v1 para deixar explícito o formato salvo. Na leitura, um JSON
corrompido ou incompatível é descartado; na escrita, falta de permissão ou quota
cheia não derruba a interface.
export const CHAVE_PEDIDOS = 'passa-prato:pedidos:v1';
export function lerPedidosSalvos(): Pedido[] | null {
try {
const texto = window.localStorage.getItem(CHAVE_PEDIDOS);
if (!texto) return null;
const dados: unknown = JSON.parse(texto);
if (!Array.isArray(dados) || !dados.every(ehPedido)) {
limparPedidosSalvos();
return null;
}
return dados;
} catch {
limparPedidosSalvos();
return null;
}
}
export function salvarPedidos(pedidos: Pedido[]): boolean {
try {
window.localStorage.setItem(CHAVE_PEDIDOS, JSON.stringify(pedidos));
return true;
} catch {
return false;
}
}
export function limparPedidosSalvos() {
try {
window.localStorage.removeItem(CHAVE_PEDIDOS);
} catch {
// A interface continua funcionando mesmo sem acesso ao storage.
}
}O segundo efeito salva somente o estado de sucesso:
useEffect(() => {
if (estado.status === 'success') salvarPedidos(estado.pedidos);
}, [estado]);Essa escolha é adequada para um laboratório de uma pessoa. Não use a mesma arquitetura para uma cozinha com vários tablets: cada navegador teria sua própria gaveta e ninguém receberia atualizações dos demais. Nesse caso, a fonte de verdade precisa ser um servidor com banco de dados. A lição de localStorage aprofunda limites e segurança.
Atualize o estado sem alterar a comanda anterior
Imutabilidade significa criar a próxima versão do valor sem modificar a
versão atual. Para adicionar, criamos um objeto com id estável e um novo array.
Para avançar, map cria outro array e troca apenas o objeto correspondente.
const adicionarPedido = useCallback((entrada: NovoPedido) => {
const pedido = {
...entrada,
id: globalThis.crypto?.randomUUID?.() ?? `pedido-${Date.now()}`,
status: 'novo' as const,
criadoEm: new Date().toISOString(),
};
setEstado((atual) =>
atual.status === 'success'
? { ...atual, pedidos: [pedido, ...atual.pedidos] }
: atual,
);
}, []);const avancarPedido = useCallback((id: string) => {
setEstado((atual) => {
if (atual.status !== 'success') return atual;
return {
...atual,
pedidos: atual.pedidos.map((pedido) =>
pedido.id === id
? { ...pedido, status: avancarStatus(pedido.status) }
: pedido,
),
};
});
}, []);O formato setEstado((atual) => ...) usa o estado mais recente entregue pelo
React. Isso importa quando duas ações acontecem próximas. A versão abaixo é uma
armadilha porque altera o objeto que já estava no array:
// Não faça: o objeto anterior é modificado no lugar.
const pedido = pedidos.find((item) => item.id === id);
if (pedido) pedido.status = 'preparo';
setPedidos(pedidos);Na versão correta, tanto o array quanto o pedido alterado ganham novas referências. Você pode explorar o raciocínio na lição de estado imutável no React.
Faça um formulário controlado que explica o erro
Um formulário controlado mantém o valor de cada campo no estado do React.
value mostra o valor atual e onChange registra a próxima digitação. Isso nos
deixa validar, limpar e enviar os três campos de maneira previsível.
type CamposFormulario = {
cliente: string;
item: string;
quantidade: string;
};
type ErrosFormulario = Partial<Record<keyof CamposFormulario, string>>;
const camposIniciais: CamposFormulario = {
cliente: '',
item: '',
quantidade: '1',
};
function validar(campos: CamposFormulario): ErrosFormulario {
const erros: ErrosFormulario = {};
const quantidade = Number(campos.quantidade);
if (campos.cliente.trim().length < 2) {
erros.cliente = 'Informe o nome do cliente com pelo menos 2 caracteres.';
}
if (campos.item.trim().length < 3) {
erros.item = 'Descreva o item com pelo menos 3 caracteres.';
}
if (!Number.isInteger(quantidade) || quantidade < 1 || quantidade > 20) {
erros.quantidade = 'Use uma quantidade inteira entre 1 e 20.';
}
return erros;
}Quantidade fica como string enquanto a pessoa digita porque um <input> sempre
entrega texto, inclusive quando type="number". A conversão ocorre na fronteira
da validação. Isso permite representar temporariamente o campo vazio sem mentir
para o tipo.
O componente recebe uma prop com a ação permitida. Ele não conhece o array de pedidos e não salva nada diretamente:
type FormularioPedidoProps = {
aoAdicionar: (pedido: NovoPedido) => void;
};
export function FormularioPedido({ aoAdicionar }: FormularioPedidoProps) {
const [campos, setCampos] = useState(camposIniciais);
const [erros, setErros] = useState<ErrosFormulario>({});
function atualizarCampo(campo: keyof CamposFormulario, valor: string) {
setCampos((atuais) => ({ ...atuais, [campo]: valor }));
setErros((atuais) => {
const proximos = { ...atuais };
delete proximos[campo];
return proximos;
});
}
return (
<input
id="cliente"
value={campos.cliente}
onChange={(evento) => atualizarCampo('cliente', evento.target.value)}
aria-invalid={Boolean(erros.cliente)}
aria-describedby={erros.cliente ? 'erro-cliente' : undefined}
/>
);
}Aqui FormularioPedidoProps é o contrato entre componentes. Quem usa o
formulário precisa entregar aoAdicionar com o formato correto. Esse fluxo é
explicado com mais calma em props no React e em
formulário controlado.
Validação também precisa funcionar com teclado e leitor de tela
Quando o envio falha, a tela mostra um resumo com role="alert", move o foco
para ele e conecta cada input à mensagem específica. Depois de corrigir um
campo, apagamos a chave de erro — não deixamos uma propriedade undefined que
parece ausente na tela, mas ainda existe no objeto.
const [enviosInvalidos, setEnviosInvalidos] = useState(0);
const resumoErrosRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (enviosInvalidos > 0) resumoErrosRef.current?.focus();
}, [enviosInvalidos]);
function enviar(evento: FormEvent<HTMLFormElement>) {
evento.preventDefault();
const proximosErros = validar(campos);
if (Object.keys(proximosErros).length > 0) {
setErros(proximosErros);
setEnviosInvalidos((total) => total + 1);
return;
}
aoAdicionar({
cliente: campos.cliente.trim(),
item: campos.item.trim(),
quantidade: Number(campos.quantidade),
});
}Na implementação inicial, o resumo recebia foco a cada caractere digitado após
um envio inválido. O teste que simulava uma pessoa escrevendo encontrou o
problema: o foco saía do input e só a primeira letra entrava. O contador
enviosInvalidos limita o foco ao evento que realmente importa, um novo envio
recusado. Essa correção melhora acessibilidade e interação ao mesmo tempo.
Filtro é um resultado calculado, não outra fonte de verdade
Busca e etapa são estados porque a pessoa pode mudá-los. Já
pedidosFiltrados é calculado a partir desses estados e dos pedidos; guardá-lo
em outro useState criaria uma cópia que precisaria ser sincronizada.
const [busca, setBusca] = useState('');
const [filtroStatus, setFiltroStatus] =
useState<FiltroStatus>('todos');
const termo = normalizar(busca.trim());
const pedidosFiltrados = estado.pedidos.filter((pedido) => {
const correspondeAoTexto = normalizar(
`${pedido.cliente} ${pedido.item}`,
).includes(termo);
const correspondeAoStatus =
filtroStatus === 'todos' || pedido.status === filtroStatus;
return correspondeAoTexto && correspondeAoStatus;
});Normalizar acentos faz pao encontrar “Pão de queijo”. O filtro não remove nada
do array original; ele produz apenas a visão atual. Ao limpar os controles, a
fila inteira volta sem nova requisição.
Renderize a lista com uma identidade estável
O map transforma cada pedido num PedidoCard. A prop especial key ajuda o
React a reconhecer a mesma comanda na próxima renderização. Usamos pedido.id,
não a posição, porque novos pedidos entram no começo e filtros mudam a ordem
visível.
<ol className="lista-pedidos">
{pedidos.map((pedido) => (
<PedidoCard
key={pedido.id}
pedido={pedido}
aoAvancar={aoAvancar}
/>
))}
</ol>O botão descreve ação e cliente para quem usa tecnologia assistiva. A etapa também aparece em texto, portanto a cor não é a única pista:
<button
className="botao botao--acao"
type="button"
onClick={() => aoAvancar(pedido.id)}
disabled={pedido.status === 'pronto'}
aria-label={`${rotulosAcao[pedido.status]}: ${pedido.cliente}`}
>
{rotulosAcao[pedido.status]}
</button>Se a lista visível está vazia, a mensagem depende da causa. Zero pedidos na fonte pede a primeira comanda; zero resultados de filtro oferece limpar filtros. Essa diferença evita o clássico “nenhum dado” quando, na verdade, os dados estão escondidos.
if (pedidos.length === 0) {
const semPedidos = totalSemFiltro === 0;
return (
<div className="estado-vazio">
<h3>
{semPedidos ? 'Nenhum pedido por aqui' : 'Nenhum pedido encontrado'}
</h3>
<p>
{semPedidos
? 'Adicione a primeira comanda para abrir a fila.'
: 'Mude a busca ou limpe os filtros para ver a fila completa.'}
</p>
</div>
);
}A lição de listas e key no React mostra outros casos em que usar o índice produz identidade errada.
Mostre loading, error, empty e success sem sobreposição
Agora a união EstadoPedidos volta para pagar seu investimento. App faz um
retorno antecipado para loading, outro para error e só então usa
estado.pedidos. O TypeScript entende que, depois dos dois retornos, estamos no
estado success.
if (estado.status === 'loading') {
return (
<main id="conteudo-principal">
<CarregandoPedidos />
</main>
);
}
if (estado.status === 'error') {
return (
<main id="conteudo-principal">
<ErroPedidos
mensagem={estado.mensagem}
aoTentarNovamente={recarregar}
/>
</main>
);
}
// A partir daqui, estado.status é "success".
const pedidos = estado.pedidos;CarregandoPedidos usa role="status"; a falha usa role="alert" e mantém um
botão de nova tentativa. O estado vazio mora dentro do sucesso, porque uma API
pode responder corretamente com []. O quarto estado, sucesso com conteúdo,
monta formulário, resumo, filtros e lista:
<main className="painel" id="conteudo-principal">
<FormularioPedido aoAdicionar={adicionarPedido} />
<section className="operacao" aria-labelledby="titulo-fila">
<h2 id="titulo-fila">Fila de pedidos</h2>
<ResumoPedidos pedidos={estado.pedidos} />
<FiltrosPedidos
busca={busca}
status={filtroStatus}
aoMudarBusca={setBusca}
aoMudarStatus={setFiltroStatus}
aoLimpar={limparFiltros}
/>
<ListaPedidos
pedidos={pedidosFiltrados}
totalSemFiltro={estado.pedidos.length}
aoAvancar={avancarPedido}
aoLimparFiltros={limparFiltros}
/>
</section>
</main>Observe as props como um mapa de responsabilidades. ResumoPedidos pode ler o
array, mas não alterá-lo. ListaPedidos recebe apenas a ação de avançar. O
formulário recebe apenas a ação de adicionar. Essa separação deixa cada peça
menor para entender e testar.
Dê ao painel uma linguagem visual de operação
O CSS usa papel frio, azul-carbono, tomate e menta. Não é decoração aleatória: o cabeçalho escuro marca o turno, o painel claro lembra comandas e uma faixa superior diferencia as etapas. Variáveis mantêm a linguagem consistente.
:root {
color: #14223a;
background: #e8edf1;
font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif;
--tinta: #14223a;
--azul-carbono: #17345f;
--azul-acao: #2f5aff;
--papel: #fbfcf8;
--fundo: #e8edf1;
--tomate: #c9402c;
--ambar: #a84d0e;
--verde: #12643f;
}Cada card funciona como uma comanda presa no trilho. A etapa muda a barra da borda, enquanto o texto continua dizendo “Na fila”, “Em preparo” ou “Pronto”.
.lista-pedidos {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin: 0;
padding: 1.2rem 0 0;
list-style: none;
}
.comanda {
position: relative;
display: grid;
border: 1px solid #bcc6ce;
border-top: 0.35rem solid var(--azul-acao);
background: var(--papel);
}
.comanda--preparo { border-top-color: var(--ambar); }
.comanda--pronto { border-top-color: var(--verde); }A tela larga usa formulário fixo à esquerda e operação à direita. Em telas
menores, vira uma única coluna. A consulta prefers-reduced-motion remove
transições para quem pediu menos movimento no sistema.
@media (max-width: 850px) {
.painel {
grid-template-columns: 1fr;
}
.cadastro { position: static; }
.lista-pedidos { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
.painel { width: min(100% - 2rem, 36rem); }
.resumo, .filtros { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}Acessibilidade também inclui <label> ligado por htmlFor, link “Pular para o
conteúdo”, foco visível, HTML semântico, mensagens com região viva e botões com
nome completo. O teste automatizado não substitui uma navegação manual: rode o
app, pressione Tab desde o topo e confirme que você alcança formulário,
filtros e ações sem mouse.
Teste o que a pessoa percebe, não detalhes internos
O Vitest executa os testes; jsdom fornece window, DOM e localStorage; Testing
Library procura elementos por papel, rótulo e texto. Essa combinação aproxima o
teste da forma como a interface é usada.
O arquivo de preparação adiciona matchers como toBeInTheDocument e limpa a
tela depois de cada teste:
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);O primeiro teste segura a Promise para enxergar loading e só depois libera a resposta. Assim ele prova uma transição, não apenas a tela final.
it('mostra loading e depois renderiza a resposta da API', async () => {
let concluir!: (resposta: Response) => void;
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockReturnValue(
new Promise<Response>((resolve) => {
concluir = resolve;
}),
),
);
render(<App />);
expect(screen.getByRole('status')).toHaveTextContent('Carregando pedidos');
await act(async () => concluir(respostaJson(pedidos)));
expect(await screen.findByText('Ana Silva')).toBeInTheDocument();
});Para erro, a API simulada responde 503. O teste espera mensagem e uma ação de recuperação, não uma variável privada do componente.
it('mostra um erro acionável quando a API responde 503', async () => {
simularApi({ mensagem: 'indisponível' }, 503);
render(<App />);
const alerta = await screen.findByRole('alert');
expect(alerta).toHaveTextContent('Os pedidos não chegaram');
expect(alerta).toHaveTextContent('HTTP 503');
expect(
within(alerta).getByRole('button', { name: 'Tentar novamente' }),
).toBeEnabled();
});O formulário é testado como uma sequência real: enviar vazio, ler o erro, digitar, enviar, ver a comanda e conferir a persistência.
await usuario.click(screen.getByRole('button', { name: 'Adicionar à fila' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Revise os campos destacados',
);
await usuario.type(screen.getByLabelText('Nome do cliente'), 'Diego Alves');
await usuario.type(screen.getByLabelText('Item do pedido'), 'Tapioca da feira');
await usuario.clear(screen.getByLabelText('Quantidade'));
await usuario.type(screen.getByLabelText('Quantidade'), '3');
await usuario.click(screen.getByRole('button', { name: 'Adicionar à fila' }));
expect(await screen.findByText('Diego Alves')).toBeInTheDocument();
await waitFor(() => {
const salvos = JSON.parse(
window.localStorage.getItem(CHAVE_PEDIDOS) ?? '[]',
) as Pedido[];
expect(salvos[0]).toMatchObject({ quantidade: 3, status: 'novo' });
});A suíte completa também cobre lista vazia, filtro com avanço imutável e JSON local corrompido. Rode tudo com o Node declarado no projeto:
npm run check
npm test
npm run buildA checagem TypeScript terminou sem diagnóstico. Esta foi a saída real dos testes:
Test Files 1 passed (1) Tests 6 passed (6) Duration 671ms
E este foi o build final, depois das correções:
Esses números pertencem a esta execução num diretório temporário. O hash do
arquivo muda quando o conteúdo muda, e o tempo varia entre máquinas; o sinal
que você precisa procurar é 6 passed, checagem sem erro e build concluído.
Rode o painel e cumpra uma missão verificável
Inicie o servidor:
npm run devAbra a URL mostrada pelo Vite. Antes de mudar o código, faça este roteiro:
- espere as três comandas aparecerem;
- busque
paoe confirme que encontra “Pão de queijo da casa”; - limpe o filtro e avance Ana de “Na fila” para “Em preparo”;
- cadastre
Diego Alves,Tapioca da feira, quantidade3; - recarregue a página e confirme que Diego continua na fila;
- navegue por
Tabe confirme que todas as ações têm foco visível.
Sua missão é acrescentar a etapa entregue depois de pronto. Atualize o tipo,
o avanço de status, os rótulos, a opção de filtro e o estilo. Depois acrescente
um teste que avance uma comanda pronta e espere o texto “Entregue”. O trabalho
só terminou quando npm run check, npm test e npm run build passam, e quando
o filtro “Entregues” mostra apenas as comandas dessa etapa.
Ao fazer essa alteração, você percorre o sistema inteiro: contrato de dados, estado imutável, props, lista, visual e teste. Esse é o próximo passo mais útil porque transforma o painel sem trocar sua arquitetura. Para ampliar depois, volte ao guia completo de React e substitua o JSON estático por uma API com banco, autenticação e sincronização entre usuários.
Perguntas frequentes
Preciso dominar TypeScript antes de fazer este painel React?
Por que usar Vite em um projeto React com TypeScript?
localStorage serve para um painel de pedidos em produção?
Como o painel trata loading, erro, vazio e sucesso do fetch?
Por que não usar o índice do array como key dos pedidos?
O que os testes automatizados deste projeto verificam?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 · npm 11.13.0 · React 19.2.8 · TypeScript 7.0.2 · Vite 8.2.2 · Vitest 4.1.11, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- React — Passing Props to a Component — react.dev
- React — Updating Arrays in State — react.dev
- React — input — react.dev
- React — useEffect — react.dev
- Vite — Getting Started — vite.dev
- Vitest — Getting Started — vitest.dev
- Testing Library — React Testing Library — testing-library.com
- MDN — Using the Fetch API — developer.mozilla.org


