O blog completo — tudo junto.
HomePage com busca e filtro por tag, ArticlePage com leitura completa e botão de curtir. Props, estado, efeitos e Context integrados num projeto real.
As lições anteriores construíram cada peça separadamente: JSX, componentes, props, estado, efeitos, eventos, Context. Agora essas peças se juntam num projeto completo — o blog React com TypeScript. A HomePage lista artigos com filtro por texto e por tag. A ArticlePage exibe o artigo completo com barra de progresso e curtida persistida. Ambas reutilizam os mesmos componentes e hooks.
A árvore de componentes
Antes de escrever código, vale ter clareza de onde cada coisa vive:
App
├── TemaProvider ← tema global (Context)
├── CurtidasProvider ← curtidas globais (Context)
│ ├── HomePage
│ │ ├── Header
│ │ │ └── ThemeToggle
│ │ ├── SearchInput ← query (estado local de HomePage)
│ │ ├── TagFilter ← tagAtiva (estado local de HomePage)
│ │ └── ArticleList
│ │ └── ArticleCard × N
│ │ └── LikeButton
│ └── ArticlePage
│ ├── Header
│ │ └── ThemeToggle
│ ├── ProgressBar ← progresso (estado local de ArticlePage)
│ ├── ArticleBody
│ └── LikeButton O mapa do estado:
tema→TemaProvider(Context) — lido porHeadere aplicado nodocumentcurtidos→CurtidasProvider(Context) — lido e alterado por qualquerLikeButtonquery→HomePage(estado local) — sóHomePageusa para filtrartagAtiva→HomePage(estado local) — idemartigos→HomePage(estado local, carregado viauseArtigos)progresso→ArticlePage(estado local) — só a barra de progresso usa
HomePage — lista com filtro
A HomePage gerencia dois estados locais (query e tagAtiva) e usa o hook useArtigos para carregar os dados. O filtro é calculado durante o render — sem useEffect:
import { useState } from "react";
import { useArtigos } from "../hooks/useArtigos";
import { Header } from "../components/Header";
import { ArticleList } from "../components/ArticleList";
export function HomePage() {
const { artigos, carregando, erro } = useArtigos();
const [query, setQuery] = useState("");
const [tagAtiva, setTagAtiva] = useState<string | null>(null);
// filtro calculado no render — sem useEffect, sem estado derivado
const artigosFiltrados = artigos.filter(artigo => {
const matchQuery =
artigo.titulo.toLowerCase().includes(query.toLowerCase()) ||
artigo.descricao.toLowerCase().includes(query.toLowerCase());
const matchTag = tagAtiva ? artigo.tags.includes(tagAtiva) : true;
return matchQuery && matchTag;
});
// todas as tags únicas para o filtro
const todasTags = [...new Set(artigos.flatMap(a => a.tags))].sort();
return (
<main>
<Header />
<div className="controles-busca">
<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Buscar artigos..."
aria-label="Buscar artigos"
/>
<div className="filtro-tags" role="group" aria-label="Filtrar por tag">
<button
className={tagAtiva === null ? "tag ativa" : "tag"}
onClick={() => setTagAtiva(null)}
>
Todos
</button>
{todasTags.map(tag => (
<button
key={tag}
className={tagAtiva === tag ? "tag ativa" : "tag"}
onClick={() => setTagAtiva(prev => prev === tag ? null : tag)}
>
{tag}
</button>
))}
</div>
</div>
{carregando && <div className="skeleton-lista" aria-label="Carregando artigos..." />}
{erro && <p className="mensagem-erro">Erro ao carregar artigos: {erro}</p>}
{!carregando && !erro && (
<ArticleList artigos={artigosFiltrados} />
)}
</main>
);
} O ArticleList recebe apenas os artigos já filtrados — não sabe que há busca e filtro de tag. Esse isolamento é intencional: ArticleList é responsável apenas por renderizar uma lista de artigos que recebe. A lógica de filtro fica em quem gerencia o estado.
ArticleCard com curtida
O ArticleCard consome o CurtidasContext via useCurtidas — sabe se o artigo está curtido e pode alternar sem receber o estado como prop:
import { useCurtidas } from "../context/CurtidasContext";
import { LikeButton } from "./LikeButton";
import type { Artigo } from "../types";
interface ArticleCardProps {
artigo: Artigo;
}
export function ArticleCard({ artigo }: ArticleCardProps) {
const { curtidos, toggleCurtida } = useCurtidas();
const curtido = curtidos.has(artigo.id);
const dataFormatada = artigo.publicadoEm
? new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium" }).format(
new Date(artigo.publicadoEm)
)
: null;
return (
<article className={`card-artigo ${curtido ? "card-curtido" : ""}`}>
<div className="card-tags">
{artigo.tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
<h2 className="card-titulo">{artigo.titulo}</h2>
<p className="card-descricao">{artigo.descricao}</p>
<div className="card-rodape">
<div className="card-meta">
<span>{artigo.autor.nome}</span>
{dataFormatada && (
<time dateTime={artigo.publicadoEm ?? ""}>{dataFormatada}</time>
)}
</div>
<LikeButton
curtido={curtido}
contagem={artigo.curtidas + (curtido ? 1 : 0)}
onCurtir={() => toggleCurtida(artigo.id)}
/>
</div>
</article>
);
} ArticlePage — leitura completa
A ArticlePage tem três responsabilidades: carregar o artigo, rastrear o progresso de leitura e exibir o botão de curtir. Cada uma vai para o lugar certo:
import { useState, useEffect } from "react";
import type { Artigo } from "../types";
export function useArtigo(slug: string) {
const [artigo, setArtigo] = useState<Artigo | null>(null);
const [carregando, setCarregando] = useState(true);
const [erro, setErro] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/artigos/${slug}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`Artigo não encontrado`);
return res.json() as Promise<Artigo>;
})
.then(dados => {
setArtigo(dados);
setCarregando(false);
})
.catch(err => {
if (err.name !== "AbortError") {
setErro(err.message);
setCarregando(false);
}
});
return () => controller.abort();
}, [slug]);
return { artigo, carregando, erro };
} import { useState, useEffect } from "react";
import { useArtigo } from "../hooks/useArtigo";
import { useCurtidas } from "../context/CurtidasContext";
import { Header } from "../components/Header";
import { ProgressBar } from "../components/ProgressBar";
import { LikeButton } from "../components/LikeButton";
interface ArticlePageProps {
slug: string;
}
export function ArticlePage({ slug }: ArticlePageProps) {
const { artigo, carregando, erro } = useArtigo(slug);
const { curtidos, toggleCurtida } = useCurtidas();
const [progresso, setProgresso] = useState(0);
// barra de progresso — listener de scroll
useEffect(() => {
function calcularProgresso() {
const scrollAtual = window.scrollY;
const alturaTotal = document.documentElement.scrollHeight - window.innerHeight;
const percentual = alturaTotal > 0 ? (scrollAtual / alturaTotal) * 100 : 0;
setProgresso(Math.round(percentual));
}
window.addEventListener("scroll", calcularProgresso, { passive: true });
return () => window.removeEventListener("scroll", calcularProgresso);
}, []); // [] — o listener roda por toda a vida da página
if (carregando) return <div className="skeleton-artigo" aria-label="Carregando..." />;
if (erro) return <p className="mensagem-erro">{erro}</p>;
if (!artigo) return null;
const curtido = curtidos.has(artigo.id);
return (
<main>
<Header />
<ProgressBar valor={progresso} />
<article className="artigo-completo">
<header className="artigo-header">
<div className="artigo-tags">
{artigo.tags.map(tag => <span key={tag} className="tag">{tag}</span>)}
</div>
<h1 className="artigo-titulo">{artigo.titulo}</h1>
<p className="artigo-descricao">{artigo.descricao}</p>
<div className="artigo-meta">
<span>{artigo.autor.nome}</span>
{artigo.publicadoEm && (
<time dateTime={artigo.publicadoEm}>
{new Intl.DateTimeFormat("pt-BR", { dateStyle: "long" }).format(
new Date(artigo.publicadoEm)
)}
</time>
)}
</div>
</header>
<div
className="artigo-corpo"
dangerouslySetInnerHTML={{ __html: artigo.conteudo }}
/>
<footer className="artigo-rodape">
<LikeButton
curtido={curtido}
contagem={artigo.curtidas + (curtido ? 1 : 0)}
onCurtir={() => toggleCurtida(artigo.id)}
/>
</footer>
</article>
</main>
);
} ProgressBar e LikeButton
Componentes pequenos que recebem tudo via props — sem estado próprio, sem lógica de negócio:
interface ProgressBarProps {
valor: number; // 0–100
}
export function ProgressBar({ valor }: ProgressBarProps) {
return (
<div
className="barra-progresso"
role="progressbar"
aria-valuenow={valor}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Progresso de leitura"
>
<div
className="barra-progresso-preenchimento"
style={{ width: `${valor}%` }}
/>
</div>
);
} interface LikeButtonProps {
curtido: boolean;
contagem: number;
onCurtir: () => void;
}
export function LikeButton({ curtido, contagem, onCurtir }: LikeButtonProps) {
return (
<button
className={`botao-curtir ${curtido ? "curtido" : ""}`}
aria-pressed={curtido}
onClick={onCurtir}
>
{curtido ? "❤️" : "🤍"} {contagem} {contagem === 1 ? "curtida" : "curtidas"}
</button>
);
} O que esse projeto demonstra
Olhando a aplicação completa, os padrões aprendidos aparecem no lugar certo:
useStatelocal:query,tagAtivaeprogressosão estados que só importam para um componente. Ficam no componente que os usa.- Lifting state up:
curtidosetemaprecisam ser compartilhados por toda a aplicação — ficam em Providers de Context. - Custom hooks:
useArtigoseuseArtigoisolam a lógica de fetch dos componentes. Componentes focam em renderizar; hooks focam em buscar dados. - Componentes controlados:
LikeButtoneProgressBarnão têm estado — são controlados por quem os usa. Mais simples de testar e reutilizar. - Renderização condicional:
if (carregando),if (erro),if (!artigo)protegem o render principal e afunilam o TypeScript. - Derivação no render:
artigosFiltradosetodasTagssão calculados a cada render com base no estado atual — semuseEffect, sem estado extra.
Resumo
- A
HomePagegerencia estado local dequeryetagAtiva, filtra os artigos no render (não em efeito), e delega a renderização paraArticleList. - A
ArticlePageusa um custom hook para o fetch, umuseEffectpara o scroll, e lê curtidas via Context. LikeButtoneProgressBarsão componentes controlados — recebem tudo via props, sem estado próprio.- Estado no lugar certo: local para o que é local, Context para o que é global.
- Componentes pequenos com uma responsabilidade cada — o resultado é uma árvore que você consegue raciocinar parte por parte.
Por que o filtro de artigos por query e tag não usa useEffect?
Por que o estado de curtidas vive no Context e não dentro de cada ArticleCard?
O que é necessário para que a barra de progresso de leitura funcione com scroll?
Qual é o motivo de separar a lógica de fetchArtigo em um custom hook useArtigo?
Aula concluída
Quase lá.