Inicio / TypeScript / React: Frontend Moderno con TypeScript / Context API y estado global

Context API y estado global

Prop drilling, createContext, Provider, consumer hooks y Context + useReducer.

Intermedio Web
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Context API y Estado Global

Cuando múltiples componentes en distintas partes del árbol necesitan acceder a los mismos datos, la Context API permite compartir estado sin pasar props por cada nivel intermedio (prop drilling).


El problema del prop drilling

// Sin Context: las props viajan por componentes que no las usan
function App() {
  const [user, setUser] = useState<User>(currentUser)
  return <Layout user={user} onLogout={() => setUser(null)} />
}

function Layout({ user, onLogout }) {
  return <Header user={user} onLogout={onLogout} />  // Layout no usa user, solo lo pasa
}

function Header({ user, onLogout }) {
  return <UserMenu user={user} onLogout={onLogout} />  // Header tampoco
}

function UserMenu({ user, onLogout }) {
  // El único que realmente necesita user y onLogout
  return <span>{user.name} <button onClick={onLogout}>Salir</button></span>
}

Con Context, UserMenu accede directamente al usuario sin que Layout ni Header lo transporten.


Crear un Context paso a paso

1. Definir el tipo y crear el contexto

import { createContext, useContext, useState, useCallback } from 'react'

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

interface AuthContextType {
  user: User | null
  isAuthenticated: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = createContext<AuthContextType | null>(null)

2. Crear el Provider

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)

  const login = useCallback(async (email: string, password: string) => {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
    if (!res.ok) throw new Error('Credenciales inválidas')
    const data = await res.json()
    setUser(data.user)
    localStorage.setItem('token', data.token)
  }, [])

  const logout = useCallback(() => {
    setUser(null)
    localStorage.removeItem('token')
  }, [])

  return (
    <AuthContext.Provider value={{
      user,
      isAuthenticated: !!user,
      login,
      logout,
    }}>
      {children}
    </AuthContext.Provider>
  )
}

3. Custom hook para consumir

function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth debe usarse dentro de <AuthProvider>')
  }
  return context
}

4. Envolver la app y consumir

// main.tsx
function App() {
  return (
    <AuthProvider>
      <Router />
    </AuthProvider>
  )
}

// En cualquier componente:
function Navbar() {
  const { user, isAuthenticated, logout } = useAuth()

  return (
    <nav>
      {isAuthenticated ? (
        <>
          <span>Hola, {user!.name}</span>
          <button onClick={logout}>Cerrar sesión</button>
        </>
      ) : (
        <Link to="/login">Iniciar sesión</Link>
      )}
    </nav>
  )
}

Múltiples Contexts

Crea un contexto por dominio o responsabilidad:

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          <CartProvider>
            <Router />
          </CartProvider>
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  )
}

Context de tema completo

type Theme = 'light' | 'dark'

interface ThemeContextType {
  theme: Theme
  toggleTheme: () => void
  colors: { bg: string; text: string; primary: string }
}

const themes = {
  light: { bg: '#ffffff', text: '#1a1a1a', primary: '#3b82f6' },
  dark:  { bg: '#1a1a1a', text: '#f5f5f5', primary: '#60a5fa' },
}

const ThemeContext = createContext<ThemeContextType | null>(null)

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(() => {
    return (localStorage.getItem('theme') as Theme) || 'light'
  })

  const toggleTheme = useCallback(() => {
    setTheme(prev => {
      const next = prev === 'light' ? 'dark' : 'light'
      localStorage.setItem('theme', next)
      return next
    })
  }, [])

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, colors: themes[theme] }}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme() {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme debe usarse dentro de ThemeProvider')
  return ctx
}

Context + useReducer (patrón escalable)

Para estado complejo, combinar Context con useReducer es un patrón potente (mini Redux):

// ── Tipos ─────────────────────────────────────────────────────────────────
interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  total: number
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
  | { type: 'REMOVE_ITEM'; payload: number }
  | { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } }
  | { type: 'CLEAR' }

// ── Reducer ───────────────────────────────────────────────────────────────
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id)
      const items = existing
        ? state.items.map(i => i.id === action.payload.id
            ? { ...i, quantity: i.quantity + 1 }
            : i)
        : [...state.items, { ...action.payload, quantity: 1 }]
      return { items, total: items.reduce((s, i) => s + i.price * i.quantity, 0) }
    }
    case 'REMOVE_ITEM': {
      const items = state.items.filter(i => i.id !== action.payload)
      return { items, total: items.reduce((s, i) => s + i.price * i.quantity, 0) }
    }
    case 'UPDATE_QUANTITY': {
      const items = state.items.map(i =>
        i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i
      ).filter(i => i.quantity > 0)
      return { items, total: items.reduce((s, i) => s + i.price * i.quantity, 0) }
    }
    case 'CLEAR':
      return { items: [], total: 0 }
    default:
      return state
  }
}

// ── Provider ──────────────────────────────────────────────────────────────
const CartContext = createContext<{
  state: CartState
  dispatch: React.Dispatch<CartAction>
} | null>(null)

function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 })

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

// ── Hook con acciones de alto nivel ───────────────────────────────────────
function useCart() {
  const ctx = useContext(CartContext)
  if (!ctx) throw new Error('useCart debe usarse dentro de CartProvider')

  const { state, dispatch } = ctx

  return {
    items: state.items,
    total: state.total,
    itemCount: state.items.reduce((s, i) => s + i.quantity, 0),
    addItem: (item: Omit<CartItem, 'quantity'>) =>
      dispatch({ type: 'ADD_ITEM', payload: item }),
    removeItem: (id: number) =>
      dispatch({ type: 'REMOVE_ITEM', payload: id }),
    updateQuantity: (id: number, quantity: number) =>
      dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }),
    clear: () => dispatch({ type: 'CLEAR' }),
  }
}

Cuándo usar Context vs otras soluciones

Escenario Solución
Tema, idioma, auth global ✅ Context
Estado de un formulario ❌ useState / useReducer local
Datos de servidor (caché, revalidación) ❌ TanStack Query / SWR
Estado global complejo (muchas acciones) Considerar Redux Toolkit
Datos que solo necesita un componente ❌ Estado local

Optimización de Context

Cuando el valor del Context cambia, todos los consumidores se re-renderizan. Estrategias para evitarlo:

Separar contexts por frecuencia de cambio

// ❌ Un solo context que cambia mucho
<AppContext.Provider value={{ user, theme, notifications, cart }}>

// ✅ Separar en contexts independientes
<AuthProvider>
  <ThemeProvider>
    <NotificationProvider>
      <CartProvider>

Memoizar el value del Provider

function MyProvider({ children }) {
  const [value, setValue] = useState(0)

  // ✅ Memoizar para que la referencia no cambie en cada render del provider
  const contextValue = useMemo(() => ({
    value,
    increment: () => setValue(v => v + 1),
  }), [value])

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

Resumen

Concepto Descripción
createContext() Crea un contexto con valor por defecto
<Context.Provider> Provee el valor a los descendientes
useContext() Consume el valor del contexto más cercano
Custom hook (useAuth) Encapsula useContext + validación
Context + useReducer Mini Redux para estado compartido complejo
Separar contexts Optimización: evita re-renders innecesarios
🔒

Ejercicio práctico disponible

Implementa un sistema de theme y auth con Context

Desbloquear ejercicios
// Implementa un sistema de theme y auth con Context
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes