Tutorial React Native & TypeScript - ES2-UFPI/TermoLOG GitHub Wiki
Tutorial em vídeo: https://drive.google.com/file/d/1vkMUWIOGPhheTyFeDZSaaDOoEnunI8ub/view?usp=sharing
Guia básico para qualquer integrante da equipe começar a desenvolver o dashboard web do TermoLOG. Cobre instalação, conceitos essenciais, comunicação com a API FastAPI, persistência (local e no servidor), navegação e gráficos.
Para facilitar, os exemplos usam um site genérico de lista de tarefas. No TermoLOG o padrão é o mesmo, só mudam os dados: onde estiver tarefa, pense em sala ou leitura de temperatura.
[ESP32 + DHT22] --BLE--> [Nó central] --HTTPS--> [FastAPI] --> [PostgreSQL]
^
[Dashboard web: React + TS]
O dashboard é o site onde o usuário acompanha as salas monitoradas, o histórico de temperatura/umidade, o status de alarme e os relatórios de qualidade. Ele faz requisições HTTP para a API, recebe JSON e mostra na tela.
| Escolha | Motivo |
|---|---|
| React | Componentes reutilizáveis (ex.: um cartão de sala usado para todas as salas) e tela que se atualiza sozinha quando os dados mudam |
| TypeScript | Tipos descrevem os dados do mesmo jeito que o Pydantic no FastAPI: se front e back não baterem, o erro aparece no editor |
| Vite | Ambiente de desenvolvimento rápido, com recarregamento instantâneo |
| Ecossistema | Bibliotecas prontas para rotas (React Router) e gráficos (Recharts) |
- Node.js 22 LTS ou mais novo (download)
- Git, VS Code e um navegador atualizado
- Python 3.11 (só para a API de teste da Seção 5)
No Linux, prefira instalar o Node via nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# feche e abra o terminal
nvm install 22
nvm use 22Confira a instalação com node -v e npm -v.
Extensões recomendadas: ES7+ React Snippets, ESLint e Prettier (VS Code) e React Developer Tools (navegador).
npm create vite@latest meu-site -- --template react-ts
cd meu-site
npm install
npm install react-router recharts
npm run devO site sobe em http://localhost:5173. Deixe o terminal aberto: ao salvar um arquivo, a página atualiza sozinha.
index.html única página HTML do site
src/
main.tsx liga o React ao index.html
App.tsx menu e rotas
types.ts tipos dos dados da API
pages/ uma página por rota
components/ peças visuais reutilizáveis
hooks/ lógica (estado + chamadas)
services/ comunicação com a API
storage/ dados salvos no navegador
Essa mesma separação será usada no dashboard do TermoLOG.
import { useEffect, useState } from 'react';
// Props descritas com interface: passar texto={42} vira erro no editor
interface BotaoProps { texto: string; onClick?: () => void; }
function Botao({ texto, onClick }: BotaoProps) {
return <button className="botao" onClick={onClick}>{texto}</button>;
}
function Exemplo() {
const [valor, setValor] = useState(0); // [valor atual, função para alterar] = useState(inicial)
const [itens, setItens] = useState<string[]>([]);
useEffect(() => { setItens(['a', 'b']); }, []); // roda depois de aparecer na tela; [] = só uma vez
return (
<div>
<Botao texto={`Cliques: ${valor}`} onClick={() => setValor((v) => v + 1)} />
{valor > 5 && <p>Mais de 5 cliques!</p>} {/* só aparece se a condição for verdadeira */}
<ul>{itens.map((i) => <li key={i}>{i}</li>)}</ul> {/* listas sempre com key única */}
</div>
);
}| HTML | JSX |
|---|---|
class |
className |
onclick="..." |
onClick={funcao} |
style="color: red" |
style={{ color: 'red' }} |
<input> |
<input /> (toda tag fechada) |
| — |
{expressão} insere qualquer valor JS |
Regra de ouro: nunca mexa no HTML direto (document.getElementById). Mude o estado e o React redesenha a tela.
// src/types.ts — o mesmo formato do JSON que a API devolve
export interface Tarefa {
id: number;
titulo: string;
concluida: boolean;
criada_em: string;
}
export type NovaTarefa = Pick<Tarefa, 'titulo'>;// src/components/Contador.tsx
import { useState } from 'react';
interface ContadorProps { rotulo: string; passo?: number; }
export default function Contador({ rotulo, passo = 1 }: ContadorProps) {
const [valor, setValor] = useState(0);
return (
<p>
{rotulo}: {valor}{' '}
<button onClick={() => setValor((v) => v - passo)}>-{passo}</button>
<button onClick={() => setValor(0)}>Zerar</button>
<button onClick={() => setValor((v) => v + passo)}>+{passo}</button>
</p>
);
}// src/pages/Ola.tsx
import { useState } from 'react';
import Contador from '../components/Contador';
export default function Ola() {
const [nome, setNome] = useState('');
return (
<>
<input value={nome} onChange={(e) => setNome(e.target.value)} placeholder="Seu nome" />
<h1>Olá, {nome || 'mundo'}!</h1>
<Contador rotulo="Contador A" />
<Contador rotulo="Contador B" passo={5} />
</>
);
}Cada Contador tem seu próprio estado: mexer em um não altera o outro. É assim que os cartões de sala do TermoLOG vão funcionar.
setValor((v) => v + passo)calcula o novo valor a partir do anterior — a forma mais segura de atualizar estado.
# backend-mock/main.py
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI()
# o site roda na porta 5173 e a API na 8000: sem CORS o navegador bloqueia as chamadas
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"],
allow_methods=["*"], allow_headers=["*"])
class NovaTarefa(BaseModel):
titulo: str
class AtualizaTarefa(BaseModel):
titulo: str | None = None
concluida: bool | None = None
class Tarefa(NovaTarefa):
id: int
concluida: bool = False
criada_em: datetime
tarefas: dict[int, Tarefa] = {}
proximo_id = 1
@app.get("/tarefas")
def listar():
return list(tarefas.values())
@app.get("/tarefas/{id}")
def buscar(id: int):
if id not in tarefas:
raise HTTPException(404, "Tarefa não encontrada")
return tarefas[id]
@app.post("/tarefas", status_code=201)
def criar(nova: NovaTarefa):
global proximo_id
t = Tarefa(id=proximo_id, titulo=nova.titulo, criada_em=datetime.now())
tarefas[t.id] = t
proximo_id += 1
return t
@app.patch("/tarefas/{id}")
def atualizar(id: int, dados: AtualizaTarefa):
tarefas[id] = buscar(id).model_copy(update=dados.model_dump(exclude_unset=True))
return tarefas[id]
@app.delete("/tarefas/{id}", status_code=204)
def remover(id: int):
buscar(id)
del tarefas[id]pip install fastapi uvicorn
uvicorn main:app --reload --port 8000Em http://localhost:8000/docs o FastAPI mostra a documentação automática (Swagger) para testar cada rota.
# .env (na raiz do projeto React)
VITE_API_URL=http://localhost:8000- A variável precisa começar com
VITE_, senão o código não enxerga. - Após mudar o
.env, reinicie onpm run dev. - Essas variáveis vão para o navegador do usuário: nunca coloque senhas nelas.
// src/services/api.ts
import type { NovaTarefa, Tarefa } from '../types';
const BASE = import.meta.env.VITE_API_URL;
async function request<T>(caminho: string, init?: RequestInit): Promise<T> {
const resp = await fetch(BASE + caminho, {
headers: { 'Content-Type': 'application/json' },
...init,
});
if (!resp.ok) throw new Error(`Erro ${resp.status}`);
return resp.status === 204 ? (undefined as T) : resp.json();
}
export const api = {
listar: () => request<Tarefa[]>('/tarefas'),
buscar: (id: number) => request<Tarefa>(`/tarefas/${id}`),
criar: (nova: NovaTarefa) =>
request<Tarefa>('/tarefas', { method: 'POST', body: JSON.stringify(nova) }),
atualizar: (id: number, dados: Partial<Tarefa>) =>
request<Tarefa>(`/tarefas/${id}`, { method: 'PATCH', body: JSON.stringify(dados) }),
remover: (id: number) => request<void>(`/tarefas/${id}`, { method: 'DELETE' }),
};O genérico <T> faz api.listar() devolver Tarefa[] já tipado: o editor sabe quais campos existem em todo o site.
// src/hooks/useTarefas.ts
import { useEffect, useState } from 'react';
import { api } from '../services/api';
import { lerCache, salvarCache } from '../storage/cache';
import type { Tarefa } from '../types';
export function useTarefas() {
const [tarefas, setTarefas] = useState<Tarefa[]>([]);
const [carregando, setCarregando] = useState(true);
const [erro, setErro] = useState<string | null>(null);
useEffect(() => {
api.listar()
.then((lista) => { setTarefas(lista); salvarCache(lista); })
.catch(() => { // API fora do ar: usa o cache (Seção 6)
setTarefas(lerCache());
setErro('Sem conexão — exibindo dados salvos no navegador');
})
.finally(() => setCarregando(false));
}, []);
// Persistência no servidor (Seção 7)
async function adicionar(titulo: string) {
const nova = await api.criar({ titulo }); // resposta já vem com o id do servidor
setTarefas((atual) => [...atual, nova]);
}
async function alternar(t: Tarefa) {
const atualizada = await api.atualizar(t.id, { concluida: !t.concluida });
setTarefas((atual) => atual.map((x) => (x.id === t.id ? atualizada : x)));
}
async function remover(id: number) {
await api.remover(id);
setTarefas((atual) => atual.filter((x) => x.id !== id));
}
return { tarefas, carregando, erro, adicionar, alternar, remover };
}// src/pages/Tarefas.tsx
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router';
import { useTarefas } from '../hooks/useTarefas';
import { usePreferencias } from '../hooks/usePreferencias';
export default function Tarefas() {
const { tarefas, carregando, erro, adicionar, alternar, remover } = useTarefas();
const { prefs } = usePreferencias();
const [titulo, setTitulo] = useState('');
function enviar(e: FormEvent) {
e.preventDefault(); // sem isso o navegador recarrega a página
if (titulo.trim()) adicionar(titulo).then(() => setTitulo(''));
}
if (carregando) return <p>Carregando...</p>;
const visiveis = prefs.ocultarConcluidas ? tarefas.filter((t) => !t.concluida) : tarefas;
return (
<>
{prefs.nome && <h2>Olá, {prefs.nome}!</h2>}
{erro && <p style={{ background: '#fde68a' }}>{erro}</p>}
<form onSubmit={enviar}>
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} />
<button>Adicionar</button>
</form>
<ul>
{visiveis.map((t) => (
<li key={t.id}>
<input type="checkbox" checked={t.concluida} onChange={() => alternar(t)} />
<Link to={`/tarefa/${t.id}`}>{t.titulo}</Link>
<button onClick={() => remover(t.id)}>🗑</button>
</li>
))}
</ul>
</>
);
}No DevTools (F12) → aba Network dá para ver o GET /tarefas e o JSON de resposta. No TermoLOG será, por exemplo, GET /salas trazendo as temperaturas.
O localStorage já vem no navegador: um dicionário chave → texto que sobrevive ao fechar a aba. Operações: setItem, getItem, removeItem. Objetos são convertidos com JSON.stringify / JSON.parse.
// src/storage/preferencias.ts
export interface Preferencias { nome: string; ocultarConcluidas: boolean; }
const CHAVE = 'meusite:preferencias';
export function lerPreferencias(): Preferencias {
const salvo = localStorage.getItem(CHAVE);
return salvo ? JSON.parse(salvo) : { nome: '', ocultarConcluidas: false };
}
export function salvarPreferencias(p: Preferencias) {
localStorage.setItem(CHAVE, JSON.stringify(p));
}// src/storage/cache.ts — últimas tarefas recebidas, usadas se a API cair
import type { Tarefa } from '../types';
const CHAVE = 'meusite:cache-tarefas';
export const salvarCache = (t: Tarefa[]) => localStorage.setItem(CHAVE, JSON.stringify(t));
export const lerCache = (): Tarefa[] => JSON.parse(localStorage.getItem(CHAVE) ?? '[]');// src/hooks/usePreferencias.ts
import { useState } from 'react';
import { lerPreferencias, salvarPreferencias, type Preferencias } from '../storage/preferencias';
export function usePreferencias() {
const [prefs, setPrefs] = useState(lerPreferencias); // já começa com o que estava salvo
function atualizar(mudanca: Partial<Preferencias>) {
const novas = { ...prefs, ...mudanca };
setPrefs(novas);
salvarPreferencias(novas);
}
return { prefs, atualizar };
}// src/pages/Configuracoes.tsx
import { usePreferencias } from '../hooks/usePreferencias';
export default function Configuracoes() {
const { prefs, atualizar } = usePreferencias();
return (
<>
<input value={prefs.nome} onChange={(e) => atualizar({ nome: e.target.value })} placeholder="Seu nome" />
<label>
<input type="checkbox" checked={prefs.ocultarConcluidas}
onChange={(e) => atualizar({ ocultarConcluidas: e.target.checked })} />
Ocultar tarefas concluídas
</label>
</>
);
}Recarregue a página (F5): as preferências continuam lá. Em DevTools → Application → Local Storage dá para ver o que foi salvo. Parando a API e recarregando, a lista vem do cache com o aviso de "sem conexão" — no TermoLOG, o dashboard continua mostrando as últimas leituras se o servidor cair por um momento.
| Local | Servidor | |
|---|---|---|
| Onde fica | Navegador | Banco de dados |
| Quem vê | Só este usuário | Todos os usuários |
| Uso | Preferências, cache | Dados principais (limites de alarme, relatórios) |
| Como | localStorage |
POST / PATCH / DELETE
|
O padrão (já implementado em adicionar, alternar e remover na Seção 5.4) é sempre: envia para o servidor → usa a resposta para atualizar o estado.
⚠️ Nunca altere o array do estado direto (tarefas.push(...)). Sempre crie um array novo com...,mapoufilter— é assim que o React percebe a mudança.
Para conferir, abra http://localhost:8000/tarefas: as alterações feitas no site estão lá, e aparecem iguais em qualquer navegador.
Um site React é uma SPA: o navegador carrega um único index.html e o React Router troca o componente conforme a URL, sem recarregar.
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter><App /></BrowserRouter>
</StrictMode>,
);// src/App.tsx
import { NavLink, Route, Routes } from 'react-router';
import Tarefas from './pages/Tarefas';
import DetalheTarefa from './pages/DetalheTarefa';
import Estatisticas from './pages/Estatisticas';
import Configuracoes from './pages/Configuracoes';
import Ola from './pages/Ola';
export default function App() {
return (
<>
<nav> {/* NavLink marca sozinho o link da página atual */}
<NavLink to="/">Tarefas</NavLink> | <NavLink to="/estatisticas">Estatísticas</NavLink> |{' '}
<NavLink to="/configuracoes">Configurações</NavLink> | <NavLink to="/ola">Olá</NavLink>
</nav>
<Routes>
<Route path="/" element={<Tarefas />} />
<Route path="/tarefa/:id" element={<DetalheTarefa />} /> {/* rota dinâmica */}
<Route path="/estatisticas" element={<Estatisticas />} />
<Route path="/configuracoes" element={<Configuracoes />} />
<Route path="/ola" element={<Ola />} />
<Route path="*" element={<h1>404 — Página não encontrada</h1>} />
</Routes>
</>
);
}// src/pages/DetalheTarefa.tsx
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { api } from '../services/api';
export default function DetalheTarefa() {
const id = Number(useParams().id); // parâmetro da URL sempre chega como texto
const navigate = useNavigate();
const [titulo, setTitulo] = useState('');
const [msg, setMsg] = useState('');
useEffect(() => { api.buscar(id).then((t) => setTitulo(t.titulo)); }, [id]);
async function salvar() {
await api.atualizar(id, { titulo });
setMsg('Salvo no servidor!');
}
return (
<>
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} />
<button onClick={salvar}>Salvar</button>
<button onClick={() => navigate(-1)}>Voltar</button>
{msg && <p>{msg}</p>}
</>
);
}- Use
<Link to="...">em vez de<a href>: o<a>recarrega o site inteiro e o estado se perde. -
useNavigate()navega pelo código (navigate(-1)volta uma página). -
useParams()lê os parâmetros da URL.
No Recharts o gráfico é montado com componentes React: os dados são uma lista de objetos e o dataKey diz qual campo usar.
// src/pages/Estatisticas.tsx
import { Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { useTarefas } from '../hooks/useTarefas';
export default function Estatisticas() {
const { tarefas } = useTarefas();
const status = [
{ nome: 'Concluídas', valor: tarefas.filter((t) => t.concluida).length },
{ nome: 'Pendentes', valor: tarefas.filter((t) => !t.concluida).length },
];
const contagem: Record<string, number> = {};
for (const t of tarefas) {
const dia = t.criada_em.slice(0, 10);
contagem[dia] = (contagem[dia] ?? 0) + 1;
}
const porDia = Object.entries(contagem).map(([dia, total]) => ({ dia, total }));
return (
<>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={status} dataKey="valor" nameKey="nome" label>
<Cell fill="#22c55e" />
<Cell fill="#f59e0b" />
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={porDia}>
<XAxis dataKey="dia" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="total" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</>
);
}No TermoLOG, o mesmo recurso vira um LineChart com a temperatura de cada sala ao longo do tempo.
| Sintoma | Solução |
|---|---|
| Erro de CORS no console | Habilitar o CORSMiddleware na API |
Mudou o .env e nada aconteceu |
Reiniciar o npm run dev
|
Variável de ambiente undefined
|
Faltou o prefixo VITE_
|
Aviso de key em lista |
key={item.id} em cada item |
| Tela não atualiza | Usar useState e criar um array/objeto novo
|
| Página recarrega ao clicar num link | Trocar <a href> por <Link to>
|
Para publicar: npm run build verifica os tipos e gera a pasta dist/, que pode ser hospedada em qualquer servidor estático.