mozak.tech Engenharia de IA Corporativa 55%

Parte III — Inteligência Artificial na Experiência do Usuário

3.3 — Componentes Dinâmicos no Frontend com Suporte de Agentes (Frontend Agents)

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 prop

Padrã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.

⚗ Laboratório prático — mozak.tech
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.