Introdução: Por que Tipificar em React com TypeScript
React é uma biblioteca poderosa para construir interfaces de usuário, mas quando você trabalha em projetos médios ou grandes, o JavaScript puro pode deixar falhas escaparem apenas em tempo de execução. TypeScript resolve isso adicionando um sistema de tipos robusto que permite detectar erros antes do código chegar à produção. O desafio começa quando precisamos tipar corretamente props, state e handlers de eventos — elementos fundamentais de qualquer componente React.
A tipificação não é apenas uma camada de segurança; ela é documentação viva no seu código. Quando um desenvolvedor novo entra no projeto e vê uma prop tipificada, ele imediatamente compreende qual tipo de dado deve ser passado e qual comportamento esperar. Nesta aula, vamos explorar as melhores práticas para tipar esses três pilares do React, evitando armadilhas comuns e desenvolvendo componentes verdadeiramente reutilizáveis.
Tipando Props: A Porta de Entrada do Seu Componente
Interfaces vs Types para Props
Em TypeScript, você pode usar tanto interface quanto type para descrever props. Historicamente, a comunidade React preferia interface, mas ambas funcionam perfeitamente. A escolha geralmente é estética, embora interface seja ligeiramente mais legível quando você está começando. Vou usar interface nos exemplos, mas sinta-se livre para usar type — eles são intercambiáveis para este caso de uso.
Props são simplesmente argumentos que você passa para um componente. Para garantir que o componente receba exatamente o que espera, você define uma interface que descreve a forma desses dados. Veja este exemplo prático:
import React from 'react';
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary';
}
const Button: React.FC<ButtonProps> = ({ label, onClick, disabled = false, variant = 'primary' }) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
);
};
export default Button;
Aqui, ButtonProps define claramente que o componente espera uma string label, uma função onClick, e opcionalmente um booleano disabled e uma string variant que só pode ser 'primary' ou 'secondary'. O ? após o nome da propriedade a torna opcional. Quando você usar este componente em outro lugar, o TypeScript vai alertá-lo se tentar passar um valor inválido.
Props Complexas e Composição
Nem sempre suas props são primitivos simples. Frequentemente você precisa passar objetos, arrays ou até mesmo outros componentes React. Para esses casos, TypeScript oferece tipos mais avançados que ajudam a manter a segurança.
interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
interface UserCardProps {
user: User;
onUserClick: (userId: number) => void;
actions?: React.ReactNode;
children?: React.ReactNode;
}
const UserCard: React.FC<UserCardProps> = ({ user, onUserClick, actions, children }) => {
return (
<div className="user-card">
<div className="user-info">
{user.avatar && <img src={user.avatar} alt={user.name} />}
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
<div className="user-actions">
{actions}
{children}
</div>
<button onClick={() => onUserClick(user.id)}>View Profile</button>
</div>
);
};
export default UserCard;
Note que React.ReactNode é usado para actions e children — isso permite que você passe praticamente qualquer coisa renderizável (strings, números, componentes, arrays). Quando você precisa ser mais restritivo e aceitar apenas um componente específico, use React.ComponentType<TProps> ou simplesmente JSX.Element. O tipo User é composável: você pode reutilizá-lo em várias interfaces, evitando duplicação.
Valores Padrão e Props Obrigatórias
TypeScript ajuda você a identificar quando uma prop é obrigatória versus opcional, mas muitas vezes você quer fornecer um valor padrão que JavaScript entende naturalmente. Use a desestruturação com padrões no parâmetro da função:
interface InputProps {
placeholder?: string;
type?: string;
maxLength?: number;
onChange: (value: string) => void;
}
const Input: React.FC<InputProps> = ({
placeholder = 'Digite algo...',
type = 'text',
maxLength = 100,
onChange
}) => {
return (
<input
type={type}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
/>
);
};
export default Input;
Aqui, placeholder, type e maxLength têm valores padrão, então o componente funciona perfeitamente mesmo que não sejam passados. Mas onChange é obrigatório — o TypeScript vai reclamar se você tentar usar o componente sem fornecê-lo.
Tipando State: Mantendo a Segurança Interna
useState com Tipos Explícitos
O estado interno de um componente é onde a lógica dinâmica acontece. Quando você usa useState, o TypeScript tenta inferir o tipo baseado no valor inicial, mas é uma excelente prática ser explícito, especialmente quando o estado pode ser null ou ter múltiplas formas.
import React, { useState } from 'react';
interface FormState {
username: string;
password: string;
rememberMe: boolean;
}
const LoginForm: React.FC = () => {
const [formData, setFormData] = useState<FormState>({
username: '',
password: '',
rememberMe: false,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (field: keyof FormState, value: string | boolean) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
// Simular chamada à API
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('Login realizado:', formData);
} catch (err) {
setError('Falha ao fazer login. Tente novamente.');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formData.username}
onChange={(e) => handleChange('username', e.target.value)}
placeholder="Usuário"
/>
<input
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
placeholder="Senha"
/>
<label>
<input
type="checkbox"
checked={formData.rememberMe}
onChange={(e) => handleChange('rememberMe', e.target.checked)}
/>
Manter-me conectado
</label>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Entrando...' : 'Entrar'}
</button>
</form>
);
};
export default LoginForm;
Observe como useState<FormState> deixa claro que o estado tem exatamente essa forma. Para error, usamos string | null porque inicialmente é null, mas pode se tornar uma mensagem de erro. Usar keyof FormState em handleChange garante que você só possa atualizar campos que realmente existem.
Estados Complexos e Discriminated Unions
Conforme seus componentes crescem, o estado pode assumir múltiplas formas. Uma técnica poderosa é usar "discriminated unions" — tipos que têm um campo comum que diferencia suas formas:
import React, { useState } from 'react';
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
interface Post {
id: number;
title: string;
content: string;
}
const PostList: React.FC = () => {
const [state, setState] = useState<AsyncState<Post[]>>({ status: 'idle' });
const fetchPosts = async () => {
setState({ status: 'loading' });
try {
// Simular fetch de API
await new Promise((resolve) => setTimeout(resolve, 1000));
const data: Post[] = [
{ id: 1, title: 'Post 1', content: 'Conteúdo 1' },
{ id: 2, title: 'Post 2', content: 'Conteúdo 2' },
];
setState({ status: 'success', data });
} catch (err) {
setState({ status: 'error', error: 'Falha ao carregar posts' });
}
};
return (
<div>
<button onClick={fetchPosts}>Carregar Posts</button>
{state.status === 'idle' && <p>Clique para carregar</p>}
{state.status === 'loading' && <p>Carregando...</p>}
{state.status === 'success' && (
<ul>
{state.data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
{state.status === 'error' && <p className="error">{state.error}</p>}
</div>
);
};
export default PostList;
Este padrão é extremamente poderoso. TypeScript entende que quando status é 'success', a propriedade data está disponível. Se você tentar acessar data quando o status não é 'success', ele vai reclamar. Isso elimina uma classe inteira de bugs de runtime.
Tipando Eventos: Handlers Seguros e Previsíveis
Tipos de Eventos do React
React envolve eventos nativos do DOM em seus próprios tipos. Quando você trabalha com eventos em TypeScript, precisa usar os tipos corretos fornecidos pelo React. Cada tipo de elemento e evento tem um tipo correspondente.
import React from 'react';
interface SearchBoxProps {
onSearch: (query: string) => void;
onFocus?: () => void;
onBlur?: () => void;
}
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch, onFocus, onBlur }) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
console.log('Busca realizada');
}
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
console.log('Botão clicado');
};
return (
<div>
<input
type="text"
placeholder="Buscar..."
onChange={handleChange}
onKeyPress={handleKeyPress}
onFocus={onFocus}
onBlur={onBlur}
/>
<button onClick={handleClick}>Buscar</button>
</div>
);
};
export default SearchBox;
React.ChangeEvent<HTMLInputElement>, React.KeyboardEvent<HTMLInputElement> e React.MouseEvent<HTMLButtonElement> são os tipos corretos. Note que você especifica qual elemento HTML o evento vem — isso permite que o TypeScript saiba quais propriedades estão disponíveis em e.target.
Handlers Parametrizados e Callbacks
Muitas vezes você quer passar dados do evento para a função callback. Aqui está uma abordagem limpa:
import React from 'react';
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
interface TodoListProps {
todos: TodoItem[];
onToggleTodo: (id: number) => void;
onDeleteTodo: (id: number) => void;
onEditTodo: (id: number, newText: string) => void;
}
const TodoList: React.FC<TodoListProps> = ({
todos,
onToggleTodo,
onDeleteTodo,
onEditTodo,
}) => {
const [editingId, setEditingId] = React.useState<number | null>(null);
const [editText, setEditText] = React.useState('');
const handleDoubleClick = (todo: TodoItem) => {
setEditingId(todo.id);
setEditText(todo.text);
};
const handleSaveEdit = (id: number) => {
onEditTodo(id, editText);
setEditingId(null);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, id: number) => {
if (e.key === 'Enter') {
handleSaveEdit(id);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{editingId === todo.id ? (
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, todo.id)}
autoFocus
/>
) : (
<>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggleTodo(todo.id)}
/>
<span
onDoubleClick={() => handleDoubleClick(todo)}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.text}
</span>
<button onClick={() => onDeleteTodo(todo.id)}>Deletar</button>
</>
)}
</li>
))}
</ul>
);
};
export default TodoList;
Aqui, onKeyDown recebe o evento e o id como parâmetros separados. TypeScript verifica que ambos têm os tipos corretos. O handler handleDoubleClick extrai dados do objeto todo antes de passar para a função callback — isso torna o código mais legível e type-safe.
Async Handlers e Promises
Quando seus handlers são assíncronos, você precisa comunicar isso claramente através dos tipos:
import React from 'react';
interface SubmitButtonProps {
onSubmit: (data: string) => Promise<void>;
label?: string;
}
const SubmitButton: React.FC<SubmitButtonProps> = ({ onSubmit, label = 'Enviar' }) => {
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await onSubmit('dados de exemplo');
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro desconhecido');
} finally {
setIsLoading(false);
}
};
return (
<>
<button onClick={handleClick} disabled={isLoading}>
{isLoading ? 'Processando...' : label}
</button>
{error && <div className="error">{error}</div>}
</>
);
};
export default SubmitButton;
Repare que onSubmit tem tipo (data: string) => Promise<void>. Isso documenta que a função é assíncrona e retorna uma Promise vazia. Quando você chama await onSubmit(...), TypeScript entende automaticamente que precisa esperar a conclusão. O tratamento de erro diferencia entre instâncias de Error e outros valores lançados.
Conclusão
Tipar Props, State e Eventos em React com TypeScript não é apenas uma formalidade — é um investimento direto na qualidade e manutenibilidade do seu código. Ao usar interface para descrever props, tipos genéricos para estado e tipos de evento específicos do React, você cria um escudo contra erros que só seriam descobertos em produção com JavaScript puro. A segunda lição é que discriminated unions (tipos que usam um campo comum para distinguir variantes) são incrivelmente poderosos para estados complexos, eliminando a necessidade de booleanos redundantes e lógica condicional confusa. Por fim, ser explícito é melhor que implícito — especificar tipos mesmo quando TypeScript poderia inferir é um padrão profissional que facilita onboarding, refatoração e depuração em projetos reais.