Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
TutorialIntermediáriocódigo testado

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.

Rodolfo Mori12 min de leitura

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 aoAdicionar sã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:

text
pedidos.json ou localStorage

        usePedidos
     ↙      ↓       ↘
formulário  resumo   filtros → lista
     ↘                ↙
       callbacks de atualização

Essa 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.

bash
mkdir painel-react
cd painel-react
npm init -y

O 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.

json
{
  "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:

bash
npm ci

Se 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.

added 117 packages, and audited 118 packages in 926ms

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.

json
{
  "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:

ts
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:

ts
/// <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:

src/main.tsx(4,8): error TS2882: Cannot find module or type declarations for side-effect import of './index.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.

ts
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:

bash
npm run check

O 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.

json
[
  {
    "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.

ts
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.

ts
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.

ts
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.

ts
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:

ts
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.

ts
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,
  );
}, []);
ts
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:

ts
// 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.

ts
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:

tsx
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.

tsx
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.

ts
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.

tsx
<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:

tsx
<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.

tsx
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.

tsx
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:

tsx
<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.

css
: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”.

css
.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.

css
@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:

ts
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.

tsx
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.

tsx
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.

tsx
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:

bash
npm run check
npm test
npm run build

A checagem TypeScript terminou sem diagnóstico. Esta foi a saída real dos testes:

RUN v4.1.11 /private/tmp/devclub-painel-react.zqNlQf

Test Files 1 passed (1) Tests 6 passed (6) Duration 671ms

E este foi o build final, depois das correções:

vite v8.2.2 building client environment for production... ✓ 26 modules transformed. dist/index.html 0.61 kB │ gzip: 0.38 kB dist/assets/index-UvPG9Ixk.css 10.08 kB │ gzip: 3.04 kB dist/assets/index-sriygO9a.js 202.25 kB │ gzip: 63.52 kB ✓ built in 164ms

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:

bash
npm run dev

Abra a URL mostrada pelo Vite. Antes de mudar o código, faça este roteiro:

  1. espere as três comandas aparecerem;
  2. busque pao e confirme que encontra “Pão de queijo da casa”;
  3. limpe o filtro e avance Ana de “Na fila” para “Em preparo”;
  4. cadastre Diego Alves, Tapioca da feira, quantidade 3;
  5. recarregue a página e confirme que Diego continua na fila;
  6. navegue por Tab e 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.

  • react
  • typescript
  • vite
  • dashboard
  • formulario
  • fetch
  • testes

Perguntas frequentes

Preciso dominar TypeScript antes de fazer este painel React?
Não, mas você precisa conhecer variáveis, funções, arrays e componentes básicos. O tutorial apresenta os tipos junto da regra que eles protegem, sem exigir que você estude TypeScript inteiro antes de começar.
Por que usar Vite em um projeto React com TypeScript?
O Vite cuida do servidor de desenvolvimento e do build para produção. O plugin React integra JSX e atualização rápida, enquanto o TypeScript faz a checagem estática antes do Vite empacotar os arquivos.
localStorage serve para um painel de pedidos em produção?
Serve para protótipo local e preferências sem dados sensíveis. Num painel usado por várias pessoas, os pedidos devem ficar em uma API com banco de dados, autenticação e regras no servidor, porque localStorage pertence a um único navegador e pode ser alterado pelo usuário.
Como o painel trata loading, erro, vazio e sucesso do fetch?
Um estado discriminado permite apenas uma situação por vez. Loading mostra a espera, error guarda uma mensagem e oferece nova tentativa, e success carrega o array; dentro do sucesso, um array vazio recebe orientação própria.
Por que não usar o índice do array como key dos pedidos?
O índice muda quando um pedido entra no começo ou quando a lista é filtrada. O id permanece ligado à mesma comanda e ajuda o React a preservar a identidade correta de cada item entre renderizações.
O que os testes automatizados deste projeto verificam?
Eles cobrem loading seguido de sucesso, resposta HTTP 503, lista vazia, validação e cadastro, persistência, filtro, avanço imutável de status e a recuperação quando o JSON salvo no navegador está corrompido.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. React — Passing Props to a Component — react.dev
  2. React — Updating Arrays in State — react.dev
  3. React — input — react.dev
  4. React — useEffect — react.dev
  5. Vite — Getting Started — vite.dev
  6. Vitest — Getting Started — vitest.dev
  7. Testing Library — React Testing Library — testing-library.com
  8. MDN — Using the Fetch API — developer.mozilla.org

Continue por aqui