TypeScript

Context API com TypeScript: Tipagem Completa e Boas Práticas na Prática

14 min de leitura

Context API com TypeScript: Tipagem Completa e Boas Práticas na Prática

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

<h2>Introdução: Por que Context API com TypeScript?</h2>

<p>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.</p>

<p>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.</p>

<h2>Entendendo os Fundamentos da Context API Tipada</h2>

<h3>O que é Context e por que tipagem importa</h3>

<p>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.</p>

<p>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.</p>

<h3>Estrutura básica de um contexto tipado</h3>

<p>Vamos criar um exemplo real: um contexto de autenticação que armazena dados do usuário.</p>

<pre><code class="language-typescript">// AuthContext.tsx

import React, { createContext, useState, ReactNode, useContext } from &#039;react&#039;;

// Definir o tipo do usuário

interface User {

id: string;

name: string;

email: string;

role: &#039;admin&#039; | &#039;user&#039;;

}

// Definir o tipo do valor do contexto

interface AuthContextType {

user: User | null;

isLoading: boolean;

login: (email: string, password: string) =&gt; Promise&lt;void&gt;;

logout: () =&gt; void;

isAuthenticated: boolean;

}

// Criar o contexto com valor inicial undefined

const AuthContext = createContext&lt;AuthContextType | undefined&gt;(undefined);

// Provider component

interface AuthProviderProps {

children: ReactNode;

}

export const AuthProvider: React.FC&lt;AuthProviderProps&gt; = ({ children }) =&gt; {

const [user, setUser] = useState&lt;User | null&gt;(null);

const [isLoading, setIsLoading] = useState(false);

const login = async (email: string, password: string): Promise&lt;void&gt; =&gt; {

setIsLoading(true);

try {

// Simulando chamada à API

const response = await fetch(&#039;/api/login&#039;, {

method: &#039;POST&#039;,

body: JSON.stringify({ email, password }),

});

const data = await response.json();

setUser(data.user);

} finally {

setIsLoading(false);

}

};

const logout = (): void =&gt; {

setUser(null);

};

const value: AuthContextType = {

user,

isLoading,

login,

logout,

isAuthenticated: user !== null,

};

return (

&lt;AuthContext.Provider value={value}&gt;

{children}

&lt;/AuthContext.Provider&gt;

);

};

// Hook customizado para usar o contexto

export const useAuth = (): AuthContextType =&gt; {

const context = useContext(AuthContext);

if (context === undefined) {

throw new Error(&#039;useAuth deve ser usado dentro de AuthProvider&#039;);

}

return context;

};</code></pre>

<p>Note que criamos o contexto com <code>undefined</code> 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 <code>useAuth</code> garante que o contexto está sempre disponível quando usado corretamente.</p>

<h2>Padrões Avançados e Boas Práticas</h2>

<h3>Separação de responsabilidades: Context e Reducer</h3>

<p>Para contextos mais complexos, separar a lógica do estado usando <code>useReducer</code> torna o código mais testável e escalável. Aqui está um exemplo de um contexto de carrinho de compras:</p>

<pre><code class="language-typescript">// CartContext.tsx

import React, {

createContext,

useReducer,

ReactNode,

useContext,

Dispatch

} from &#039;react&#039;;

interface Product {

id: string;

name: string;

price: number;

quantity: number;

}

interface CartState {

items: Product[];

total: number;

}

type CartAction =

{ type: &#039;ADD_ITEM&#039;; payload: Product } | { type: &#039;REMOVE_ITEM&#039;; payload: string } | { type: &#039;UPDATE_QUANTITY&#039;; payload: { id: string; quantity: number } } | { type: &#039;CLEAR_CART&#039; };

interface CartContextType {

state: CartState;

dispatch: Dispatch&lt;CartAction&gt;;

addItem: (product: Product) =&gt; void;

removeItem: (productId: string) =&gt; void;

clearCart: () =&gt; void;

}

const CartContext = createContext&lt;CartContextType | undefined&gt;(undefined);

const cartReducer = (state: CartState, action: CartAction): CartState =&gt; {

switch (action.type) {

case &#039;ADD_ITEM&#039;: {

const existingItem = state.items.find(item =&gt; item.id === action.payload.id);

const newItems = existingItem

? state.items.map(item =&gt;

item.id === action.payload.id

? { ...item, quantity: item.quantity + 1 }

: item

)

: [...state.items, action.payload];

const newTotal = newItems.reduce((sum, item) =&gt;

sum + (item.price * item.quantity), 0

);

return { items: newItems, total: newTotal };

}

case &#039;REMOVE_ITEM&#039;:

const filteredItems = state.items.filter(item =&gt; item.id !== action.payload);

const removedTotal = filteredItems.reduce((sum, item) =&gt;

sum + (item.price * item.quantity), 0

);

return { items: filteredItems, total: removedTotal };

case &#039;CLEAR_CART&#039;:

return { items: [], total: 0 };

case &#039;UPDATE_QUANTITY&#039;: {

const updatedItems = state.items.map(item =&gt;

item.id === action.payload.id

? { ...item, quantity: action.payload.quantity }

: item

);

const updatedTotal = updatedItems.reduce((sum, item) =&gt;

sum + (item.price * item.quantity), 0

);

return { items: updatedItems, total: updatedTotal };

}

default:

return state;

}

};

interface CartProviderProps {

children: ReactNode;

}

export const CartProvider: React.FC&lt;CartProviderProps&gt; = ({ children }) =&gt; {

const [state, dispatch] = useReducer(cartReducer, {

items: [],

total: 0,

});

const addItem = (product: Product): void =&gt; {

dispatch({ type: &#039;ADD_ITEM&#039;, payload: product });

};

const removeItem = (productId: string): void =&gt; {

dispatch({ type: &#039;REMOVE_ITEM&#039;, payload: productId });

};

const clearCart = (): void =&gt; {

dispatch({ type: &#039;CLEAR_CART&#039; });

};

const value: CartContextType = {

state,

dispatch,

addItem,

removeItem,

clearCart,

};

return (

&lt;CartContext.Provider value={value}&gt;

{children}

&lt;/CartContext.Provider&gt;

);

};

export const useCart = (): CartContextType =&gt; {

const context = useContext(CartContext);

if (context === undefined) {

throw new Error(&#039;useCart deve ser usado dentro de CartProvider&#039;);

}

return context;

};</code></pre>

<p>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.</p>

<h3>Múltiplos Contextos e Composição</h3>

<p>Em aplicações reais, você frequentemente precisa de vários contextos. Em vez de aninhar vários providers, crie um componente que los combine:</p>

<pre><code class="language-typescript">// RootProvider.tsx

import React, { ReactNode } from &#039;react&#039;;

import { AuthProvider } from &#039;./AuthContext&#039;;

import { CartProvider } from &#039;./CartContext&#039;;

import { ThemeProvider } from &#039;./ThemeContext&#039;;

interface RootProviderProps {

children: ReactNode;

}

export const RootProvider: React.FC&lt;RootProviderProps&gt; = ({ children }) =&gt; {

return (

&lt;AuthProvider&gt;

&lt;ThemeProvider&gt;

&lt;CartProvider&gt;

{children}

&lt;/CartProvider&gt;

&lt;/ThemeProvider&gt;

&lt;/AuthProvider&gt;

);

};

// E no App.tsx

import { RootProvider } from &#039;./providers/RootProvider&#039;;

function App() {

return (

&lt;RootProvider&gt;

&lt;YourApp /&gt;

&lt;/RootProvider&gt;

);

}</code></pre>

<p>Dessa forma, você evita &quot;provider hell&quot; e mantém seu código organizado e legível.</p>

<h3>Consumindo múltiplos contextos em um componente</h3>

<p>Às vezes você precisa usar dados de vários contextos no mesmo componente. TypeScript ajuda a garantir que você está acessando tudo corretamente:</p>

<pre><code class="language-typescript">// CheckoutComponent.tsx

import React from &#039;react&#039;;

import { useAuth } from &#039;./AuthContext&#039;;

import { useCart } from &#039;./CartContext&#039;;

import { useTheme } from &#039;./ThemeContext&#039;;

const CheckoutComponent: React.FC = () =&gt; {

const { user, isAuthenticated } = useAuth();

const { state: cartState, clearCart } = useCart();

const { theme } = useTheme();

const handleCheckout = async (): Promise&lt;void&gt; =&gt; {

if (!isAuthenticated || !user) {

console.error(&#039;Usuário não autenticado&#039;);

return;

}

try {

const response = await fetch(&#039;/api/checkout&#039;, {

method: &#039;POST&#039;,

body: JSON.stringify({

userId: user.id,

items: cartState.items,

total: cartState.total,

}),

});

if (response.ok) {

clearCart();

}

} catch (error) {

console.error(&#039;Erro no checkout:&#039;, error);

}

};

return (

&lt;div style={{ background: theme === &#039;dark&#039; ? &#039;#000&#039; : &#039;#fff&#039; }}&gt;

&lt;h2&gt;Checkout para {user?.name}&lt;/h2&gt;

&lt;p&gt;Total: R$ {cartState.total.toFixed(2)}&lt;/p&gt;

&lt;button onClick={handleCheckout}&gt;Finalizar Pedido&lt;/button&gt;

&lt;/div&gt;

);

};

export default CheckoutComponent;</code></pre>

<h2>Otimização de Performance e Selectors</h2>

<h3>Evitando re-renders desnecessários</h3>

<p>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:</p>

<pre><code class="language-typescript">// useAuthSelector.ts

import { useAuth } from &#039;./AuthContext&#039;;

// Selector para pegar apenas o usuário

export const useAuthUser = () =&gt; {

const { user } = useAuth();

return user;

};

// Selector para pegar apenas o status de autenticação

export const useIsAuthenticated = () =&gt; {

const { isAuthenticated } = useAuth();

return isAuthenticated;

};

// Selector para pegar apenas o status de carregamento

export const useAuthLoading = () =&gt; {

const { isLoading } = useAuth();

return isLoading;

};

// Componente que usa apenas o que precisa

const UserProfile: React.FC = () =&gt; {

const user = useAuthUser(); // Só re-renderiza se user mudar

if (!user) return &lt;div&gt;Usuário não carregado&lt;/div&gt;;

return &lt;div&gt;{user.name}&lt;/div&gt;;

};</code></pre>

<p>Uma abordagem mais robusta é usar <code>useMemo</code> dentro do provider para evitar que o valor do contexto mude em cada render:</p>

<pre><code class="language-typescript">// AuthContext.tsx (versão melhorada)

export const AuthProvider: React.FC&lt;AuthProviderProps&gt; = ({ children }) =&gt; {

const [user, setUser] = useState&lt;User | null&gt;(null);

const [isLoading, setIsLoading] = useState(false);

// ... funções login e logout ...

// Memoizar o valor para evitar re-renders

const value = React.useMemo(

() =&gt; ({

user,

isLoading,

login,

logout,

isAuthenticated: user !== null,

}),

[user, isLoading]

);

return (

&lt;AuthContext.Provider value={value}&gt;

{children}

&lt;/AuthContext.Provider&gt;

);

};</code></pre>

<h3>Dividindo contextos para melhor performance</h3>

<p>Para contextos grandes, dividir em múltiplos contextos menores é uma estratégia eficaz:</p>

<pre><code class="language-typescript">// AuthStateContext.ts - apenas dados

interface AuthState {

user: User | null;

isLoading: boolean;

}

const AuthStateContext = createContext&lt;AuthState | undefined&gt;(undefined);

// AuthActionsContext.ts - apenas funções

interface AuthActions {

login: (email: string, password: string) =&gt; Promise&lt;void&gt;;

logout: () =&gt; void;

}

const AuthActionsContext = createContext&lt;AuthActions | undefined&gt;(undefined);

// Agora componentes que só precisam ler dados não re-renderizam

// quando uma ação é executada

export const useAuthState = () =&gt; {

const context = useContext(AuthStateContext);

if (!context) throw new Error(&#039;useAuthState deve estar dentro de AuthProvider&#039;);

return context;

};

export const useAuthActions = () =&gt; {

const context = useContext(AuthActionsContext);

if (!context) throw new Error(&#039;useAuthActions deve estar dentro de AuthProvider&#039;);

return context;

};</code></pre>

<h2>Tratamento de Erros e Validação</h2>

<h3>Tipagem segura com optional chaining</h3>

<p>TypeScript nos permite ser explícitos sobre possíveis valores nulos:</p>

<pre><code class="language-typescript"></code></pre>

<h3>Tratamento de erros no contexto</h3>

<p>Adicionar erro como parte do estado é essencial para aplicações robustas:</p>

<pre><code class="language-typescript">// NotificationContext.tsx

interface Notification {

id: string;

message: string;

type: &#039;success&#039; | &#039;error&#039; | &#039;warning&#039; | &#039;info&#039;;

duration?: number;

}

interface NotificationContextType {

notifications: Notification[];

addNotification: (message: string, type: Notification[&#039;type&#039;]) =&gt; void;

removeNotification: (id: string) =&gt; void;

}

const NotificationContext = createContext&lt;NotificationContextType | undefined&gt;(undefined);

export const NotificationProvider: React.FC&lt;{ children: ReactNode }&gt; = ({ children }) =&gt; {

const [notifications, setNotifications] = useState&lt;Notification[]&gt;([]);

const addNotification = (message: string, type: Notification[&#039;type&#039;]): void =&gt; {

const id = Date.now().toString();

const notification: Notification = { id, message, type };

setNotifications(prev =&gt; [...prev, notification]);

// Auto-remover depois de 5 segundos

if (notification.duration !== 0) {

setTimeout(() =&gt; removeNotification(id), notification.duration || 5000);

}

};

const removeNotification = (id: string): void =&gt; {

setNotifications(prev =&gt; prev.filter(n =&gt; n.id !== id));

};

return (

&lt;NotificationContext.Provider value={{ notifications, addNotification, removeNotification }}&gt;

{children}

&lt;/NotificationContext.Provider&gt;

);

};

export const useNotification = (): NotificationContextType =&gt; {

const context = useContext(NotificationContext);

if (!context) {

throw new Error(&#039;useNotification deve estar dentro de NotificationProvider&#039;);

}

return context;

};

// Usando com tratamento de erro

const MyComponent: React.FC = () =&gt; {

const { addNotification } = useNotification();

const { login } = useAuth();

const handleLogin = async (email: string, password: string): Promise&lt;void&gt; =&gt; {

try {

await login(email, password);

addNotification(&#039;Login realizado com sucesso!&#039;, &#039;success&#039;);

} catch (error) {

const message = error instanceof Error ? error.message : &#039;Erro ao fazer login&#039;;

addNotification(message, &#039;error&#039;);

}

};

return &lt;button onClick={() =&gt; handleLogin(&#039;user@example.com&#039;, &#039;pass&#039;)}&gt;Login&lt;/button&gt;;

};</code></pre>

<h2>Conclusão</h2>

<p>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 <code>useReducer</code>, 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.</p>

<p>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.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://react.dev/reference/react/useContext" target="_blank" rel="noopener noreferrer">React Context API - Documentação Oficial</a></li>

<li><a href="https://www.typescriptlang.org/docs/handbook/2/generics.html" target="_blank" rel="noopener noreferrer">TypeScript Handbook - Generics</a></li>

<li><a href="https://blog.logrocket.com/advanced-react-patterns/" target="_blank" rel="noopener noreferrer">Advanced Patterns with React Context - LogRocket Blog</a></li>

<li><a href="https://kentcdodds.com/blog/how-to-use-react-context-effectively" target="_blank" rel="noopener noreferrer">React useReducer Hook with TypeScript - Kent C. Dodds</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em TypeScript

Como Usar Constraints e Default Types em Generics TypeScript em Produção
Como Usar Constraints e Default Types em Generics TypeScript em Produção

Entendendo Generics em TypeScript Os generics são um dos recursos mais podero...

Como Usar Metadata e Reflect-Metadata em TypeScript com Decorators em Produção
Como Usar Metadata e Reflect-Metadata em TypeScript com Decorators em Produção

Entendendo Metadata e Reflect-Metadata Metadata é simplesmente informação sob...

Event-Driven Architecture com TypeScript: Events e Subscribers Tipados na Prática
Event-Driven Architecture com TypeScript: Events e Subscribers Tipados na Prática

O Que é Event-Driven Architecture? A Event-Driven Architecture é um padrão ar...