Neste codelab, um arquivo chamado produtos.ts será usado como uma fonte local de dados. Ele terá uma lista de produtos escrita em TypeScript e será importado diretamente pelas páginas React.
O comportamento lembrará o consumo de uma API porque teremos:
id;Porém, existe uma diferença fundamental: o arquivo já faz parte do projeto. Não haverá requisição de rede, tempo de espera, carregamento externo ou programação assíncrona.
Etapa | Arquivo ou componente | O que acontece |
1 |
| Define o formato obrigatório de um produto. |
2 |
| Cria e exporta o array |
3 |
| Importa o array, aplica filtros e prepara a listagem. |
4 |
| Recebe um produto por props e apresenta seus dados. |
5 | Botão "Editar produto" | Informa o |
6 |
| Abre |
7 |
| Lê o |
8 |
| Recebe o produto encontrado e apresenta os campos preenchidos. |
O projeto já existe e se chama:
my-app
As seguintes rotas já estão configuradas:
URL | Componente |
|
|
|
|
|
|
erro de rota |
|
Este material preservará exatamente essa convenção: nomes das pastas de componentes com inicial maiúscula, arquivos index.tsx e URLs em letras minúsculas.
Ao final, você conseguirá:
.ts;type, união de tipos e interface;map, filter e find corretamente;toLowerCase, includes e trim durante uma busca;useState, useNavigate e useParams;id do produto pela URL;EditarProdutos;useEffect não é necessário.Em cada etapa:
Antes de escrever código, precisamos separar duas ideias.
Quando importamos um arquivo TypeScript:
import { listaProdutos } from "../../data/produtos";
o Vite encontra esse módulo durante o desenvolvimento e durante a construção do projeto. O array exportado passa a fazer parte da aplicação.
O fluxo é direto:
produtos.ts
↓ import
routes/Produtos/index.tsx
↓ props
CardProduto.tsx
Em uma API verdadeira, a aplicação normalmente precisa solicitar dados a outro endereço:
React → rede → servidor → banco de dados → resposta → React
Esse processo pode demorar, falhar ou depender de autenticação. Nada disso acontece neste exercício.
Arquivo | API verdadeira |
Faz parte do projeto. | Está em outro serviço ou servidor. |
É importado diretamente. | É solicitado pela rede. |
Os dados chegam imediatamente ao módulo. | A resposta pode demorar. |
Não possui erro HTTP. | Pode devolver 404, 500 e outros códigos. |
Alterar a tela não altera o arquivo-fonte. | O servidor pode persistir alterações. |
Excelente para aprendizado e protótipos. | Necessária para dados compartilhados e persistentes. |
Significa que organizaremos o projeto como se existisse uma fonte central de dados. As páginas não criarão objetos espalhados pelo JSX. Elas receberão os dados de um arquivo especializado.
Não significa que o arquivo local ganhou recursos de servidor.
Responda antes de continuar:
produtos.ts?O projeto final utilizará esta organização:
my-app/
├── public/
├── src/
│ ├── components/
│ │ ├── Cabecalho.tsx
│ │ ├── CardProduto.tsx
│ │ └── Rodape.tsx
│ ├── data/
│ │ └── produtos.ts
│ ├── routes/
│ │ ├── EditarProdutos/
│ │ │ └── index.tsx
│ │ ├── Error/
│ │ │ └── index.tsx
│ │ ├── Home/
│ │ │ └── index.tsx
│ │ └── Produtos/
│ │ └── index.tsx
│ ├── types/
│ │ └── produto.ts
│ ├── App.tsx
│ └── main.tsx
├── package.json
└── vite.config.ts
Pasta | Responsabilidade |
| Componentes reutilizáveis, como o card. |
| Fonte local de dados. |
| Componentes relacionados às URLs, cada um em uma pasta com |
| Contratos TypeScript compartilhados. |
types e data?O arquivo de tipos responde:
Qual é o formato válido de um produto?
O arquivo de dados responde:
Quais produtos existem neste exemplo?
Essa separação permite que páginas, componentes e dados utilizem o mesmo contrato.
No terminal, entre no projeto e confirme que ele funciona:
cd my-app
npm run dev
Abra o endereço mostrado pelo Vite e teste as rotas existentes.
Você não precisa recriar o roteamento se ele já funciona. Apenas confira se o arquivo src/main.tsx segue a estrutura ensinada:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import App from "./App.tsx";
import EditarProdutos from "./routes/EditarProdutos/index.tsx";
import Error from "./routes/Error/index.tsx";
import Home from "./routes/Home/index.tsx";
import Produtos from "./routes/Produtos/index.tsx";
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <Error />,
children: [
{
path: "/",
element: <Home />,
},
{
path: "/produtos",
element: <Produtos />,
},
{
path: "/editar-produtos/:id",
element: <EditarProdutos />,
},
],
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
Como as páginas são filhas de App, o componente App.tsx precisa possuir um Outlet:
import { Outlet } from "react-router";
import Cabecalho from "./components/Cabecalho";
import Rodape from "./components/Rodape";
export default function App() {
return (
<div>
<Cabecalho />
<Outlet />
<Rodape />
</div>
);
}
Outlet é o espaço reservado no layout para a rota filha correspondente à URL atual. Cabecalho e Rodape permanecem ao redor de todas as páginas.
Crie:
src/types/produto.ts
Digite:
export type CategoriaProduto =
| "notebook"
| "smartphone"
| "acessorio";
export type StatusProduto =
| "disponivel"
| "baixo-estoque"
| "indisponivel";
export interface Produto {
id: number;
nome: string;
preco: number;
categoria: CategoriaProduto;
status: StatusProduto;
estoque: number;
imagem: string;
descricao: string;
}
export type FiltroCategoria = "todos" | CategoriaProduto;
export type FormularioProduto = Omit<Produto, "id">;
type faz?type cria um nome para uma definição de tipo.
type CategoriaProduto = "notebook" | "smartphone" | "acessorio";
Não estamos criando uma variável chamada CategoriaProduto. Estamos criando uma regra usada pelo TypeScript.
O símbolo | significa ou.
"notebook" OU "smartphone" OU "acessorio"
Isto é válido:
const categoria: CategoriaProduto = "notebook";
Isto produz erro:
const categoria: CategoriaProduto = "televisao";
O erro aparece porque "televisao" não faz parte das opções permitidas.
A interface descreve o formato esperado de um objeto.
export interface Produto {
id: number;
nome: string;
}
Ela diz que todo produto precisa ter:
id numérica;nome textual.Propriedade | Tipo | Significado |
|
| Identificador único. |
|
| Nome apresentado na tela. |
|
| Valor numérico usado em cálculos e formatação. |
|
| Uma das categorias permitidas. |
|
| Situação comercial do produto. |
|
| Quantidade disponível. |
|
| Caminho ou URL da imagem. |
|
| Texto explicativo. |
Em:
nome: string;
nome é a propriedade;: pode ser lido como "deve ser do tipo";string é o tipo;; encerra aquela declaração.FiltroCategoriaexport type FiltroCategoria = "todos" | CategoriaProduto;
Isso equivale a permitir:
"todos" | "notebook" | "smartphone" | "acessorio"
Omitexport type FormularioProduto = Omit<Produto, "id">;
Omit cria um novo tipo retirando propriedades de outro tipo.
Aqui, o formulário terá todos os campos de Produto, menos id.
O id fica de fora porque identifica o produto que já existe. O usuário editará nome, preço, estoque e demais informações, mas não trocará sua identidade.
Quais linhas são válidas?
const a: CategoriaProduto = "smartphone";
const b: CategoriaProduto = "monitor";
const c: StatusProduto = "baixo-estoque";
const d: StatusProduto = "acabando";
Crie:
src/data/produtos.ts
Digite:
import type { Produto } from "../types/produto";
export const listaProdutos: Produto[] = [
{
id: 1,
nome: "Notebook Pro 15",
preco: 5799.9,
categoria: "notebook",
status: "disponivel",
estoque: 12,
imagem: "https://placehold.co/600x400/172554/ffffff?text=Notebook+Pro+15",
descricao: "Notebook para desenvolvimento, estudos e produtividade.",
},
{
id: 2,
nome: "Smartphone Connect X",
preco: 2899.9,
categoria: "smartphone",
status: "baixo-estoque",
estoque: 4,
imagem: "https://placehold.co/600x400/14532d/ffffff?text=Connect+X",
descricao: "Smartphone com tela de alta resolução e conexão 5G.",
},
{
id: 3,
nome: "Mouse Precision",
preco: 189.9,
categoria: "acessorio",
status: "disponivel",
estoque: 30,
imagem: "https://placehold.co/600x400/78350f/ffffff?text=Mouse+Precision",
descricao: "Mouse ergonômico com seis botões configuráveis.",
},
{
id: 4,
nome: "Notebook Air 13",
preco: 4299.9,
categoria: "notebook",
status: "baixo-estoque",
estoque: 3,
imagem: "https://placehold.co/600x400/581c87/ffffff?text=Notebook+Air+13",
descricao: "Notebook leve para mobilidade e tarefas acadêmicas.",
},
{
id: 5,
nome: "Headset Focus",
preco: 349.9,
categoria: "acessorio",
status: "indisponivel",
estoque: 0,
imagem: "https://placehold.co/600x400/7f1d1d/ffffff?text=Headset+Focus",
descricao: "Headset com microfone e isolamento acústico.",
},
{
id: 6,
nome: "Smartphone Start",
preco: 1599.9,
categoria: "smartphone",
status: "disponivel",
estoque: 18,
imagem: "https://placehold.co/600x400/164e63/ffffff?text=Smartphone+Start",
descricao: "Smartphone para comunicação, estudos e tarefas diárias.",
},
];
import type { Produto } from "../types/produto";
import type informa que Produto será usado somente pelo TypeScript para conferência.
Ele não representa um valor utilizado pelo navegador.
export const listaProdutos = ...
O export permite que outro módulo importe listaProdutos pelo mesmo nome.
Produto[]?const listaProdutos: Produto[] = [];
Os colchetes [] depois de Produto significam:
array cujos itens devem obedecer à interface Produto.
O TypeScript verificará cada objeto do array.
Altere temporariamente:
preco: 5799.9,
para:
preco: "5799.90",
O editor deverá informar que string não pode ser atribuída a number.
Depois, desfaça a alteração.
R$?Isto está correto:
preco: 5799.9,
Isto dificultaria cálculos:
preco: "R$ 5.799,90",
O dado permanece numérico. A formatação em reais será feita no componente.
id deve ser único?O id será usado para:
key durante o map;find.Dois produtos com o mesmo id criariam ambiguidade.
Abra:
src/routes/Produtos/index.tsx
Comece com uma versão mínima:
import { listaProdutos } from "../../data/produtos";
export default function Produtos() {
console.table(listaProdutos);
return (
<main className="mx-auto max-w-7xl px-4 py-10">
<h1 className="text-3xl font-bold text-slate-900">
Produtos
</h1>
<p className="mt-2 text-slate-600">
Quantidade cadastrada: {listaProdutos.length}
</p>
</main>
);
}
import { listaProdutos } from "../../data/produtos";
Trecho | Explicação |
| Solicita algo exportado por outro módulo. |
| Nome exato da exportação nomeada. |
| Indica de onde vem o valor. |
| Sobe de |
| Entra em |
Não é necessário escrever .ts no final do caminho.
console.tableconsole.table(listaProdutos);
Esse comando apresenta arrays de objetos em formato de tabela no console do navegador.
Para conferir:
/produtos;F12;length{listaProdutos.length}
length informa a quantidade de itens do array. Não é uma função, por isso não recebe parênteses.
Não precisamos de um hook para "carregar" essa lista. Quando o módulo Produtos.tsx é executado, listaProdutos já foi importada.
map percorre um array e cria um novo array com o resultado produzido para cada item.
const nomes = listaProdutos.map((produto) => produto.nome);
Entrada simplificada:
[
{ nome: "Notebook Pro 15" },
{ nome: "Mouse Precision" }
]
Saída:
[
"Notebook Pro 15",
"Mouse Precision"
]
No React, o novo array pode conter elementos JSX:
<ul>
{listaProdutos.map((produto) => (
<li key={produto.id}>{produto.nome}</li>
))}
</ul>
listaProdutos.map((produto) => (
<li key={produto.id}>{produto.nome}</li>
))
listaProdutos é o array original..map pede que cada item seja transformado.produto é o item atual da repetição.=> separa a entrada da transformação.li.key?key={produto.id}
A key ajuda o React a identificar cada item entre renderizações.
Uma boa key deve ser:
O id cumpre essas condições.
Isto não devolve nada:
listaProdutos.map((produto) => {
<li>{produto.nome}</li>;
});
Com chaves, escreva return:
listaProdutos.map((produto) => {
return <li key={produto.id}>{produto.nome}</li>;
});
Ou use parênteses para retorno direto:
listaProdutos.map((produto) => (
<li key={produto.id}>{produto.nome}</li>
));
Crie:
src/components/CardProduto.tsx
Digite:
import type { Produto } from "../types/produto";
interface CardProdutoProps {
produto: Produto;
onEditar: (id: number) => void;
}
export default function CardProduto({
produto,
onEditar,
}: CardProdutoProps) {
const precoFormatado = produto.preco.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
});
return (
<article className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<img
src={produto.imagem}
alt={`Imagem ilustrativa de ${produto.nome}`}
className="h-48 w-full object-cover"
/>
<div className="space-y-3 p-5">
<div className="flex items-start justify-between gap-3">
<h2 className="text-xl font-semibold text-slate-900">
{produto.nome}
</h2>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{produto.categoria}
</span>
</div>
<p className="text-sm leading-6 text-slate-600">
{produto.descricao}
</p>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-slate-500">Preço</dt>
<dd className="font-semibold text-slate-900">
{precoFormatado}
</dd>
</div>
<div>
<dt className="text-slate-500">Estoque</dt>
<dd className="font-semibold text-slate-900">
{produto.estoque} unidades
</dd>
</div>
</dl>
<p className="text-sm font-medium text-slate-700">
Status: {produto.status}
</p>
<button
type="button"
onClick={() => onEditar(produto.id)}
className="w-full rounded-xl bg-slate-900 px-4 py-3 font-semibold text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
Editar produto
</button>
</div>
</article>
);
}
interface CardProdutoProps {
produto: Produto;
onEditar: (id: number) => void;
}
Prop | Tipo | Responsabilidade |
|
| Fornece os dados exibidos. |
| função | Avisa ao componente pai qual |
onEditar: (id: number) => void;
id;id deve ser number;void indica que o componente não espera um valor devolvido.function CardProduto({ produto, onEditar }: CardProdutoProps)
Sem desestruturação, seria necessário escrever:
function CardProduto(props: CardProdutoProps) {
props.produto.nome;
props.onEditar(props.produto.id);
}
Com desestruturação, usamos diretamente produto e onEditar.
produto.preco.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
});
O valor continua numérico nos dados. O componente apenas cria uma representação apropriada para reais brasileiros.
onClick={() => onEditar(produto.id)}
A função não é executada durante a renderização. Ela será executada quando o botão for acionado.
Não escreva:
onClick={onEditar(produto.id)}
Essa forma tentaria executar onEditar imediatamente.
Atualize temporariamente routes/Produtos/index.tsx:
import CardProduto from "../../components/CardProduto";
import { listaProdutos } from "../../data/produtos";
export default function Produtos() {
function testarEdicao(id: number) {
console.log("Produto escolhido:", id);
}
return (
<main className="mx-auto max-w-7xl px-4 py-10">
<h1 className="text-3xl font-bold text-slate-900">
Produtos
</h1>
<div className="mt-8 grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{listaProdutos.map((produto) => (
<CardProduto
key={produto.id}
produto={produto}
onEditar={testarEdicao}
/>
))}
</div>
</main>
);
}
Em cada repetição:
produto={produto}
O primeiro produto é o nome da prop. O segundo é a variável da repetição.
Podemos ler como:
Entregue à prop chamada produto o objeto do produto atual.
onEditar={testarEdicao}
Não há parênteses porque estamos passando a função para o componente filho. O card decidirá quando executá-la.
/produtos;id diferente.Agora a tela terá informações que mudam durante a interação.
No início de routes/Produtos/index.tsx, importe:
import { useState } from "react";
import type { FiltroCategoria } from "../../types/produto";
Dentro do componente:
const [busca, setBusca] = useState("");
const [categoria, setCategoria] =
useState<FiltroCategoria>("todos");
Estado é uma informação que:
useStateconst [busca, setBusca] = useState("");
Parte | Significado |
| Valor atual. |
| Função usada para atualizar o valor. |
| Cria o estado com texto vazio. |
| Desestruturação do array devolvido pelo hook. |
useState<FiltroCategoria>("todos")
O trecho informa os valores aceitos pelo estado.
<section
aria-label="Filtros dos produtos"
className="mt-8 grid gap-4 rounded-2xl bg-slate-100 p-5 md:grid-cols-2"
>
<div>
<label
htmlFor="busca"
className="mb-2 block font-medium text-slate-800"
>
Buscar por nome ou descrição
</label>
<input
id="busca"
type="search"
value={busca}
onChange={(event) => setBusca(event.target.value)}
className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-slate-900"
placeholder="Exemplo: notebook"
/>
</div>
<div>
<label
htmlFor="categoria"
className="mb-2 block font-medium text-slate-800"
>
Categoria
</label>
<select
id="categoria"
value={categoria}
onChange={(event) =>
setCategoria(event.target.value as FiltroCategoria)
}
className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-slate-900"
>
<option value="todos">Todos</option>
<option value="notebook">Notebooks</option>
<option value="smartphone">Smartphones</option>
<option value="acessorio">Acessórios</option>
</select>
</div>
</section>
O input recebe o valor do estado:
value={busca}
E atualiza o estado quando a pessoa digita:
onChange={(event) => setBusca(event.target.value)}
Esse conjunto forma um campo controlado.
as FiltroCategoria?Para o TypeScript, event.target.value é uma string genérica. Porém, sabemos que as opções do select pertencem a FiltroCategoria.
event.target.value as FiltroCategoria
Essa afirmação só é segura porque controlamos as opções do select.
Agora criaremos uma lista derivada.
Dentro de Produtos, depois dos estados:
const termoNormalizado = busca.trim().toLowerCase();
const produtosFiltrados = listaProdutos.filter((produto) => {
const nomeNormalizado = produto.nome.toLowerCase();
const descricaoNormalizada = produto.descricao.toLowerCase();
const correspondeAoTexto =
nomeNormalizado.includes(termoNormalizado) ||
descricaoNormalizada.includes(termoNormalizado);
const correspondeACategoria =
categoria === "todos" || produto.categoria === categoria;
return correspondeAoTexto && correspondeACategoria;
});
filter cria um novo array contendo apenas os itens aprovados pela condição.
Para cada produto, a função precisa devolver:
true: o produto permanece;false: o produto fica fora do novo array.trimbusca.trim()
Remove espaços no início e no final.
" notebook " → "notebook"
toLowerCaseproduto.nome.toLowerCase()
Cria uma versão em letras minúsculas.
"Notebook Pro 15" → "notebook pro 15"
Isso permite comparar textos sem depender de maiúsculas e minúsculas.
includesnomeNormalizado.includes(termoNormalizado)
Pergunta se um texto contém outro texto.
"notebook pro 15" contém "pro"? true
"mouse precision" contém "pro"? false
||nomeContem || descricaoContem
Significa ou. Basta uma das condições ser verdadeira.
&&correspondeAoTexto && correspondeACategoria
Significa e. As duas condições precisam ser verdadeiras.
produtosFiltrados não é outro estado?Ele pode ser calculado usando:
listaProdutos;busca;categoria.Como é um valor derivado, não precisamos duplicá-lo em outro useState.
Duplicar dados aumentaria o risco de manter estados desencontrados.
Troque:
listaProdutos.map(...)
por:
produtosFiltrados.map(...)
Adicione também:
<p className="mt-5 text-sm text-slate-600" aria-live="polite">
{produtosFiltrados.length} produto(s) encontrado(s).
</p>
notebook.FOCUS.mouse.Método | Pergunta principal | Resultado |
| O que cada item deve virar? | Outro array com a mesma quantidade. |
| Quais itens devem permanecer? | Outro array com zero, um ou vários itens. |
| Qual é o primeiro item que atende à condição? | Um item ou |
const nomes = listaProdutos.map((produto) => produto.nome);
Resultado: array de nomes.
const acessorios = listaProdutos.filter(
(produto) => produto.categoria === "acessorio",
);
Resultado: array de acessórios.
const produtoTres = listaProdutos.find(
(produto) => produto.id === 3,
);
Resultado: o objeto de id 3 ou undefined.
A página de edição precisa de um produto específico, não de uma lista.
Por isso, find combina melhor com o problema.
Na página Produtos, importe:
import { useNavigate } from "react-router";
Dentro do componente:
const navigate = useNavigate();
Crie a função:
function abrirEdicao(id: number) {
navigate(`/editar-produtos/${id}`);
}
Entregue essa função ao card:
<CardProduto
key={produto.id}
produto={produto}
onEditar={abrirEdicao}
/>
useNavigate devolve uma função de navegação programática.
const navigate = useNavigate();
Não é uma página, um endereço ou um componente. É uma função que pode mudar a localização atual da aplicação.
`/editar-produtos/${id}`
As crases criam uma template string. O trecho ${id} insere o valor do identificador.
Se id for 4, o endereço será:
/editar-produtos/4
/4?É o valor que ocupa o parâmetro dinâmico :id definido na rota.
/editar-produtos/:id → definição da rota
/editar-produtos/4 → endereço acessado
└─ valor de id
Na configuração, :id funciona como um espaço reservado. Na navegação, esse espaço recebe o identificador do produto.
id representa identidade;find;/editar-produtos/1;/editar-produtos/5.Abra:
src/routes/EditarProdutos/index.tsx
Comece com:
import { Link, useParams } from "react-router";
import { listaProdutos } from "../../data/produtos";
export default function EditarProdutos() {
const { id } = useParams();
const produtoId = Number(id);
const produtoEncontrado = listaProdutos.find(
(produto) => produto.id === produtoId,
);
if (!produtoEncontrado) {
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<h1 className="text-3xl font-bold text-slate-900">
Produto não encontrado
</h1>
<p className="mt-3 text-slate-600">
Não existe um produto com o identificador informado.
</p>
<Link
to="/produtos"
className="mt-6 inline-block font-semibold text-blue-700 underline"
>
Voltar para produtos
</Link>
</main>
);
}
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<h1 className="text-3xl font-bold text-slate-900">
Editar produto
</h1>
<p className="mt-3 text-slate-600">
Produto encontrado: {produtoEncontrado.nome}
</p>
</main>
);
}
const { id } = useParams();
O hook devolve um objeto com os parâmetros dinâmicos da rota atual. Como a rota possui :id, desestruturamos a propriedade id.
idconst { id } = useParams();
Se a URL for:
/editar-produtos/4
id será o texto "4". Seu tipo também admite undefined, pois o TypeScript não pressupõe que todo endereço possua esse parâmetro.
O id do nosso produto é number, mas o valor da URL chega como texto.
Por isso fazemos:
const produtoId = Number(id);
Exemplo:
"4" → 4
string → number
const produtoEncontrado = listaProdutos.find(
(produto) => produto.id === produtoId,
);
O método percorre os produtos e para quando encontra o primeiro id igual.
O tipo inferido será:
Produto | undefined
Ou existe um produto, ou nada foi encontrado.
if (!produtoEncontrado) {
return <main>Produto não encontrado.</main>;
}
Depois desse if, o TypeScript entende que produtoEncontrado existe. Neste caso, mostramos uma mensagem dentro da própria página. O componente Error configurado como errorElement continua responsável por URLs que não correspondem às rotas.
Digite manualmente:
/editar-produtos/1
/editar-produtos/abc
/editar-produtos/999
/editar-produtos
O primeiro endereço deve localizar o produto 1. Os dois seguintes entram na página de edição e mostram "Produto não encontrado". O último não corresponde à rota /editar-produtos/:id; por isso, o roteador apresenta o componente definido em errorElement.
Não criaremos o estado do formulário antes de saber se o produto existe.
Uma solução clara é separar responsabilidades:
export default function EditarProdutos() {
// Lê a URL e encontra o produto.
if (!produtoEncontrado) {
return <main>Produto não encontrado.</main>;
}
return <FormularioEdicao produto={produtoEncontrado} />;
}
O componente FormularioEdicao será responsável pelos hooks do formulário.
Hooks precisam ser chamados sempre na mesma ordem em cada renderização do componente.
Evite:
if (!produtoEncontrado) {
return <main>Produto não encontrado.</main>;
}
// Evite colocar o hook aqui no mesmo componente.
const [formulario, setFormulario] = useState(...);
Ao mover o formulário para outro componente:
EditarProdutos localiza o produto;FormularioEdicao gerencia os campos;interface FormularioEdicaoProps {
produto: Produto;
}
O formulário só será renderizado quando receber um produto válido.
No mesmo arquivo routes/EditarProdutos/index.tsx, adicione os imports:
import { useState, type FormEvent } from "react";
import type {
FormularioProduto,
Produto,
} from "../../types/produto";
Depois do componente principal, crie:
interface FormularioEdicaoProps {
produto: Produto;
}
function FormularioEdicao({ produto }: FormularioEdicaoProps) {
const [formulario, setFormulario] = useState<FormularioProduto>(() => ({
nome: produto.nome,
preco: produto.preco,
categoria: produto.categoria,
status: produto.status,
estoque: produto.estoque,
imagem: produto.imagem,
descricao: produto.descricao,
}));
const [mensagem, setMensagem] = useState("");
function salvarEdicao(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const produtoAtualizado: Produto = {
id: produto.id,
...formulario,
};
console.table(produtoAtualizado);
setMensagem("Alterações preparadas apenas na memória do navegador.");
}
return (
<form
onSubmit={salvarEdicao}
className="mt-8 space-y-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
>
{/* Os campos serão adicionados na próxima etapa. */}
<button
type="submit"
className="rounded-xl bg-slate-900 px-5 py-3 font-semibold text-white hover:bg-slate-700"
>
Preparar alteração
</button>
<p aria-live="polite" className="text-sm font-medium text-emerald-700">
{mensagem}
</p>
</form>
);
}
useState<FormularioProduto>(() => ({ ... }))
O useState recebe uma função que cria o valor inicial. Essa função é usada na inicialização do estado.
Não devemos alterar diretamente o objeto importado.
O formulário recebe sua própria cópia dos valores editáveis:
nome: produto.nome,
preco: produto.preco,
const produtoAtualizado = {
id: produto.id,
...formulario,
};
...formulario espalha as propriedades do formulário dentro do novo objeto.
O resultado volta a possuir todas as propriedades de Produto, incluindo id.
event.preventDefault();
Impede o comportamento HTML padrão de recarregar ou navegar ao enviar o formulário.
<p aria-live="polite">{mensagem}</p>
aria-live="polite" permite que tecnologias assistivas anunciem a mensagem quando ela mudar.
Substitua o retorno temporário de FormularioEdicao pelo formulário completo:
return (
<form
onSubmit={salvarEdicao}
className="mt-8 space-y-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div>
<label htmlFor="nome" className="mb-2 block font-medium text-slate-800">
Nome
</label>
<input
id="nome"
type="text"
value={formulario.nome}
onChange={(event) =>
setFormulario((estadoAtual) => ({
...estadoAtual,
nome: event.target.value,
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="preco" className="mb-2 block font-medium text-slate-800">
Preço
</label>
<input
id="preco"
type="number"
min="0"
step="0.01"
value={formulario.preco}
onChange={(event) =>
setFormulario((estadoAtual) => ({
...estadoAtual,
preco: Number(event.target.value),
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<div>
<label htmlFor="estoque" className="mb-2 block font-medium text-slate-800">
Estoque
</label>
<input
id="estoque"
type="number"
min="0"
step="1"
value={formulario.estoque}
onChange={(event) =>
setFormulario((estadoAtual) => ({
...estadoAtual,
estoque: Number(event.target.value),
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="categoria" className="mb-2 block font-medium text-slate-800">
Categoria
</label>
<select
id="categoria"
value={formulario.categoria}
onChange={(event) =>
setFormulario((estadoAtual) => ({
...estadoAtual,
categoria: event.target.value as Produto["categoria"],
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
>
<option value="notebook">Notebook</option>
<option value="smartphone">Smartphone</option>
<option value="acessorio">Acessório</option>
</select>
</div>
<div>
<label htmlFor="status" className="mb-2 block font-medium text-slate-800">
Status
</label>
<select
id="status"
value={formulario.status}
onChange={(event) =>
setFormulario((estadoAtual) => ({
...estadoAtual,
status: event.target.value as Produto["status"],
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
>
<option value="disponivel">Disponível</option>
<option value="baixo-estoque">Baixo estoque</option>
<option value="indisponivel">Indisponível</option>
</select>
</div>
</div>
<div>
<label htmlFor="imagem" className="mb-2 block font-medium text-slate-800">
Endereço da imagem
</label>
<input
id="imagem"
type="url"
value={formulario.imagem}
onChange={(event) =>
setFormulario((estadoAtual) => ({
...estadoAtual,
imagem: event.target.value,
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<div>
<label htmlFor="descricao" className="mb-2 block font-medium text-slate-800">
Descrição
</label>
<textarea
id="descricao"
rows={5}
value={formulario.descricao}
onChange={(event) =>
setFormulario((estadoAtual) => ({
...estadoAtual,
descricao: event.target.value,
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<figure className="overflow-hidden rounded-2xl bg-slate-100 p-4">
<img
src={formulario.imagem}
alt={`Prévia de ${formulario.nome}`}
className="mx-auto max-h-72 rounded-xl object-contain"
/>
<figcaption className="mt-2 text-center text-sm text-slate-600">
Prévia da imagem informada
</figcaption>
</figure>
<button
type="submit"
className="rounded-xl bg-slate-900 px-5 py-3 font-semibold text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
Preparar alteração
</button>
<p aria-live="polite" className="text-sm font-medium text-emerald-700">
{mensagem}
</p>
</form>
);
Observe:
setFormulario((estadoAtual) => ({
...estadoAtual,
nome: event.target.value,
}));
O processo é:
nome;Não faça:
formulario.nome = event.target.value;
Isso altera o objeto diretamente e não segue o padrão imutável esperado para o estado.
O valor de um input, mesmo com type="number", chega como texto.
Number(event.target.value)
Converte o texto antes de armazená-lo em preco ou estoque.
Produto["categoria"]
Significa:
use exatamente o tipo da propriedade categoria da interface Produto.
Assim, não precisamos repetir manualmente a união.
src/routes/Produtos/index.tsximport { useState } from "react";
import { useNavigate } from "react-router";
import CardProduto from "../../components/CardProduto";
import { listaProdutos } from "../../data/produtos";
import type { FiltroCategoria } from "../../types/produto";
export default function Produtos() {
const navigate = useNavigate();
const [busca, setBusca] = useState("");
const [categoria, setCategoria] =
useState<FiltroCategoria>("todos");
const termoNormalizado = busca.trim().toLowerCase();
const produtosFiltrados = listaProdutos.filter((produto) => {
const nomeNormalizado = produto.nome.toLowerCase();
const descricaoNormalizada = produto.descricao.toLowerCase();
const correspondeAoTexto =
nomeNormalizado.includes(termoNormalizado) ||
descricaoNormalizada.includes(termoNormalizado);
const correspondeACategoria =
categoria === "todos" || produto.categoria === categoria;
return correspondeAoTexto && correspondeACategoria;
});
function abrirEdicao(id: number) {
navigate(`/editar-produtos/${id}`);
}
return (
<main className="mx-auto max-w-7xl px-4 py-10">
<header>
<h1 className="text-3xl font-bold text-slate-900">
Produtos
</h1>
<p className="mt-2 text-slate-600">
Consulte e selecione um produto para editar.
</p>
</header>
<section
aria-label="Filtros dos produtos"
className="mt-8 grid gap-4 rounded-2xl bg-slate-100 p-5 md:grid-cols-2"
>
<div>
<label htmlFor="busca" className="mb-2 block font-medium text-slate-800">
Buscar por nome ou descrição
</label>
<input
id="busca"
type="search"
value={busca}
onChange={(event) => setBusca(event.target.value)}
className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3"
placeholder="Exemplo: notebook"
/>
</div>
<div>
<label htmlFor="categoria" className="mb-2 block font-medium text-slate-800">
Categoria
</label>
<select
id="categoria"
value={categoria}
onChange={(event) =>
setCategoria(event.target.value as FiltroCategoria)
}
className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3"
>
<option value="todos">Todos</option>
<option value="notebook">Notebooks</option>
<option value="smartphone">Smartphones</option>
<option value="acessorio">Acessórios</option>
</select>
</div>
</section>
<p className="mt-5 text-sm text-slate-600" aria-live="polite">
{produtosFiltrados.length} produto(s) encontrado(s).
</p>
{produtosFiltrados.length > 0 ? (
<div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{produtosFiltrados.map((produto) => (
<CardProduto
key={produto.id}
produto={produto}
onEditar={abrirEdicao}
/>
))}
</div>
) : (
<p className="mt-8 rounded-2xl bg-amber-50 p-5 text-amber-900">
Nenhum produto corresponde aos filtros escolhidos.
</p>
)}
</main>
);
}
src/routes/EditarProdutos/index.tsximport { useState, type FormEvent } from "react";
import { Link, useParams } from "react-router";
import { listaProdutos } from "../../data/produtos";
import type {
FormularioProduto,
Produto,
} from "../../types/produto";
export default function EditarProdutos() {
const { id } = useParams();
const produtoId = Number(id);
const produtoEncontrado = listaProdutos.find(
(produto) => produto.id === produtoId,
);
if (!produtoEncontrado) {
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<h1 className="text-3xl font-bold text-slate-900">
Produto não encontrado
</h1>
<p className="mt-3 text-slate-600">
Não existe um produto com o identificador informado.
</p>
<Link
to="/produtos"
className="mt-6 inline-block font-semibold text-blue-700 underline"
>
Voltar para produtos
</Link>
</main>
);
}
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<header>
<h1 className="text-3xl font-bold text-slate-900">
Editar produto
</h1>
<p className="mt-2 text-slate-600">
Produto selecionado: {produtoEncontrado.nome}
</p>
</header>
<FormularioEdicao produto={produtoEncontrado} />
</main>
);
}
interface FormularioEdicaoProps {
produto: Produto;
}
function FormularioEdicao({ produto }: FormularioEdicaoProps) {
const [formulario, setFormulario] = useState<FormularioProduto>(() => ({
nome: produto.nome,
preco: produto.preco,
categoria: produto.categoria,
status: produto.status,
estoque: produto.estoque,
imagem: produto.imagem,
descricao: produto.descricao,
}));
const [mensagem, setMensagem] = useState("");
function salvarEdicao(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const produtoAtualizado: Produto = {
id: produto.id,
...formulario,
};
console.table(produtoAtualizado);
setMensagem("Alterações preparadas apenas na memória do navegador.");
}
return (
<form
onSubmit={salvarEdicao}
className="mt-8 space-y-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div>
<label htmlFor="nome" className="mb-2 block font-medium text-slate-800">
Nome
</label>
<input
id="nome"
type="text"
value={formulario.nome}
onChange={(event) =>
setFormulario((atual) => ({
...atual,
nome: event.target.value,
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="preco" className="mb-2 block font-medium text-slate-800">
Preço
</label>
<input
id="preco"
type="number"
min="0"
step="0.01"
value={formulario.preco}
onChange={(event) =>
setFormulario((atual) => ({
...atual,
preco: Number(event.target.value),
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<div>
<label htmlFor="estoque" className="mb-2 block font-medium text-slate-800">
Estoque
</label>
<input
id="estoque"
type="number"
min="0"
step="1"
value={formulario.estoque}
onChange={(event) =>
setFormulario((atual) => ({
...atual,
estoque: Number(event.target.value),
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="categoria-edicao" className="mb-2 block font-medium text-slate-800">
Categoria
</label>
<select
id="categoria-edicao"
value={formulario.categoria}
onChange={(event) =>
setFormulario((atual) => ({
...atual,
categoria: event.target.value as Produto["categoria"],
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
>
<option value="notebook">Notebook</option>
<option value="smartphone">Smartphone</option>
<option value="acessorio">Acessório</option>
</select>
</div>
<div>
<label htmlFor="status" className="mb-2 block font-medium text-slate-800">
Status
</label>
<select
id="status"
value={formulario.status}
onChange={(event) =>
setFormulario((atual) => ({
...atual,
status: event.target.value as Produto["status"],
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
>
<option value="disponivel">Disponível</option>
<option value="baixo-estoque">Baixo estoque</option>
<option value="indisponivel">Indisponível</option>
</select>
</div>
</div>
<div>
<label htmlFor="imagem" className="mb-2 block font-medium text-slate-800">
Endereço da imagem
</label>
<input
id="imagem"
type="url"
value={formulario.imagem}
onChange={(event) =>
setFormulario((atual) => ({
...atual,
imagem: event.target.value,
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<div>
<label htmlFor="descricao" className="mb-2 block font-medium text-slate-800">
Descrição
</label>
<textarea
id="descricao"
rows={5}
value={formulario.descricao}
onChange={(event) =>
setFormulario((atual) => ({
...atual,
descricao: event.target.value,
}))
}
className="w-full rounded-xl border border-slate-300 px-4 py-3"
required
/>
</div>
<figure className="overflow-hidden rounded-2xl bg-slate-100 p-4">
<img
src={formulario.imagem}
alt={`Prévia de ${formulario.nome}`}
className="mx-auto max-h-72 rounded-xl object-contain"
/>
<figcaption className="mt-2 text-center text-sm text-slate-600">
Prévia da imagem informada
</figcaption>
</figure>
<button
type="submit"
className="rounded-xl bg-slate-900 px-5 py-3 font-semibold text-white hover:bg-slate-700"
>
Preparar alteração
</button>
<p aria-live="polite" className="text-sm font-medium text-emerald-700">
{mensagem}
</p>
</form>
);
}
useEffect é útil para sincronizar o componente com algo externo ao processo normal de renderização.
Neste codelab:
Portanto, isto é suficiente:
import { listaProdutos } from "../../data/produtos";
E isto seria trabalho desnecessário:
const [produtos, setProdutos] = useState<Produto[]>([]);
useEffect(() => {
setProdutos(listaProdutos);
}, []);
O segundo exemplo cria:
Se o valor já pode ser importado ou calculado durante a renderização, não crie um efeito apenas para copiá-lo.
Hook | Página | Responsabilidade |
|
| Guardar busca e categoria escolhida. |
|
| Abrir a página de edição com o |
|
| Ler o |
|
| Guardar os valores alterados e a mensagem. |
Ao pressionar "Preparar alteração", criamos:
const produtoAtualizado: Produto = {
id: produto.id,
...formulario,
};
Esse objeto existe apenas durante a execução atual da aplicação.
produtos.ts não é reescrito;Ele ensina o fluxo que também será necessário quando uma fonte externa for conectada:
map;filter;id;find;Depois, a persistência será uma nova responsabilidade, sem apagar o aprendizado desta etapa.
Cannot find module '../../data/produtos'
Confira:
data;produtos.ts;../;Property 'preço' does not exist on type 'Produto'
A interface usa preco, sem acento. O nome precisa ser idêntico.
Type 'string' is not assignable to type 'number'
Converta valores numéricos vindos de campos:
Number(event.target.value)
Confira se o map devolve JSX e se está usando produtosFiltrados.
produtosFiltrados.map((produto) => (
<CardProduto key={produto.id} produto={produto} onEditar={abrirEdicao} />
))
Verifique:
/editar-produtos/ID;Number;id;===;find recebe a lista correta.Normalize tanto o campo quanto os dados:
produto.nome.toLowerCase().includes(busca.trim().toLowerCase())
Hooks devem ficar:
if, for, map ou função de clique.Explique com suas palavras:
CategoriaProduto é uma união;preco é number;FormularioProduto não possui id;Produto[].Crie um array contendo apenas os preços:
const precos = // complete
Depois, crie uma lista JSX contendo somente os nomes.
Crie uma lista contendo produtos com estoque menor ou igual a 5.
const estoqueBaixo = // complete
Localize o produto com id 6.
Depois, tente localizar o id 100 e explique o resultado.
Adicione um checkbox:
Mostrar apenas produtos disponíveis
Use outro useState booleano e integre a condição ao filter existente.
Explique o resultado de:
/editar-produtos/3
/editar-produtos/999
/editar-produtos/abc
/editar-produtos
Na página EditarProdutos, adicione antes do formulário:
id do produto;Use uma lista de descrição com dl, dt e dd.
Produto[] é um array formado por objetos que obedecem à interface.const precos = listaProdutos.map((produto) => produto.preco);
<ul>
{listaProdutos.map((produto) => (
<li key={produto.id}>{produto.nome}</li>
))}
</ul>
const estoqueBaixo = listaProdutos.filter(
(produto) => produto.estoque <= 5,
);
const produtoSeis = listaProdutos.find(
(produto) => produto.id === 6,
);
const produtoInexistente = listaProdutos.find(
(produto) => produto.id === 100,
);
produtoSeis recebe o objeto encontrado. produtoInexistente recebe undefined.
Estado:
const [somenteDisponiveis, setSomenteDisponiveis] = useState(false);
Controle:
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={somenteDisponiveis}
onChange={(event) => setSomenteDisponiveis(event.target.checked)}
/>
Mostrar apenas produtos disponíveis
</label>
Condição no filter:
const correspondeAoStatus =
!somenteDisponiveis || produto.status === "disponivel";
return (
correspondeAoTexto &&
correspondeACategoria &&
correspondeAoStatus
);
URL | Resultado esperado |
| Encontra e apresenta o produto 3. |
| A rota corresponde, mas o produto não existe; mostra a mensagem local. |
| A rota corresponde, mas a conversão não gera um id válido; mostra a mensagem local. |
| Não corresponde à rota com |
<dl className="mt-6 grid gap-4 rounded-2xl bg-slate-100 p-5 sm:grid-cols-2">
<div>
<dt className="text-sm text-slate-500">Identificador</dt>
<dd className="font-semibold text-slate-900">{produtoEncontrado.id}</dd>
</div>
<div>
<dt className="text-sm text-slate-500">Categoria</dt>
<dd className="font-semibold text-slate-900">{produtoEncontrado.categoria}</dd>
</div>
<div>
<dt className="text-sm text-slate-500">Estoque</dt>
<dd className="font-semibold text-slate-900">{produtoEncontrado.estoque}</dd>
</div>
<div>
<dt className="text-sm text-slate-500">Status</dt>
<dd className="font-semibold text-slate-900">{produtoEncontrado.status}</dd>
</div>
</dl>
produto.ts exporta os tipos.produtos.ts importa Produto com import type.listaProdutos está tipada como Produto[].id são únicos.Produtos importa a lista diretamente.map renderiza CardProduto com uma key estável.filter combina texto e categoria.trim, toLowerCase e includes.useNavigate envia o id pela URL.useParams lê o parâmetro dinâmico :id.Number converte o id textual.find localiza um único produto./produtos./, /produtos e /editar-produtos/:id, com Error em errorElement.1. Produto define o contrato.
2. produtos.ts cria Produto[].
3. routes/Produtos/index.tsx importa o array.
4. useState guarda os filtros.
5. filter seleciona os produtos.
6. map cria os cards.
7. CardProduto informa o id escolhido.
8. useNavigate abre /editar-produtos/ID.
9. useParams lê o id definido por :id.
10. Number converte texto em número.
11. find localiza o objeto.
12. useState inicializa o formulário.
13. o formulário monta um novo Produto em memória.
Você concluiu o processo completo de consumo de um arquivo TypeScript local, desde a definição do tipo até a apresentação dos dados na página de edição.