Objetivo da Aula
Ao concluir esta aula, você será capaz de:
Implementar ordenação multi-tipo em tabela React com useMemo e useState
Implementar paginação com slice e controle de página atual
Renderizar controles de paginação acessíveis com estados prev/next
Completar os 3 TODOs do componente DataTable genérico com TypeScript
Usar aria-sort e outros atributos de acessibilidade em tabelas
Por que isso importa
Tabelas com sorting e paginação são onipresentes em dashboards administrativos, sistemas de gestão e painéis de BI. O componente DataTable do starter é um padrão genérico — funciona para qualquer tipo de dado via TypeScript generics.
Implementar sorting e paginação corretamente (sem mutação de estado, com memoização) é um indicador de maturidade em React. Erros comuns — como usar data.sort() (mutação in-place) ou recalcular em cada render sem useMemo — impactam tanto a correção quanto a performance.
Conceitos Fundamentais
TypeScript Generics no DataTable
interface DataTableProps<T extends Record<string, unknown>> {
data: T[];
columns: Column<T>[];
pageSize?: number;
onRowClick?: (row: T) => void;
}
function DataTable<T extends Record<string, unknown>>({...}: DataTableProps<T>) {
// ...
}
T extends Record<string, unknown> garante que T é um objeto. Isso permite que o componente funcione com Produto[], Usuario[], Pedido[] — qualquer tipo com keyof T para acessar as colunas.TODO 1: Sorting com useMemo
const dadosOrdenados = useMemo(() => {
if (!sortKey || !sortDir) return data; // sem sort: retorna original
return [...data].sort((a, b) => { // [...data] cria cópia — não muta o original
const aVal = a[sortKey];
const bVal = b[sortKey];
// Comparação numérica vs string
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
}
// String: locale-aware comparison (suporta acentos)
const aStr = String(aVal ?? '');
const bStr = String(bVal ?? '');
const cmp = aStr.localeCompare(bStr, 'pt-BR');
return sortDir === 'asc' ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);Por que [...data] e não data.sort()? Array.sort() é in-place — muta o array original. Como data é uma prop, mutá-la viola o princípio de imutabilidade do React e pode causar bugs sutis com React.StrictMode e Concurrent Features.
Por que useMemo? Sem memoização, o sort roda a cada render — mesmo quando data, sortKey e sortDir não mudaram. Para tabelas com 1000+ linhas, isso é perceptível (200-500ms).
TODO 2: Paginação com slice
const dadosPaginados = useMemo(() => {
const inicio = (page - 1) * pageSize;
const fim = inicio + pageSize;
return dadosOrdenados.slice(inicio, fim);
}, [dadosOrdenados, page, pageSize]);
const totalPaginas = Math.ceil(data.length / pageSize);
slice(inicio, fim) é não-mutante — retorna um novo array com apenas os elementos da página atual.Por que Math.ceil? 10 itens com pageSize=3 → 4 páginas (3+3+3+1). Math.ceil(10/3) = 4.
Dependência em dadosOrdenados (não em data): a paginação deve mostrar os dados já ordenados, não os originais.
TODO 3: Controles de Paginação
<div style={{ padding: '12px', textAlign: 'center', display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '12px' }}>
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
aria-label="Página anterior"
style={{
padding: '6px 12px',
cursor: page === 1 ? 'not-allowed' : 'pointer',
opacity: page === 1 ? 0.5 : 1,
border: '1px solid #e2e8f0',
borderRadius: '4px',
background: 'white',
}}
>
← Anterior
</button>
<span aria-live="polite" aria-atomic>
Página {page} de {totalPaginas}
</span>
<button
onClick={() => setPage(p => Math.min(totalPaginas, p + 1))}
disabled={page === totalPaginas}
aria-label="Próxima página"
style={{
padding: '6px 12px',
cursor: page === totalPaginas ? 'not-allowed' : 'pointer',
opacity: page === totalPaginas ? 0.5 : 1,
border: '1px solid #e2e8f0',
borderRadius: '4px',
background: 'white',
}}
>
Próximo →
</button>
</div>
Math.max(1, p - 1) garante que a página nunca vai abaixo de 1. Math.min(totalPaginas, p + 1) garante que não passa do total.
aria-live="polite" notifica leitores de tela quando a página muda. disabled nos botões é tanto visual quanto acessível.Aprofundamento Técnico
Reset de Página ao Mudar Sort
const toggleSort = (key: keyof T) => {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : d === 'desc' ? null : 'asc');
if (sortDir === 'desc') setSortKey(null);
} else {
setSortKey(key);
setSortDir('asc');
setPage(1); // ← reset ao mudar coluna de sort
}
};Sem o setPage(1), usuário está na página 5 → clica em outra coluna para ordenar → vê a página 5 dos dados reordenados, que pode estar vazia. Reset para página 1 ao mudar sort é UX correto.
Column Renderer para Formatação
const colunas: Column<Produto>[] = [
{
key: 'preco',
label: 'Preço',
sortable: true,
// render é chamado com (value, row) — pode usar o row completo
render: (v) => `R$ ${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`,
},
{
key: 'estoque',
label: 'Estoque',
sortable: true,
render: (v) => (
<span style={{ color: Number(v) < 10 ? '#ef4444' : '#22c55e', fontWeight: 600 }}>
{String(v)} un.
</span>
),
},
];O render recebe (value, row) — pode acessar outros campos do row para contexto (ex: mostrar ícone diferente para produto sem estoque).
aria-sort para Acessibilidade
<th
aria-sort={
sortKey === col.key
? sortDir === 'asc' ? 'ascending' : 'descending'
: 'none'
}
>
aria-sort é o atributo ARIA para colunas de tabela com ordenação. Valores: ascending, descending, none, other. Leitores de tela anunciam “coluna X, ordenação ascendente” quando o usuário foca o header.Exemplos Anotados
Exemplo 1: DataTable Completo com Todos os TODOs
import React, { useState, useMemo } from 'react';
interface Column<T> {
key: keyof T;
label: string;
sortable?: boolean;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
function DataTable<T extends Record<string, unknown>>({
data, columns, pageSize = 10, onRowClick,
}: {
data: T[];
columns: Column<T>[];
pageSize?: number;
onRowClick?: (row: T) => void;
}) {
const [sortKey, setSortKey] = useState<keyof T | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc' | null>(null);
const [page, setPage] = useState(1);
// TODO 1: Sorting
const dadosOrdenados = useMemo(() => {
if (!sortKey || !sortDir) return data;
return [...data].sort((a, b) => {
const av = a[sortKey], bv = b[sortKey];
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
const cmp = String(av ?? '').localeCompare(String(bv ?? ''), 'pt-BR');
return sortDir === 'asc' ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
// TODO 2: Paginação
const dadosPaginados = useMemo(() => {
const inicio = (page - 1) * pageSize;
return dadosOrdenados.slice(inicio, inicio + pageSize);
}, [dadosOrdenados, page, pageSize]);
const totalPaginas = Math.ceil(data.length / pageSize);
const toggleSort = (key: keyof T) => {
if (sortKey !== key) { setSortKey(key); setSortDir('asc'); setPage(1); return; }
if (sortDir === 'asc') { setSortDir('desc'); return; }
setSortKey(null); setSortDir(null);
};
return (
<div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
{columns.map(col => (
<th
key={String(col.key)}
onClick={() => col.sortable && toggleSort(col.key)}
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
style={{ padding: '8px 12px', textAlign: 'left', borderBottom: '2px solid #e2e8f0', cursor: col.sortable ? 'pointer' : 'default', userSelect: 'none' }}
>
{col.label} {col.sortable && (sortKey === col.key ? (sortDir === 'asc' ? '↑' : '↓') : '↕')}
</th>
))}
</tr>
</thead>
<tbody>
{dadosPaginados.map((row, i) => (
<tr key={i} onClick={() => onRowClick?.(row)}
style={{ cursor: onRowClick ? 'pointer' : 'default', background: i % 2 === 0 ? '#f8fafc' : 'white' }}>
{columns.map(col => (
<td key={String(col.key)} style={{ padding: '8px 12px', borderBottom: '1px solid #e2e8f0' }}>
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* TODO 3: Controles de paginação */}
{totalPaginas > 1 && (
<div style={{ padding: '12px', display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '12px' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}
style={{ padding: '6px 12px', cursor: page === 1 ? 'not-allowed' : 'pointer', opacity: page === 1 ? 0.5 : 1, border: '1px solid #e2e8f0', borderRadius: '4px', background: 'white' }}>
← Anterior
</button>
<span aria-live="polite" aria-atomic>Página {page} de {totalPaginas}</span>
<button onClick={() => setPage(p => Math.min(totalPaginas, p + 1))} disabled={page === totalPaginas}
style={{ padding: '6px 12px', cursor: page === totalPaginas ? 'not-allowed' : 'pointer', opacity: page === totalPaginas ? 0.5 : 1, border: '1px solid #e2e8f0', borderRadius: '4px', background: 'white' }}>
Próximo →
</button>
</div>
)}
</div>
);
}Padrões e Armadilhas
Padrões
Padrão 1: [...data].sort() — nunca mutar props
return [...data].sort(...) // correto: nova cópia
// data.sort(...) // errado: muta a propPadrão 2: useMemo para operações caras em tabela Sort + slice em 1000+ linhas sem memo = lag visível. Dependências corretas evitam renders desnecessários.
Padrão 3: aria-sort e aria-label para acessibilidade Tabelas são elementos de navegação para usuários de leitor de tela. aria-sort é obrigatório para headers clicáveis.
Armadilhas
⚠️ Armadilha 1: Não resetar página ao mudar sort Usuário na página 5 → clica em nova coluna → vê dados inesperados. Sempre setPage(1) ao mudar sortKey.
⚠️ Armadilha 2: Paginar antes de ordenar
// ERRADO: ordena dados paginados (só ordena a página atual!)
const dadosPaginados = data.slice(inicio, fim)
const dadosOrdenados = dadosPaginados.sort(...)
// CORRETO: ordena todo o dataset, depois pagina
const dadosOrdenados = [...data].sort(...)
const dadosPaginados = dadosOrdenados.slice(inicio, fim)⚠️ Armadilha 3: totalPaginas baseado em data.length, não dadosOrdenados.length Se você filtrar dados (feature extra), use o tamanho dos dados filtrados. data.length dá o total sem filtro.
Se não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
3 TODOs no starter.ts:
TODO 1 — em dadosOrdenados:
return [...data].sort((a, b) => {
if (!sortKey || !sortDir) return 0;
const av = a[sortKey], bv = b[sortKey];
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
const cmp = String(av ?? '').localeCompare(String(bv ?? ''), 'pt-BR');
return sortDir === 'asc' ? cmp : -cmp;
});TODO 2 — em dadosPaginados:
const inicio = (page - 1) * pageSize;
return dadosOrdenados.slice(inicio, inicio + pageSize);TODO 3 — nos controles de paginação, substitua [TODO 3: paginação aqui] pelos botões Anterior/Próximo com o indicador de página.
Teste: com pageSize={3} e 5 produtos, verifique que são 2 páginas e que clicar em “Próximo” mostra os produtos 4 e 5.
Agora você está pronto para o lab.