Vamos transformar my-app em uma interface que lê e modifica produtos por uma API local. Ao final, cadastrar e editar alterará db.json. Recarregar a página não deve apagar uma operação já gravada.
Pré-requisitos: projeto Vite + React + TypeScript funcionando; rotas da aula configuradas; Node compatível; noções de estado, eventos e objetos. Tailwind pode permanecer como já foi configurado; as classes do exemplo cuidam da apresentação, não da comunicação com a API.

Não execute todos os exemplos de terminal sem ler a etapa. Os testes de escrita modificam registros. Use somente os dados fictícios da prática.
Mantenha createBrowserRouter e RouterProvider no main.tsx, com importações de react-router. App continua com Cabecalho, Outlet e Rodape. Home e Error não precisam ser substituídos.
URL da interface | Componente |
/ | src/routes/Home/index.tsx |
/produtos | src/routes/Produtos/index.tsx |
/editar-produtos/:id | src/routes/EditarProdutos/index.tsx |
Error continua definido em errorElement. Não crie uma rota /Error nem outro roteador. O cadastro ficará na própria página Produtos, para evitar acrescentar uma rota não ensinada.
Criaremos db.json na raiz; src/types/produto.ts para o contrato; src/services/produtos.ts para comunicação HTTP; src/components/FormularioProduto.tsx para os campos; e atualizaremos as duas páginas existentes.
A URL http://localhost:3001/produtos é da API. Ela não entra na configuração de rotas do React. /produtos na interface e /produtos no servidor são atendidos por processos diferentes.
Na pasta que contém package.json:
node -v
npm -v
npm install -D --save-exact json-server@1.0.0-beta.15
npm list json-server
A versão fixada exige Node >=22.12.0. Confira o resultado antes de prosseguir. Este codelab usa a linha v1 beta: ids são strings. Não misture instruções antigas de v0 sem conferir a versão.
install instala; -D registra dependência de desenvolvimento; –save-exact fixa a versão. Preserve package-lock.json para repetir a instalação. Não instale o pacote globalmente nem substitua seu package.json pelo de outro projeto.
Verificação: npm list deve mostrar json-server@1.0.0-beta.15 sem erro de dependência.
Crie my-app/db.json ao lado do package.json:
{
"produtos": [
{ "id": "1", "nome": "Notebook", "preco": 3500, "estoque": 10 },
{ "id": "2", "nome": "Mouse", "preco": 120, "estoque": 25 },
{ "id": "3", "nome": "Teclado", "preco": 210, "estoque": 8 }
]
}
produtos é a coleção. Cada objeto possui id, nome, preco e estoque. Os ids têm aspas porque são texto; preço e estoque são números. Não coloque comentários no JSON nem vírgula após a última propriedade.
Guarde uma cópia inicial antes das exclusões da prática. Não coloque db.json em public para tentar transformá-lo em banco: quem implementa as operações é JSON Server.
Exercício: adicione um quarto produto com id único, respeitando os tipos. Confira as vírgulas entre objetos.
Dentro da seção scripts existente em package.json, acrescente:
"api": "json-server db.json --port 3001"
Esse fragmento não é um arquivo JSON completo. Adicione a vírgula entre ele e os outros scripts, preservando dev, build e lint como já estão.
No primeiro terminal:
npm run api
No segundo, na mesma pasta:
npm run dev
Deixe os dois ativos. No navegador, abra http://localhost:3001/produtos. Confira se recebeu um array. Depois consulte http://localhost:3001/produtos/1 e confira se recebeu um objeto.
Não avance para o React enquanto a API não responder diretamente. Se a porta estiver ocupada, identifique o processo; não encerre um serviço desconhecido.

Os comandos abaixo são para Git Bash. As aspas simples protegem o JSON; a barra invertida no fim da linha continua o comando. Não cole esta sintaxe no CMD sem adaptá-la.
Listar:
curl -i http://localhost:3001/produtos
Cadastrar:
curl -i -X POST http://localhost:3001/produtos \
-H 'Content-Type: application/json' \
-d '{"nome":"Produto de teste","preco":50,"estoque":2}'
Observe o id devolvido. Ele foi gerado pela API. Para alterar ou excluir esse registro, substitua ID-RETORNADO nos próximos comandos pelo valor real, sem sinais de menor e maior:
curl -i -X PATCH http://localhost:3001/produtos/ID-RETORNADO \
-H 'Content-Type: application/json' \
-d '{"estoque":7}'
curl -i -X DELETE http://localhost:3001/produtos/ID-RETORNADO
Abra db.json e confira a alteração após cada operação. PUT representa substituição; usaremos PATCH para editar campos. Não reutilize o id de outro produto apenas para terminar o teste.
Crie src/types/produto.ts:
export type Produto = {
id: string;
nome: string;
preco: number;
estoque: number;
};
// O cadastro não escolhe o identificador: a API o gera.
export type DadosProduto = Omit<Produto, "id">;
Produto descreve um objeto persistido. DadosProduto usa Omit para definir o mesmo contrato sem id. O formulário fornece esses dados; a API gera a identidade no cadastro.
Omit existe na tipagem: não apaga propriedades de um objeto em execução. Se você inserir id manualmente num corpo HTTP, a tipagem não serve como barreira de segurança do servidor.
Exercício: por que id não é number neste exemplo? Resposta: a versão v1 da API trabalha com ids textuais, inclusive os gerados no cadastro.
Crie src/services/produtos.ts:
import type { DadosProduto, Produto } from "../types/produto";
const URL = "http://localhost:3001/produtos";
// fetch não rejeita automaticamente respostas HTTP como 404.
function verificarResposta(resposta: Response): void {
if (!resposta.ok) {
if (resposta.status === 404) {
throw new Error("Produto não encontrado.");
}
throw new Error(`A operação falhou. HTTP ${resposta.status}.`);
}
}
export async function listarProdutos(signal?: AbortSignal): Promise<Produto[]> {
const resposta = await fetch(URL, { signal });
verificarResposta(resposta);
return resposta.json();
}
export async function buscarProduto(id: string, signal?: AbortSignal): Promise<Produto> {
const resposta = await fetch(`${URL}/${encodeURIComponent(id)}`, { signal });
verificarResposta(resposta);
return resposta.json();
}
export async function criarProduto(dados: DadosProduto): Promise<Produto> {
const resposta = await fetch(URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dados),
});
verificarResposta(resposta);
return resposta.json();
}
export async function atualizarProduto(id: string, dados: DadosProduto): Promise<Produto> {
const resposta = await fetch(`${URL}/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dados),
});
verificarResposta(resposta);
return resposta.json();
}
export async function excluirProduto(id: string): Promise<void> {
const resposta = await fetch(`${URL}/${encodeURIComponent(id)}`, {
method: "DELETE",
});
verificarResposta(resposta);
// Não precisamos interpretar um corpo para confirmar a exclusão.
}
Função | Entrada | Resultado aguardado |
listarProdutos | signal opcional | Produto[] |
buscarProduto | id e signal opcional | Produto |
criarProduto | DadosProduto | Produto criado com id |
atualizarProduto | id e DadosProduto | Produto atualizado |
excluirProduto | id | Sem valor útil |
Promise descreve o resultado futuro da função async. Não criamos Promise manualmente. return resposta.json() devolve uma operação que a função async incorpora; o chamador usa await para receber o valor final.
verificarResposta lança um erro quando o status não indica sucesso. O catch ficará no componente ou formulário que precisa informar a pessoa. Centralizar a verificação evita repetir a mesma condição cinco vezes.
JSON.stringify transforma o objeto enviado em texto. Content-Type descreve esse texto como JSON. encodeURIComponent protege a composição do segmento id da URL. O serviço de exclusão não tenta interpretar um corpo que não precisa utilizar.
As anotações não validam os dados recebidos em execução. Este exercício usa uma API local cujo contrato controlamos. Um sistema real deve validar respostas externas e regras no servidor.
Crie src/components/FormularioProduto.tsx:
import { useState, type FormEvent } from "react";
import type { DadosProduto, Produto } from "../types/produto";
type Props = {
inicial?: Produto;
ocupado?: boolean;
onSalvar: (dados: DadosProduto) => Promise<void>;
};
export default function FormularioProduto({ inicial, ocupado = false, onSalvar }: Props) {
// Campos numéricos ficam como texto durante a digitação.
// Assim, é possível apagar o conteúdo sem convertê-lo imediatamente em zero.
const [nome, setNome] = useState(inicial?.nome ?? "");
const [preco, setPreco] = useState(inicial ? String(inicial.preco) : "");
const [estoque, setEstoque] = useState(inicial ? String(inicial.estoque) : "");
const [salvando, setSalvando] = useState(false);
const [erro, setErro] = useState("");
async function enviar(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (salvando || ocupado) return;
setErro("");
const valorPreco = Number(preco);
const valorEstoque = Number(estoque);
if (
nome.trim() === "" || preco.trim() === "" || estoque.trim() === "" ||
!Number.isFinite(valorPreco) || valorPreco < 0 ||
!Number.isInteger(valorEstoque) || valorEstoque < 0
) {
setErro("Informe um nome, preço não negativo e estoque inteiro não negativo.");
return;
}
setSalvando(true);
try {
await onSalvar({ nome: nome.trim(), preco: valorPreco, estoque: valorEstoque });
// Limpar só depois do sucesso, e somente no cadastro.
if (!inicial) {
setNome("");
setPreco("");
setEstoque("");
}
} catch (error) {
setErro(error instanceof Error ? error.message : "Não foi possível salvar.");
} finally {
setSalvando(false);
}
}
return (
<form onSubmit={enviar} className="space-y-4 rounded-xl border p-5">
<fieldset disabled={salvando || ocupado} className="space-y-4">
<legend className="font-semibold">Dados do produto</legend>
<label className="block">
Nome
<input required value={nome} onChange={(e) => setNome(e.target.value)}
className="block w-full rounded border p-2" />
</label>
<label className="block">
Preço
<input required type="number" min="0" step="0.01" value={preco}
onChange={(e) => setPreco(e.target.value)}
className="block w-full rounded border p-2" />
</label>
<label className="block">
Estoque
<input required type="number" min="0" step="1" value={estoque}
onChange={(e) => setEstoque(e.target.value)}
className="block w-full rounded border p-2" />
</label>
<button type="submit"
className="rounded bg-slate-900 px-4 py-2 text-white disabled:opacity-50">
{salvando ? "Salvando..." : "Salvar produto"}
</button>
</fieldset>
{erro && <p role="alert" className="text-red-700">{erro}</p>}
</form>
);
}
O teste de string vazia vem antes de aceitar a conversão. Isso evita tratar Number("") como um zero digitado intencionalmente. O estoque precisa ser inteiro; o preço pode ter casas decimais.
Não limpe os campos quando a API falhar. A pessoa deve poder corrigir ou tentar novamente sem redigitar tudo.
Atualize src/routes/Produtos/index.tsx:
import { useEffect, useState } from "react";
import { Link } from "react-router";
import FormularioProduto from "../../components/FormularioProduto";
import { criarProduto, excluirProduto, listarProdutos } from "../../services/produtos";
import type { DadosProduto, Produto } from "../../types/produto";
export default function Produtos() {
const [produtos, setProdutos] = useState<Produto[]>([]);
const [carregando, setCarregando] = useState(true);
const [ocupado, setOcupado] = useState(false);
const [erro, setErro] = useState("");
const [mensagem, setMensagem] = useState("");
useEffect(() => {
const controller = new AbortController();
async function carregar() {
try {
const dados = await listarProdutos(controller.signal);
if (!controller.signal.aborted) setProdutos(dados);
} catch (error) {
if (!controller.signal.aborted) {
setErro(error instanceof Error ? error.message : "Falha ao carregar.");
}
} finally {
if (!controller.signal.aborted) setCarregando(false);
}
}
carregar();
return () => controller.abort();
}, []);
async function cadastrar(dados: DadosProduto): Promise<void> {
setOcupado(true);
setMensagem("");
try {
const criado = await criarProduto(dados);
// A API devolve o objeto com id. Não inventamos o id no React.
setProdutos((atuais) => [...atuais, criado]);
setMensagem("Produto cadastrado.");
} finally {
setOcupado(false);
}
// Uma falha sobe para o catch do formulário, que mantém os campos.
}
async function remover(produto: Produto) {
if (ocupado || !window.confirm(`Excluir ${produto.nome}?`)) return;
setOcupado(true);
setErro("");
setMensagem("");
try {
await excluirProduto(produto.id);
// A tela muda somente depois da confirmação da API.
setProdutos((atuais) => atuais.filter((p) => p.id !== produto.id));
setMensagem("Produto excluído.");
} catch (error) {
setErro(error instanceof Error ? error.message : "Falha ao excluir.");
} finally {
setOcupado(false);
}
}
return (
<main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-3xl font-bold">Produtos</h1>
{carregando && <p role="status">Carregando...</p>}
{erro && <p role="alert" className="text-red-700">{erro}</p>}
<p role="status">{mensagem}</p>
<ul className="space-y-3">
{produtos.map((produto) => (
<li key={produto.id} className="rounded-xl border p-4">
<h2 className="font-semibold">{produto.nome}</h2>
<p>{produto.preco.toLocaleString("pt-BR", {
style: "currency", currency: "BRL",
})} | Estoque: {produto.estoque}</p>
<div className="mt-3 flex gap-4">
{!ocupado && (
<Link className="underline" to={`/editar-produtos/${produto.id}`}>
Editar
</Link>
)}
<button disabled={ocupado} onClick={() => remover(produto)}
className="text-red-700 disabled:opacity-50">Excluir</button>
</div>
</li>
))}
</ul>
{!carregando && !erro && produtos.length === 0 && <p>Nenhum produto.</p>}
<section aria-labelledby="novo-produto">
<h2 id="novo-produto" className="mb-3 text-xl font-semibold">Novo produto</h2>
<FormularioProduto ocupado={carregando || ocupado} onSalvar={cadastrar} />
</section>
</main>
);
}
onSalvar recebe cadastrar. O formulário chama essa função com DadosProduto. Após o POST, usamos o objeto devolvido, que já contém id. A atualização funcional [...atuais, criado] acrescenta o item sem mutar o array anterior.
cadastrar possui finally, mas não catch: a falha é propagada ao catch de FormularioProduto. Assim, o formulário mostra a mensagem e preserva a digitação. Não devolva sucesso falso depois de capturar um erro.
A confirmação antecede DELETE. Somente depois do sucesso usamos filter para retirar o item da tela. Filter não apaga o banco; DELETE faz isso. Se a API falhar, o item permanece e a mensagem aparece.
A listagem inicial bloqueia gravações até terminar, evitando que uma resposta inicial atrasada sobrescreva o resultado de um cadastro. ocupado bloqueia operações simultâneas pela interface desta página.
Atualize src/routes/EditarProdutos/index.tsx:
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import FormularioProduto from "../../components/FormularioProduto";
import { atualizarProduto, buscarProduto } from "../../services/produtos";
import type { DadosProduto, Produto } from "../../types/produto";
export default function EditarProdutos() {
const { id } = useParams();
const navigate = useNavigate();
const [produto, setProduto] = useState<Produto | null>(null);
const [erro, setErro] = useState("");
const [carregando, setCarregando] = useState(true);
useEffect(() => {
const controller = new AbortController();
setCarregando(true);
setErro("");
setProduto(null);
async function carregar() {
try {
if (!id) throw new Error("Identificador ausente.");
const encontrado = await buscarProduto(id, controller.signal);
if (!controller.signal.aborted) setProduto(encontrado);
} catch (error) {
if (!controller.signal.aborted) {
setErro(error instanceof Error ? error.message : "Falha ao consultar.");
}
} finally {
if (!controller.signal.aborted) setCarregando(false);
}
}
carregar();
return () => controller.abort();
}, [id]);
async function salvar(dados: DadosProduto): Promise<void> {
if (!id) throw new Error("Identificador ausente.");
await atualizarProduto(id, dados);
// Só voltar depois da confirmação de gravação.
navigate("/produtos");
}
return (
<main className="mx-auto max-w-2xl space-y-4 p-6">
<h1 className="text-3xl font-bold">Editar produto</h1>
{carregando && <p role="status">Carregando produto...</p>}
{erro && <p role="alert" className="text-red-700">{erro}</p>}
{!carregando && produto && (
<FormularioProduto key={produto.id} inicial={produto} onSalvar={salvar} />
)}
<Link to="/produtos" className="inline-block underline">Voltar para produtos</Link>
</main>
);
}

useParams fornece id como texto. Não use Number(id). O serviço consulta o produto individual, sem baixar o array inteiro. O efeito depende de [id] porque uma mudança do parâmetro exige outra consulta.
Produto | null representa um objeto encontrado ou sua ausência inicial. Não montamos o formulário antes de obter o produto. key={produto.id} faz o formulário receber uma nova identidade ao trocar o registro; seu useState inicial não deve reaproveitar os campos do produto anterior.
salvar aguarda PATCH antes de navigate. Se ocorrer falha, ela sobe até o catch do formulário e a página continua aberta. Ao retornar à listagem, seu efeito realiza uma nova leitura.
O cancelamento desta prática protege leituras. Abortar uma requisição no navegador não desfaz uma gravação que chegou ao servidor. Evite navegar durante uma gravação; controle de mutações entre páginas é uma evolução além desta prática inicial.
Teste | Resultado esperado |
Abrir /produtos | Lista recebida da API. |
Cadastrar Monitor | Novo id e produto visível. |
Recarregar | Monitor continua na lista. |
Clicar em Editar | URL /editar-produtos/ID e campos preenchidos. |
Alterar preço e salvar | Retorno à lista com preço atualizado. |
Excluir e confirmar | Registro some da lista e de db.json. |
Cancelar a confirmação | Nenhuma exclusão. |
Consultar id inexistente | Mensagem de erro e link para voltar. |
Parar API e tentar salvar | Mensagem de falha; campos preservados. |
Enviar estoque negativo | Validação impede o envio. |
Abra Network no navegador: confira método, endereço, corpo e status. Depois execute npm run build e, se estiver configurado, npm run lint. Não desative regras só para esconder uma mensagem.
Teste os endereços:
http://localhost:3001/produtos?nome=Mouse
http://localhost:3001/produtos?_sort=preco
http://localhost:3001/produtos?_page=1&_per_page=2
Na paginação v1, o resultado é um objeto com metadados e data. O endpoint sem paginação usado no serviço retorna array. Não troque a URL e mantenha cegamente a mesma tipagem.
Exercício: em uma função separada, modele ao menos items, pages e data: Produto[]. Apresente a quantidade total antes de renderizar data.map. Confira o formato realmente recebido em Network.
Sintoma | Próxima verificação |
API não abre | Terminal npm run api e porta 3001. |
React não abre | Terminal Vite e endereço informado por ele. |
404 | Caminho produtos e id real. |
Erro de sintaxe JSON | Aspas, vírgulas e números. |
Registro reaparece | Houve DELETE ou só filter? |
Resultado não acompanha edição | A gravação foi aguardada antes de voltar? |
Duas leituras em desenvolvimento | Confira StrictMode e limpeza do efeito. |
Erro de CORS | Confira URL e resposta; não use no-cors para mascarar. |
localhost refere-se ao dispositivo que abre a página. A API do seu computador não é publicada junto com o front-end. Não armazene dados pessoais reais ou tokens neste banco de testes.
Se quiser recomeçar com os dados iniciais, pare a API, preserve o arquivo atual se precisar dele, restaure a cópia inicial e reinicie. Isso substitui o estado atual da prática.
O que cada arquivo faz? Onde está o catch de um cadastro? Quem gera o id? Qual método grava? Qual método só muda o array da tela? O que acontece ao desligar a API?