react

Referência React & Next.js

Exemplos práticos de código para consulta rápida, componentes, hooks, tipagem, formulários e mais.

JSX & Composição

JSX é açúcar sintático sobre React.createElement. Composição é o principal padrão para reutilização em React.

jsx-composition.tsx
// JSX básico :: retorna elementos React
function Greeting({ name }: { name: string }) {
  return <h1>Olá, {name}!</h1>;
}

// Composição via children
function Card({ children }: { children: React.ReactNode }) {
  return (
    <div className="rounded-lg border p-4">
      {children}
    </div>
  );
}

// Composição via render props / slot nomeado
function Layout({
  sidebar,
  children,
}: {
  sidebar: React.ReactNode;
  children: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[240px_1fr] gap-6">
      <aside>{sidebar}</aside>
      <main>{children}</main>
    </div>
  );
}

// Uso
function App() {
  return (
    <Layout sidebar={<nav>Menu</nav>}>
      <Card>
        <Greeting name="Marco" />
        <p>Conteúdo principal aqui.</p>
      </Card>
    </Layout>
  );
}

Props tipadas com TypeScript

typed-props.tsx
// Props opcionais com defaults
type ButtonProps = {
  children: React.ReactNode;
  variant?: "primary" | "secondary" | "ghost";
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  onClick?: () => void;
  type?: "button" | "submit" | "reset";
};

export function Button({
  children,
  variant = "primary",
  size = "md",
  disabled = false,
  onClick,
  type = "button",
}: ButtonProps) {
  const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors";

  const variants = {
    primary:   "bg-sky-500 text-white hover:bg-sky-400 disabled:bg-sky-900",
    secondary: "border border-slate-700 text-slate-200 hover:bg-slate-800",
    ghost:     "text-slate-400 hover:text-slate-100 hover:bg-slate-800",
  };

  const sizes = {
    sm: "text-xs px-3 py-1.5",
    md: "text-sm px-4 py-2",
    lg: "text-base px-5 py-2.5",
  };

  return (
    <button
      type={type}
      disabled={disabled}
      onClick={onClick}
      className={`${base} ${variants[variant]} ${sizes[size]}`}
    >
      {children}
    </button>
  );
}
discriminated-union-props.tsx
// Union discriminada para variantes mutuamente exclusivas
type AlertProps =
  | { variant: "info";    message: string }
  | { variant: "success"; message: string; onDismiss: () => void }
  | { variant: "error";   message: string; retry?: () => void };

function Alert(props: AlertProps) {
  if (props.variant === "success") {
    return (
      <div className="flex justify-between">
        <span>{props.message}</span>
        <button onClick={props.onDismiss}>✕</button>
      </div>
    );
  }
  return <div>{props.message}</div>;
}

useState

ℹ️ Info

useState é síncrono na definição mas assíncrono na aplicação, o estado atualizado só fica disponível no próximo render.
use-state.tsx
"use client";
import { useState } from "react";

// Estado primitivo
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div className="flex items-center gap-3">
      <button onClick={() => setCount((c) => c - 1)}>−</button>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

Estado de objeto :: sempre espalhe o estado anterior!
type Form = { name: string; email: string };

function ContactForm() {
  const [form, setForm] = useState<Form>({ name: "", email: "" });

  const update = (field: keyof Form) =>
    (e: React.ChangeEvent<HTMLInputElement>) =>
      setForm((prev) => ({ ...prev, [field]: e.target.value }));

  return (
    <form>
      <input value={form.name}  onChange={update("name")}  placeholder="Nome" />
      <input value={form.email} onChange={update("email")} placeholder="Email" />
    </form>
  );
}

// Estado derivado :: evite derivar estado com useState
// ✅ calcule na renderização
function FilteredList({ items }: { items: string[] }) {
  const [query, setQuery] = useState("");
  const filtered = items.filter((i) => i.includes(query)); // derivado direto

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>{filtered.map((i) => <li key={i}>{i}</li>)}</ul>
    </>
  );
}

useEffect

⚠️ Atenção

useEffect é um mecanismo de sincronização com efeitos externos, não um lifecycle hook. Evite usá-lo para derivar estado ou responder a eventos de usuário.
use-effect.tsx
"use client";
import { useState, useEffect } from "react";

// Sincronização com API externa
function useWindowWidth() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);

    const handler = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handler);
    return () => window.removeEventListener("resize", handler); // cleanup!
  }, []); // [] = roda apenas na montagem

  return width;
}

// Effect com dependência
function UserCard({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    let cancelled = false; // evita race condition

    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then((data) => { if (!cancelled) setUser(data); });

    return () => { cancelled = true; };
  }, [userId]); // re-executa quando userId muda

  if (!user) return <div>Carregando...</div>;
  return <div>{user.name}</div>;
}

// ❌ NÃO faça isso :: use TanStack Query / SWR em vez disso
// useEffect para data fetching é propenso a bugs

useMemo & useCallback

useMemo memoriza o resultado de um cálculo caro. useCallback memoriza a referência de uma função. Ambos são otimizações :: não os use por padrão, use quando há problema de performance.

use-memo-callback.tsx
"use client";
import { useState, useMemo, useCallback, memo } from "react";

// useMemo :: cálculo caro
function SortedList({ items }: { items: number[] }) {
  const [multiplier, setMultiplier] = useState(1);

  // Só recalcula quando items ou multiplier mudam
  const processed = useMemo(
    () => items.map((n) => n * multiplier).sort((a, b) => a - b),
    [items, multiplier]
  );

  return <ul>{processed.map((n) => <li key={n}>{n}</li>)}</ul>;
}

// useCallback :: estabiliza referência de função para componente filho memorizado
const Row = memo(function Row({
  item,
  onDelete,
}: {
  item: string;
  onDelete: (item: string) => void;
}) {
  return (
    <li>
      {item}
      <button onClick={() => onDelete(item)}>Remover</button>
    </li>
  );
});

function List({ items }: { items: string[] }) {
  const [list, setList] = useState(items);

  // Sem useCallback, Row re-renderiza em cada render do List
  const handleDelete = useCallback((item: string) => {
    setList((prev) => prev.filter((i) => i !== item));
  }, []);

  return (
    <ul>
      {list.map((i) => (
        <Row key={i} item={i} onDelete={handleDelete} />
      ))}
    </ul>
  );
}

useRef

useRef tem dois usos principais: referência a um elemento DOM e manter valor mutável entre renders sem causar re-render.

use-ref.tsx
"use client";
import { useRef, useEffect } from "react";

// Ref de elemento DOM
function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="Focado automaticamente" />;
}

// Ref de valor mutable (não causa re-render)
function Stopwatch() {
  const [running, setRunning] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  function start() {
    setRunning(true);
    intervalRef.current = setInterval(() => {
      setElapsed((e) => e + 10);
    }, 10);
  }

  function stop() {
    setRunning(false);
    if (intervalRef.current) clearInterval(intervalRef.current);
  }

  return (
    <div>
      <span>{(elapsed / 1000).toFixed(2)}s</span>
      <button onClick={running ? stop : start}>
        {running ? "Parar" : "Iniciar"}
      </button>
    </div>
  );
}

// forwardRef :: expor ref para componente pai
import { forwardRef } from "react";

const Input = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
  (props, ref) => <input ref={ref} className="border rounded px-3 py-2" {...props} />
);
Input.displayName = "Input";

Context API

💡 Dica

Context é para estado global de leitura frequente mas escrita pouco frequente. Para estado complexo que muda muito, considere Zustand ou Jotai.
context-api.tsx
"use client";
import { createContext, useContext, useState, ReactNode } from "react";

// 1. Definir o tipo e criar o contexto
type Theme = "dark" | "light";
type ThemeContextType = {
  theme: Theme;
  toggle: () => void;
};

const ThemeContext = createContext<ThemeContextType | null>(null);

// 2. Provider
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("dark");
  const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

// 3. Hook de consumo (garante que está dentro do Provider)
export function useTheme(): ThemeContextType {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme deve ser usado dentro de ThemeProvider");
  return ctx;
}

// 4. Uso em componente filho
function ThemeToggle() {
  const { theme, toggle } = useTheme();
  return (
    <button onClick={toggle}>
      Tema atual: {theme}
    </button>
  );
}

Custom Hooks

Custom hooks encapsulam lógica stateful reutilizável. Devem começar com use para que o React aplique as regras dos hooks.

custom-hooks.tsx
"use client";
import { useState, useEffect, useCallback } from "react";

// Hook de fetch genérico
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetch(url)
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json() as Promise<T>;
      })
      .then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
      .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } });

    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

// Hook de debounce
function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

// Hook de local storage
function useLocalStorage<T>(key: string, initial: T) {
  const [stored, setStored] = useState<T>(() => {
    if (typeof window === "undefined") return initial;
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initial;
    } catch { return initial; }
  });

  const setValue = useCallback((value: T | ((prev: T) => T)) => {
    setStored((prev) => {
      const next = typeof value === "function" ? (value as (p: T) => T)(prev) : value;
      localStorage.setItem(key, JSON.stringify(next));
      return next;
    });
  }, [key]);

  return [stored, setValue] as const;
}

// Uso
function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 400);
  const { data, loading } = useFetch<string[]>(`/api/search?q=${debouncedQuery}`);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {loading ? "Buscando..." : data?.map((r) => <div key={r}>{r}</div>)}
    </div>
  );
}

Server Components vs Client Components

Server Components (padrão no App Router)

  • Renderizados no servidor, nunca enviados ao browser como JS
  • Podem acessar banco de dados, APIs privadas, filesystem diretamente
  • Sem bundle no cliente, melhor performance
  • Não podem usar hooks, eventos, browser APIs

Client Components ("use client")

  • Hydratados no browser após o HTML inicial
  • Podem usar hooks, useState, eventos
  • Aumentam o bundle do cliente, use com moderação
server-component.tsx
// Server Component (sem "use client")
// Pode fazer fetch diretamente, sem useEffect!
async function UserProfile({ userId }: { userId: string }) {
  // Fetch direto no servidor, não vai para o bundle do browser
  const user = await db.users.findById(userId);
  const posts = await db.posts.findByUserId(userId, { limit: 5 });

  if (!user) notFound();

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      {/* Passa dados para um Client Component que precisa de interatividade */}
      <LikeButton postIds={posts.map((p) => p.id)} />
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}
client-component.tsx
"use client";
// Client Component, precisa de interatividade
import { useState } from "react";

export function LikeButton({ postIds }: { postIds: string[] }) {
  const [liked, setLiked] = useState<Set<string>>(new Set());

  const toggle = (id: string) =>
    setLiked((prev) => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });

  return (
    <div>
      {postIds.map((id) => (
        <button
          key={id}
          onClick={() => toggle(id)}
          className={liked.has(id) ? "text-red-500" : "text-slate-400"}
        >
          {liked.has(id) ? "❤️" : "🤍"} {id}
        </button>
      ))}
    </div>
  );
}

"use client" quando usar e quando evitar

⚠️ Atenção

"use client" marca um componente e TODOS os seus descendentes como Client Components. Coloque-o o mais profundo possível na árvore.
use-client-placement.tsx
// ❌ Evite marca a página inteira como client
"use client";
export default function Page() {
  return (
    <div>
      <HeavyServerContent />  {/* perdeu os benefícios de Server Component */}
      <Counter />
    </div>
  );
}

// ✅ Correto : isola "use client" no menor componente possível
// app/page.tsx (Server Component)
import Counter from "./Counter"; // client component isolado

export default function Page() {
  return (
    <div>
      <HeavyServerContent />  {/* ainda é Server Component */}
      <Counter />             {/* somente Counter é client */}
    </div>
  );
}

// Counter.tsx
"use client";
import { useState } from "react";
export default function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

Formulários

controlled-form.tsx
"use client";
import { useState, FormEvent } from "react";

type FormState = {
  name: string;
  email: string;
  message: string;
};

type SubmitState = "idle" | "loading" | "success" | "error";

export function ContactForm() {
  const [form, setForm] = useState<FormState>({ name: "", email: "", message: "" });
  const [status, setStatus] = useState<SubmitState>("idle");
  const [errors, setErrors] = useState<Partial<FormState>>({});

  const validate = (): boolean => {
    const e: Partial<FormState> = {};
    if (!form.name.trim()) e.name = "Nome é obrigatório";
    if (!form.email.includes("@")) e.email = "Email inválido";
    if (form.message.length < 10) e.message = "Mínimo 10 caracteres";
    setErrors(e);
    return Object.keys(e).length === 0;
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    if (!validate()) return;

    setStatus("loading");
    try {
      await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });
      setStatus("success");
    } catch {
      setStatus("error");
    }
  };

  const field = (key: keyof FormState) => ({
    value: form[key],
    onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
      setForm((prev) => ({ ...prev, [key]: e.target.value })),
  });

  if (status === "success") return <p>Mensagem enviada! ✓</p>;

  return (
    <form onSubmit={handleSubmit} noValidate className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium mb-1">Nome</label>
        <input id="name" type="text" {...field("name")}
          aria-describedby={errors.name ? "name-err" : undefined}
          className="w-full border rounded px-3 py-2 bg-slate-900"
        />
        {errors.name && <p id="name-err" role="alert" className="text-red-400 text-xs mt-1">{errors.name}</p>}
      </div>
      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">Email</label>
        <input id="email" type="email" {...field("email")}
          className="w-full border rounded px-3 py-2 bg-slate-900"
        />
        {errors.email && <p role="alert" className="text-red-400 text-xs mt-1">{errors.email}</p>}
      </div>
      <div>
        <label htmlFor="message" className="block text-sm font-medium mb-1">Mensagem</label>
        <textarea id="message" rows={4} {...field("message")}
          className="w-full border rounded px-3 py-2 bg-slate-900 resize-none"
        />
        {errors.message && <p role="alert" className="text-red-400 text-xs mt-1">{errors.message}</p>}
      </div>
      <button type="submit" disabled={status === "loading"}
        className="bg-sky-500 hover:bg-sky-400 text-white px-4 py-2 rounded font-medium disabled:opacity-50"
      >
        {status === "loading" ? "Enviando..." : "Enviar"}
      </button>
      {status === "error" && <p role="alert" className="text-red-400 text-sm">Erro ao enviar. Tente novamente.</p>}
    </form>
  );
}

Listas & Keys

⚠️ Atenção

Nunca use o índice do array como key quando a lista pode ser reordenada ou filtrada. Use um ID único e estável.
lists-keys.tsx
// ❌ Índice como key : problemático com listas dinâmicas
items.map((item, index) => <Row key={index} item={item} />);

// ✅ ID estável como key
items.map((item) => <Row key={item.id} item={item} />);

// Componente de lista com renderização otimizada
type Todo = { id: string; text: string; done: boolean };

function TodoList({ todos }: { todos: Todo[] }) {
  if (todos.length === 0) {
    return <p className="text-slate-500">Nenhuma tarefa.</p>;
  }

  return (
    <ul role="list" className="space-y-2">
      {todos.map((todo) => (
        <li
          key={todo.id}
          className={`flex items-center gap-3 p-3 rounded border ${
            todo.done ? "border-slate-800 opacity-50" : "border-slate-700"
          }`}
        >
          <span className={`flex-1 ${todo.done ? "line-through" : ""}`}>
            {todo.text}
          </span>
        </li>
      ))}
    </ul>
  );
}

Renderização condicional

conditional-rendering.tsx
// Formas de renderização condicional em JSX

function StatusBadge({ status }: { status: "active" | "inactive" | "pending" }) {
  // 1. Objeto de variantes (preferível para muitos casos)
  const config = {
    active:   { label: "Ativo",     className: "bg-green-900 text-green-400" },
    inactive: { label: "Inativo",   className: "bg-slate-800 text-slate-400" },
    pending:  { label: "Pendente",  className: "bg-yellow-900 text-yellow-400" },
  };
  const { label, className } = config[status];
  return <span className={`px-2 py-0.5 rounded text-xs ${className}`}>{label}</span>;
}

// 2. && - cuidado com valores falsy (0 renderiza!)
function Notification({ count }: { count: number }) {
  return (
    <div>
      {count > 0 && <span className="badge">{count}</span>}
      {/* ✅ count > 0 evita renderizar "0" */}
    </div>
  );
}

// 3. Ternário, bom para alternativas binárias
function LoginButton({ isLoggedIn }: { isLoggedIn: boolean }) {
  return isLoggedIn
    ? <button>Sair</button>
    : <button>Entrar</button>;
}

// 4. Early return, bom para estados de loading/error
function UserCard({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <Skeleton />;
  if (error)   return <ErrorMessage error={error} />;
  if (!user)   return null;

  return <div>{user.name}</div>;
}

Error Boundaries no Next.js

No App Router, error.tsx é o mecanismo nativo de Error Boundary por segmento de rota.

app/[locale]/dashboard/error.tsx
"use client"; // Error components devem ser Client Components

import { useEffect } from "react";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log para serviço de monitoramento (Sentry, etc.)
    console.error("Dashboard error:", error);
  }, [error]);

  return (
    <div className="flex flex-col items-center gap-4 py-16 text-center">
      <h2 className="text-xl font-semibold text-red-400">
        Algo deu errado
      </h2>
      <p className="text-slate-400 text-sm max-w-sm">
        {error.message || "Erro inesperado. Tente novamente."}
      </p>
      <button
        onClick={reset}
        className="bg-sky-500 text-white px-4 py-2 rounded text-sm"
      >
        Tentar novamente
      </button>
    </div>
  );
}

Suspense & Loading

app/[locale]/dashboard/loading.tsx
// loading.tsx fallback automático durante navegação
export default function DashboardLoading() {
  return (
    <div className="space-y-4 animate-pulse">
      <div className="h-8 bg-slate-800 rounded w-1/3" />
      <div className="grid grid-cols-3 gap-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="h-32 bg-slate-800 rounded" />
        ))}
      </div>
      <div className="h-64 bg-slate-800 rounded" />
    </div>
  );
}
suspense-streaming.tsx
import { Suspense } from "react";

// Componente lento (async Server Component)
async function SlowWidget() {
  await new Promise((r) => setTimeout(r, 2000)); // simula latência
  const data = await fetch("/api/analytics").then((r) => r.json());
  return <div>{data.value}</div>;
}

// Streaming com Suspense,  o resto da página carrega imediatamente
export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<div className="h-20 bg-slate-800 animate-pulse rounded" />}>
          <SlowWidget />
        </Suspense>
        <Suspense fallback={<div className="h-20 bg-slate-800 animate-pulse rounded" />}>
          <AnotherSlowWidget />
        </Suspense>
      </div>
    </div>
  );
}

Data Fetching com Server Components

ℹ️ Info

No App Router, fetch em Server Components tem cache automático por padrão. Use cache: 'no-store' para dados sempre frescos ou revalidate para ISR.
server-data-fetching.tsx
// Server Component, fetch direto, sem useEffect
async function ProductPage({ params }: { params: { id: string } }) {
  // Cache padrão: memoizado por request (Next.js 15)
  const product = await fetch(`https://api.marcocianci.com/products/${params.id}`).then(r => r.json());

  // Sem cache, sempre busca dados frescos
  const inventory = await fetch(`https://api.marcocianci.com/inventory/${params.id}`, {
    cache: "no-store",
  }).then(r => r.json());

  // Revalidação periódica (ISR-like)
  const reviews = await fetch(`https://api.marcocianci.com/reviews/${params.id}`, {
    next: { revalidate: 3600 }, // revalida a cada 1 hora
  }).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <p>Estoque: {inventory.available}</p>
      <ReviewList reviews={reviews} />
    </div>
  );
}

// Acesso ao banco direto (Drizzle, Prisma, etc.)
async function UserProfile({ id }: { id: string }) {
  const user = await db.query.users.findFirst({
    where: (users, { eq }) => eq(users.id, id),
    with: { posts: true },
  });

  if (!user) notFound();
  return <div>{user.name}</div>;
}

Fetches paralelos, evitando waterfall

⚠️ Atenção

Await sequencial cria waterfall: cada fetch espera o anterior terminar. Use Promise.all para paralelizar.
parallel-fetches.tsx
// ❌ Waterfall, total = userTime + postsTime + ordersTime
async function ProfilePage({ userId }: { userId: string }) {
  const user   = await fetchUser(userId);    // 200ms
  const posts  = await fetchPosts(userId);  // 150ms
  const orders = await fetchOrders(userId); // 100ms
  // Total: 450ms
}

// ✅ Paralelo, total = max(userTime, postsTime, ordersTime)
async function ProfilePage({ userId }: { userId: string }) {
  const [user, posts, orders] = await Promise.all([
    fetchUser(userId),    // }
    fetchPosts(userId),   // } executam ao mesmo tempo
    fetchOrders(userId),  // }
  ]);
  // Total: ~200ms
}

// ✅ Alternativa com Suspense streaming, melhor para UX
// Inicia os fetches sem await para que corram em paralelo
async function ProfilePage({ userId }: { userId: string }) {
  const userPromise   = fetchUser(userId);   // inicia imediatamente
  const postsPromise  = fetchPosts(userId);  // inicia imediatamente
  const ordersPromise = fetchOrders(userId); // inicia imediatamente

  const user = await userPromise; // espera apenas o user para renderizar o header

  return (
    <div>
      <UserHeader user={user} />
      <Suspense fallback={<Skeleton />}>
        <Posts postsPromise={postsPromise} />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <Orders ordersPromise={ordersPromise} />
      </Suspense>
    </div>
  );
}

Acessibilidade básica

  • Todo <img> precisa de alt descritivo (ou alt="" se decorativo)
  • Use elementos semânticos: <button>, <nav>, <main>, <article>
  • Labels associados a todos os inputs via htmlFor
  • Focus ring visível em todos os elementos interativos
  • Contraste de cor mínimo 4.5:1 para texto normal (WCAG AA)
  • Keyboard navigation funcional (Tab, Enter, Space, Escape)
accessible-components.tsx
// ✅ Dialog acessível
function Dialog({
  open,
  onClose,
  title,
  children,
}: {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}) {
  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      hidden={!open}
      className="fixed inset-0 z-50 flex items-center justify-center"
    >
      <div className="bg-slate-900 rounded-lg p-6 max-w-md w-full">
        <h2 id="dialog-title" className="text-lg font-semibold">{title}</h2>
        {children}
        <button
          onClick={onClose}
          aria-label="Fechar dialog"
          className="absolute top-4 right-4 focus-visible:ring-2 focus-visible:ring-sky-500"
        >
          ✕
        </button>
      </div>
    </div>
  );
}

// ✅ Botão de ícone com aria-label
function IconButton({ icon, label, onClick }: {
  icon: React.ReactNode;
  label: string;
  onClick: () => void;
}) {
  return (
    <button
      onClick={onClick}
      aria-label={label}
      className="p-2 rounded hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-sky-500 transition-colors"
    >
      {icon}
    </button>
  );
}

// ✅ Live region para status dinâmico
function StatusAnnouncer({ message }: { message: string }) {
  return (
    <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
      {message}
    </div>
  );
}

Padrões Tailwind em componentes React

tailwind-patterns.tsx
// Variantes com objeto de classes (evita string concatenation condicional bagunçada)
type Variant = "primary" | "secondary" | "danger";

const buttonVariants: Record<Variant, string> = {
  primary:   "bg-sky-500 text-white hover:bg-sky-400 focus-visible:ring-sky-500",
  secondary: "border border-slate-700 text-slate-200 hover:bg-slate-800",
  danger:    "bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500",
};

// Responsividade
function ResponsiveGrid({ children }: { children: React.ReactNode }) {
  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
      {children}
    </div>
  );
}

// Dark mode com classe (configurado no tailwind.config)
function Card({ children }: { children: React.ReactNode }) {
  return (
    <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4">
      {children}
    </div>
  );
}

// Estados de foco acessíveis
const inputClass = [
  "w-full rounded-lg border px-3 py-2 text-sm",
  "bg-slate-900 border-slate-700 text-slate-100",
  "placeholder:text-slate-500",
  "focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:border-transparent",
  "disabled:opacity-50 disabled:cursor-not-allowed",
].join(" ");

// cn helper (sem dependência de lib)
function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(" ");
}

// Uso
function Input({ error, className, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { error?: boolean }) {
  return (
    <input
      className={cn(
        inputClass,
        error && "border-red-500 focus-visible:ring-red-500",
        className
      )}
      {...props}
    />
  );
}