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

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

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.

Referências


Artigos relacionados