Tipando contextos do React com generics

1. Por que usar generics em contextos React?

Contextos são fundamentais para compartilhar estado entre componentes React, mas sua tipagem tradicional frequentemente leva a problemas. Sem generics, somos forçados a usar tipos amplos como any ou criar contextos específicos para cada caso, resultando em código duplicado e propenso a erros.

// Sem generics - problemas comuns
const ThemeContext = createContext<any>(null); // Perde toda segurança de tipos
const UserContext = createContext<UserType | null>(null); // Tipagem fixa, inflexível

Com generics, ganhamos:
- Reutilização: um único padrão de contexto serve para múltiplos tipos
- Segurança: o TypeScript valida os tipos em tempo de compilação
- Inferência automática: hooks customizados herdam os tipos do contexto

A diferença fundamental está na sintaxe:

// Sem generic - tipagem fixa
const ThemeContext = createContext<Theme>(defaultTheme);

// Com generic - tipagem flexível e reutilizável
function createThemeContext<T extends Theme>() {
  return createContext<T>(defaultTheme as T);
}

2. Criando um contexto genérico básico

A sintaxe para criar um contexto genérico é direta: createContext<T>(defaultValue). O tipo T define a estrutura do valor que o contexto armazenará.

interface Theme {
  primaryColor: string;
  secondaryColor: string;
  fontSize: number;
}

const defaultTheme: Theme = {
  primaryColor: '#3498db',
  secondaryColor: '#2ecc71',
  fontSize: 16
};

// Contexto tipado com generic
const ThemeContext = createContext<Theme>(defaultTheme);

// Uso no provedor
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(defaultTheme);

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

3. Tipando provedores com generics

Para criar provedores verdadeiramente reutilizáveis, combinamos generics com restrições de tipo usando extends:

interface ContextProvider<T extends Record<string, unknown>> {
  value: T;
  children: React.ReactNode;
  onChange?: (newValue: T) => void;
}

function GenericProvider<T extends Record<string, unknown>>({ 
  value, 
  children, 
  onChange 
}: ContextProvider<T>) {
  const contextValue = useMemo(() => ({
    ...value,
    update: (newValue: T) => onChange?.(newValue)
  }), [value, onChange]);

  return (
    <SomeContext.Provider value={contextValue}>
      {children}
    </SomeContext.Provider>
  );
}

A restrição T extends Record<string, unknown> garante que apenas objetos sejam usados, mantendo a flexibilidade.

4. Hooks customizados com inferência de tipo

Hooks customizados são a chave para uma experiência de desenvolvimento fluida com contextos genéricos:

function createContextHook<T>() {
  const Context = createContext<T | undefined>(undefined);

  function useContextValue(): T {
    const context = useContext(Context);
    if (!context) {
      throw new Error('Context must be used within its Provider');
    }
    return context;
  }

  return { Context, useContextValue };
}

// Uso prático
const { Context: ThemeContext, useContextValue: useTheme } = createContextHook<Theme>();

function ThemedComponent() {
  const theme = useTheme(); // theme é inferido como Theme
  return <div style={{ color: theme.primaryColor }}>Themed!</div>;
}

5. Contextos com ações e estado (reducer pattern)

Combinar useReducer com contextos genéricos cria um padrão poderoso para gerenciamento de estado:

type Action<T> = 
  | { type: 'SET_DATA'; payload: T }
  | { type: 'RESET' }
  | { type: 'UPDATE'; payload: Partial<T> };

interface State<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
}

function createReducerContext<T>() {
  const Context = createContext<{
    state: State<T>;
    dispatch: React.Dispatch<Action<T>>;
  } | undefined>(undefined);

  function reducer(state: State<T>, action: Action<T>): State<T> {
    switch (action.type) {
      case 'SET_DATA':
        return { ...state, data: action.payload, isLoading: false, error: null };
      case 'RESET':
        return { data: null, isLoading: false, error: null };
      case 'UPDATE':
        return { ...state, data: { ...state.data, ...action.payload } as T };
      default:
        return state;
    }
  }

  function Provider({ children, initialData }: { 
    children: React.ReactNode; 
    initialData?: T 
  }) {
    const [state, dispatch] = useReducer(reducer, {
      data: initialData || null,
      isLoading: false,
      error: null
    });

    return (
      <Context.Provider value={{ state, dispatch }}>
        {children}
      </Context.Provider>
    );
  }

  function useDataContext() {
    const context = useContext(Context);
    if (!context) throw new Error('Must be used within Provider');
    return context;
  }

  return { Provider, useDataContext };
}

// Exemplo de uso
interface User {
  id: number;
  name: string;
  email: string;
}

const { Provider: UserProvider, useDataContext: useUser } = createReducerContext<User>();

function UserProfile() {
  const { state, dispatch } = useUser();

  const updateName = (name: string) => {
    dispatch({ type: 'UPDATE', payload: { name } });
  };

  return (
    <div>
      {state.data?.name}
      <button onClick={() => updateName('Novo Nome')}>Atualizar</button>
    </div>
  );
}

6. Contextos assíncronos e loading states

Para dados de API, combinamos tipos de união com contextos genéricos:

type AsyncState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

interface DataContextValue<T> {
  state: AsyncState<T>;
  fetchData: (url: string) => Promise<void>;
  clearData: () => void;
}

function createDataContext<T>() {
  const Context = createContext<DataContextValue<T> | undefined>(undefined);

  function DataProvider({ children }: { children: React.ReactNode }) {
    const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });

    const fetchData = async (url: string) => {
      setState({ status: 'loading' });
      try {
        const response = await fetch(url);
        const data: T = await response.json();
        setState({ status: 'success', data });
      } catch (error) {
        setState({ status: 'error', error: String(error) });
      }
    };

    const clearData = () => setState({ status: 'idle' });

    return (
      <Context.Provider value={{ state, fetchData, clearData }}>
        {children}
      </Context.Provider>
    );
  }

  function useData() {
    const context = useContext(Context);
    if (!context) throw new Error('Must be used within DataProvider');
    return context;
  }

  return { DataProvider, useData };
}

// Uso com User
interface User {
  id: number;
  name: string;
}

const { DataProvider: UserDataProvider, useData: useUserData } = createDataContext<User>();

function UserList() {
  const { state, fetchData } = useUserData();

  useEffect(() => {
    fetchData('/api/users');
  }, []);

  if (state.status === 'loading') return <div>Loading...</div>;
  if (state.status === 'error') return <div>Error: {state.error}</div>;
  if (state.status === 'success') return <div>{state.data.name}</div>;
  return null;
}

7. Boas práticas e armadilhas comuns

Evitando any no valor padrão:

// Ruim
const Context = createContext<any>(null);

// Bom - use undefined e trate no hook
const Context = createContext<T | undefined>(undefined);

Usando as com cautela:

// Evite isso
const value = { name: 'John' } as T;

// Prefira validação explícita
function isValidUser(data: unknown): data is User {
  return typeof data === 'object' && data !== null && 'name' in data;
}

Performance com memoização:

function Provider({ children }: { children: React.ReactNode }) {
  const [data, setData] = useState<T | null>(null);

  // Sempre memoize valores do contexto
  const value = useMemo(() => ({
    data,
    update: (newData: T) => setData(newData)
  }), [data]);

  return <Context.Provider value={value}>{children}</Context.Provider>;
}

8. Exemplo completo: sistema de notificações tipado

interface Notification<T = unknown> {
  id: string;
  type: 'success' | 'error' | 'info' | 'warning';
  message: string;
  payload?: T;
  duration?: number;
}

interface NotificationContextValue<T> {
  notifications: Notification<T>[];
  addNotification: (notification: Omit<Notification<T>, 'id'>) => void;
  removeNotification: (id: string) => void;
  clearAll: () => void;
}

function createNotificationContext<T>() {
  const Context = createContext<NotificationContextValue<T> | undefined>(undefined);

  function NotificationProvider({ children }: { children: React.ReactNode }) {
    const [notifications, setNotifications] = useState<Notification<T>[]>([]);

    const addNotification = useCallback(
      (notification: Omit<Notification<T>, 'id'>) => {
        const id = crypto.randomUUID();
        const newNotification: Notification<T> = { ...notification, id };

        setNotifications(prev => [...prev, newNotification]);

        if (notification.duration) {
          setTimeout(() => removeNotification(id), notification.duration);
        }
      },
      []
    );

    const removeNotification = useCallback((id: string) => {
      setNotifications(prev => prev.filter(n => n.id !== id));
    }, []);

    const clearAll = useCallback(() => {
      setNotifications([]);
    }, []);

    const value = useMemo(() => ({
      notifications,
      addNotification,
      removeNotification,
      clearAll
    }), [notifications, addNotification, removeNotification, clearAll]);

    return (
      <Context.Provider value={value}>
        {children}
      </Context.Provider>
    );
  }

  function useNotification() {
    const context = useContext(Context);
    if (!context) {
      throw new Error('useNotification must be used within NotificationProvider');
    }
    return context;
  }

  return { NotificationProvider, useNotification };
}

// Uso com payload tipado
interface OrderPayload {
  orderId: string;
  total: number;
  items: string[];
}

const { NotificationProvider: OrderNotificationProvider, useNotification: useOrderNotification } = 
  createNotificationContext<OrderPayload>();

function OrderComponent() {
  const { addNotification } = useOrderNotification();

  const handleOrder = () => {
    addNotification({
      type: 'success',
      message: 'Pedido confirmado!',
      payload: {
        orderId: '123',
        total: 250.00,
        items: ['Item A', 'Item B']
      },
      duration: 5000
    });
  };

  return <button onClick={handleOrder}>Finalizar Pedido</button>;
}

Este sistema de notificações demonstra como generics permitem criar contextos reutilizáveis, tipados e seguros, adaptando-se perfeitamente a diferentes tipos de payload sem perder a segurança de tipos do TypeScript.

Referências

Conclusão

Neste artigo, exploramos como os generics do TypeScript podem transformar a maneira como tipamos contextos do React, oferecendo segurança, reutilização e flexibilidade. Começamos entendendo os problemas comuns de tipagem em contextos tradicionais e como os generics resolvem essas questões. Em seguida, construímos desde contextos genéricos básicos até padrões avançados como contextos assíncronos e sistemas de notificações tipados.

Os principais aprendizados incluem:

  • Segurança de tipos: Contextos genéricos eliminam a necessidade de any e garantem que o tipo correto seja inferido em todos os níveis da aplicação.
  • Reutilização: Uma única definição de contexto pode ser adaptada para diferentes tipos de dados, reduzindo duplicação de código.
  • Inferência automática: Hooks customizados com generics permitem que o TypeScript infira automaticamente os tipos, proporcionando uma experiência de desenvolvimento mais fluida.
  • Padrões avançados: Combinação com useReducer, estados assíncronos e memoização garantem que contextos genéricos sejam tão performáticos quanto seguros.

Ao adotar esses padrões, você estará construindo aplicações React mais robustas, com tipagem forte que previne erros em tempo de compilação e melhora a experiência do desenvolvedor. Lembre-se sempre de evitar any, usar undefined como valor padrão quando necessário, e aplicar memoização para garantir performance em contextos com valores que mudam frequentemente.

Os generics não são apenas uma ferramenta de tipagem — eles são uma forma de expressar contratos claros entre componentes, tornando seu código mais previsível, autodocumentado e fácil de manter.