Introdução: Por que Context API com TypeScript?
A Context API é uma ferramenta nativa do React que permite compartilhar dados entre componentes sem a necessidade de prop drilling — aquele problema comum quando você precisa passar props através de vários níveis de componentes. Quando combinada com TypeScript, a Context API se torna ainda mais poderosa, oferecendo tipagem robusta e autocompletar inteligente, reduzindo erros em tempo de desenvolvimento e facilitando a manutenção do código.
Muitos desenvolvedores evitam TypeScript com Context API por acreditar que é complexo. A verdade é que, com as práticas corretas, é incrivelmente simples e oferece uma experiência de desenvolvimento superior. Este artigo vai te guiar através de padrões profissionais que você pode usar em produção imediatamente.
Entendendo os Fundamentos da Context API Tipada
O que é Context e por que tipagem importa
A Context API funciona criando um contexto que armazena dados e os disponibiliza para qualquer componente descendente. Sem TypeScript, você pode facilmente passar tipos errados ou acessar propriedades que não existem. Com TypeScript, o compilador avisa você antes do código rodar — economizando horas de debug.
A tipagem em Context API envolve tipificar três coisas: o estado do contexto, o valor fornecido pelo provider e os hooks customizados que consomem esse contexto. Quando feito corretamente, você obtém autocomplete perfeito e segurança de tipos em todo seu código.
Estrutura básica de um contexto tipado
Vamos criar um exemplo real: um contexto de autenticação que armazena dados do usuário.
// AuthContext.tsx
import React, { createContext, useState, ReactNode, useContext } from 'react';
// Definir o tipo do usuário
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
// Definir o tipo do valor do contexto
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
// Criar o contexto com valor inicial undefined
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider component
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const login = async (email: string, password: string): Promise<void> => {
setIsLoading(true);
try {
// Simulando chamada à API
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
setUser(data.user);
} finally {
setIsLoading(false);
}
};
const logout = (): void => {
setUser(null);
};
const value: AuthContextType = {
user,
isLoading,
login,
logout,
isAuthenticated: user !== null,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
// Hook customizado para usar o contexto
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth deve ser usado dentro de AuthProvider');
}
return context;
};
Note que criamos o contexto com undefined como tipo padrão. Isso nos permite capturar erros em tempo de desenvolvimento se alguém tentar usar o hook fora do provider. O hook useAuth garante que o contexto está sempre disponível quando usado corretamente.
Padrões Avançados e Boas Práticas
Separação de responsabilidades: Context e Reducer
Para contextos mais complexos, separar a lógica do estado usando useReducer torna o código mais testável e escalável. Aqui está um exemplo de um contexto de carrinho de compras:
// CartContext.tsx
import React, {
createContext,
useReducer,
ReactNode,
useContext,
Dispatch
} from 'react';
interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: Product[];
total: number;
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' };
interface CartContextType {
state: CartState;
dispatch: Dispatch<CartAction>;
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
clearCart: () => void;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
const cartReducer = (state: CartState, action: CartAction): CartState => {
switch (action.type) {
case 'ADD_ITEM': {
const existingItem = state.items.find(item => item.id === action.payload.id);
const newItems = existingItem
? state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
)
: [...state.items, action.payload];
const newTotal = newItems.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
return { items: newItems, total: newTotal };
}
case 'REMOVE_ITEM':
const filteredItems = state.items.filter(item => item.id !== action.payload);
const removedTotal = filteredItems.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
return { items: filteredItems, total: removedTotal };
case 'CLEAR_CART':
return { items: [], total: 0 };
case 'UPDATE_QUANTITY': {
const updatedItems = state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
);
const updatedTotal = updatedItems.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
return { items: updatedItems, total: updatedTotal };
}
default:
return state;
}
};
interface CartProviderProps {
children: ReactNode;
}
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
total: 0,
});
const addItem = (product: Product): void => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
const removeItem = (productId: string): void => {
dispatch({ type: 'REMOVE_ITEM', payload: productId });
};
const clearCart = (): void => {
dispatch({ type: 'CLEAR_CART' });
};
const value: CartContextType = {
state,
dispatch,
addItem,
removeItem,
clearCart,
};
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
};
export const useCart = (): CartContextType => {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart deve ser usado dentro de CartProvider');
}
return context;
};
Este padrão é poderoso porque a lógica do reducer é pura — fácil de testar e debugar. TypeScript garante que cada ação tenha a estrutura correta.
Múltiplos Contextos e Composição
Em aplicações reais, você frequentemente precisa de vários contextos. Em vez de aninhar vários providers, crie um componente que los combine:
// RootProvider.tsx
import React, { ReactNode } from 'react';
import { AuthProvider } from './AuthContext';
import { CartProvider } from './CartContext';
import { ThemeProvider } from './ThemeContext';
interface RootProviderProps {
children: ReactNode;
}
export const RootProvider: React.FC<RootProviderProps> = ({ children }) => {
return (
<AuthProvider>
<ThemeProvider>
<CartProvider>
{children}
</CartProvider>
</ThemeProvider>
</AuthProvider>
);
};
// E no App.tsx
import { RootProvider } from './providers/RootProvider';
function App() {
return (
<RootProvider>
<YourApp />
</RootProvider>
);
}
Dessa forma, você evita "provider hell" e mantém seu código organizado e legível.
Consumindo múltiplos contextos em um componente
Às vezes você precisa usar dados de vários contextos no mesmo componente. TypeScript ajuda a garantir que você está acessando tudo corretamente:
// CheckoutComponent.tsx
import React from 'react';
import { useAuth } from './AuthContext';
import { useCart } from './CartContext';
import { useTheme } from './ThemeContext';
const CheckoutComponent: React.FC = () => {
const { user, isAuthenticated } = useAuth();
const { state: cartState, clearCart } = useCart();
const { theme } = useTheme();
const handleCheckout = async (): Promise<void> => {
if (!isAuthenticated || !user) {
console.error('Usuário não autenticado');
return;
}
try {
const response = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({
userId: user.id,
items: cartState.items,
total: cartState.total,
}),
});
if (response.ok) {
clearCart();
}
} catch (error) {
console.error('Erro no checkout:', error);
}
};
return (
<div style={{ background: theme === 'dark' ? '#000' : '#fff' }}>
<h2>Checkout para {user?.name}</h2>
<p>Total: R$ {cartState.total.toFixed(2)}</p>
<button onClick={handleCheckout}>Finalizar Pedido</button>
</div>
);
};
export default CheckoutComponent;
Otimização de Performance e Selectors
Evitando re-renders desnecessários
Um problema comum com Context API é que qualquer mudança no valor do contexto causa re-render em todos os componentes que o consomem. Para evitar isso, você pode criar selectors:
// useAuthSelector.ts
import { useAuth } from './AuthContext';
// Selector para pegar apenas o usuário
export const useAuthUser = () => {
const { user } = useAuth();
return user;
};
// Selector para pegar apenas o status de autenticação
export const useIsAuthenticated = () => {
const { isAuthenticated } = useAuth();
return isAuthenticated;
};
// Selector para pegar apenas o status de carregamento
export const useAuthLoading = () => {
const { isLoading } = useAuth();
return isLoading;
};
// Componente que usa apenas o que precisa
const UserProfile: React.FC = () => {
const user = useAuthUser(); // Só re-renderiza se user mudar
if (!user) return <div>Usuário não carregado</div>;
return <div>{user.name}</div>;
};
Uma abordagem mais robusta é usar useMemo dentro do provider para evitar que o valor do contexto mude em cada render:
// AuthContext.tsx (versão melhorada)
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
// ... funções login e logout ...
// Memoizar o valor para evitar re-renders
const value = React.useMemo(
() => ({
user,
isLoading,
login,
logout,
isAuthenticated: user !== null,
}),
[user, isLoading]
);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
Dividindo contextos para melhor performance
Para contextos grandes, dividir em múltiplos contextos menores é uma estratégia eficaz:
// AuthStateContext.ts - apenas dados
interface AuthState {
user: User | null;
isLoading: boolean;
}
const AuthStateContext = createContext<AuthState | undefined>(undefined);
// AuthActionsContext.ts - apenas funções
interface AuthActions {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthActionsContext = createContext<AuthActions | undefined>(undefined);
// Agora componentes que só precisam ler dados não re-renderizam
// quando uma ação é executada
export const useAuthState = () => {
const context = useContext(AuthStateContext);
if (!context) throw new Error('useAuthState deve estar dentro de AuthProvider');
return context;
};
export const useAuthActions = () => {
const context = useContext(AuthActionsContext);
if (!context) throw new Error('useAuthActions deve estar dentro de AuthProvider');
return context;
};
Tratamento de Erros e Validação
Tipagem segura com optional chaining
TypeScript nos permite ser explícitos sobre possíveis valores nulos:
// Com tipagem correta, TypeScript avisa sobre possíveis nulos
const handleUserAction = (user: User | null): void => {
// Isso causaria erro de tipagem sem verificação
// console.log(user.name); // ❌ Error: Object is possibly 'null'
// Forma segura com optional chaining
console.log(user?.name); // ✅ Ok, retorna undefined se user for null
// Ou com verificação explícita
if (user) {
console.log(user.name); // ✅ TypeScript sabe que user não é null
}
};
Tratamento de erros no contexto
Adicionar erro como parte do estado é essencial para aplicações robustas:
// NotificationContext.tsx
interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
duration?: number;
}
interface NotificationContextType {
notifications: Notification[];
addNotification: (message: string, type: Notification['type']) => void;
removeNotification: (id: string) => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = (message: string, type: Notification['type']): void => {
const id = Date.now().toString();
const notification: Notification = { id, message, type };
setNotifications(prev => [...prev, notification]);
// Auto-remover depois de 5 segundos
if (notification.duration !== 0) {
setTimeout(() => removeNotification(id), notification.duration || 5000);
}
};
const removeNotification = (id: string): void => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
return (
<NotificationContext.Provider value={{ notifications, addNotification, removeNotification }}>
{children}
</NotificationContext.Provider>
);
};
export const useNotification = (): NotificationContextType => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification deve estar dentro de NotificationProvider');
}
return context;
};
// Usando com tratamento de erro
const MyComponent: React.FC = () => {
const { addNotification } = useNotification();
const { login } = useAuth();
const handleLogin = async (email: string, password: string): Promise<void> => {
try {
await login(email, password);
addNotification('Login realizado com sucesso!', 'success');
} catch (error) {
const message = error instanceof Error ? error.message : 'Erro ao fazer login';
addNotification(message, 'error');
}
};
return <button onClick={() => handleLogin('user@example.com', 'pass')}>Login</button>;
};
Conclusão
Você aprendeu três conceitos fundamentais que transformam a forma como trabalha com estado global em React com TypeScript. Primeiro, a importância de tipagem explícita em contextos — criar interfaces claras para seus dados e ações evita bugs e oferece autocomplete perfeito. Segundo, padrões arquiteturais avançados como separação de state e actions com useReducer, além de composição de múltiplos contextos, tornam seu código escalável e testável. Terceiro, otimização através de selectors e divisão de contextos é essencial para evitar re-renders desnecessários conforme sua aplicação cresce.
Com essas práticas em mãos, você está pronto para construir sistemas robustos de gerenciamento de estado em React que são fáceis de manter, debugar e estender.