TypeScript com React: tipando props, hooks e eventos

TypeScript trouxe um novo patamar de segurança e produtividade para o desenvolvimento React. Ao adicionar tipagem estática a props, hooks e eventos, eliminamos uma classe inteira de bugs em tempo de execução e melhoramos drasticamente a experiência de desenvolvimento com autocompletar e refatoração segura. Neste artigo, exploraremos na prática como tipar corretamente cada aspecto de uma aplicação React com TypeScript.

1. Configuração Inicial e Fundamentos

Para começar, o Vite é atualmente a ferramenta recomendada para criar projetos React com TypeScript:

npm create vite@latest meu-projeto -- --template react-ts

O Vite já inclui @types/react e @types/react-dom como dependências. Quanto à escolha entre interface e type para componentes, a recomendação moderna é usar type para props, pois oferece maior flexibilidade com uniões e interseções:

// Usando type (recomendado)
type ButtonProps = {
  label: string;
  variant?: 'primary' | 'secondary';
};

// Usando interface (alternativa válida)
interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
}

2. Tipando Props de Componentes

Props opcionais são marcadas com ?, e children deve ser tipado como React.ReactNode:

type CardProps = {
  title: string;
  description?: string;
  children: React.ReactNode;
};

function Card({ title, description, children }: CardProps) {
  return (
    <div>
      <h2>{title}</h2>
      {description && <p>{description}</p>}
      {children}
    </div>
  );
}

Para componentes reutilizáveis, use genéricos:

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};

function List<T>({ items, renderItem }: ListProps<T>) {
  return <ul>{items.map(renderItem)}</ul>;
}

// Uso
<List items={[1, 2, 3]} renderItem={(item) => <li>{item}</li>} />

3. Tipando Estados com useState

O TypeScript infere automaticamente o tipo baseado no valor inicial, mas em casos complexos devemos declarar explicitamente:

// Inferência automática
const [count, setCount] = useState(0); // tipo: number

// Declaração explícita para tipos complexos
type User = {
  id: number;
  name: string;
  email: string;
};

const [user, setUser] = useState<User | null>(null);

// Estados com arrays tipados
const [tags, setTags] = useState<string[]>([]);

// Narrowing para tipos null/undefined
if (user) {
  console.log(user.name); // TypeScript sabe que user não é null aqui
}

4. Tipando useEffect e useRef

O useEffect não requer tipagem especial, mas as dependências devem ser consistentes:

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch('/api/data');
    const data: User[] = await response.json();
    // processar dados
  };
  fetchData();
}, []); // dependências vazias = executa uma vez

Para referências a elementos DOM, use os tipos HTML específicos:

const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);

// useRef para valores mutáveis (sem re-render)
const renderCount = useRef<number>(0);
renderCount.current += 1;

5. Tipando Eventos do DOM

Eventos de formulário e clique são comuns e devem ser tipados corretamente:

// Evento de formulário
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  // lógica de submit
}

// Evento de clique
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  console.log(e.clientX, e.clientY);
}

// Evento de teclado
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
  if (e.key === 'Enter') {
    // submeter formulário
  }
}

// Exemplo completo
function SearchForm() {
  const [search, setSearch] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log('Buscando:', search);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={search} onChange={handleChange} />
      <button type="submit">Buscar</button>
    </form>
  );
}

6. Tipando Custom Hooks

Custom hooks devem ter retorno tipado para garantir segurança:

// Hook com retorno tipado como tupla
function useToggle(initialValue = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}

// Hook genérico: useLocalStorage<T>
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };

  return [storedValue, setValue];
}

// Uso
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

7. Tipando Context API e Reducers

O Context API com TypeScript exige tipos explícitos:

type Theme = 'light' | 'dark';
type ThemeContextType = {
  theme: Theme;
  toggleTheme: () => void;
};

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

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light');

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

// Hook customizado para consumo seguro
function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme deve ser usado dentro de ThemeProvider');
  }
  return context;
}

Para useReducer com discriminated unions:

type Action =
  | { type: 'INCREMENT'; payload: number }
  | { type: 'DECREMENT' }
  | { type: 'RESET' };

type State = { count: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + action.payload };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0 });

8. Boas Práticas e Padrões Avançados

Evite any a todo custo — prefira unknown quando o tipo for realmente desconhecido:

// Ruim
function processData(data: any) { }

// Bom
function processData(data: unknown) {
  if (typeof data === 'string') {
    // TypeScript sabe que é string aqui
  }
}

Utilitários do React que facilitam a tipagem:

// React.FC é opcional, muitos preferem tipar props diretamente
type MyComponentProps = {
  title: string;
};

// React.ComponentProps para extrair props de componentes existentes
type ButtonProps = React.ComponentProps<'button'>;

// React.PropsWithChildren para adicionar children rapidamente
type CardProps = React.PropsWithChildren<{
  title: string;
}>;

// Memoização com tipagem correta
const MemoizedComponent = React.memo(function MyComponent({ value }: { value: string }) {
  return <div>{value}</div>;
});

Conclusão

Tipar corretamente props, hooks e eventos em React com TypeScript não é apenas uma questão de boas práticas — é uma mudança fundamental na forma como escrevemos código. A segurança de tipos elimina bugs antes mesmo de executarmos o código, melhora a documentação através dos próprios tipos e torna a refatoração muito mais segura. Comece aplicando esses padrões em seus componentes e, gradualmente, você perceberá que o TypeScript se torna um aliado indispensável no desenvolvimento React.

Referências