Esta guía explora dos patrones poderosos para manejar el estado en aplicaciones React que superan las capacidades de un simple useState
.
- El Patrón nativo (
useContext
+useReducer
): La solución integrada de React para manejar estado complejo y compartirlo a través de la aplicación sin necesidad de librerías externas. Ideal para aplicaciones de tamaño pequeño a mediano. - Redux con Hooks (
useSelector
yuseDispatch
): La solución estándar de la industria para aplicaciones a gran escala que requieren un estado global predecible, herramientas de depuración avanzadas y un ecosistema robusto.
🔹 1. El patrón nativo: useContext
+ useReducer
Para muchas aplicaciones, no necesitas una librería externa como Redux. La combinación de useContext
y useReducer
te da un "mini-Redux" propio, directamente con las herramientas que provee React.
¿Cuándo es esta la solución correcta?
- Cuando el estado es demasiado complejo para
useState
. - Cuando la lógica de actualización del estado es compartida por muchos componentes.
- Cuando necesitas evitar el "prop drilling" (pasar props a través de muchos niveles).
- Cuando quieres una solución ligera sin añadir dependencias a tu proyecto.
El patrónexplicado
La idea es simple:
-
useReducer
: Centraliza toda la lógica de estado en una funciónreducer
. -
useContext
: Provee elestado
y la funcióndispatch
a cualquier componente hijo que lo necesite, sin pasar props manualmente.
Ejemplo práctico: carrito de compras
Vamos a implementar un carrito de compras. La lógica (añadir, eliminar, etc.) vivirá en el reducer, y el estado del carrito estará disponible en toda la aplicación gracias al contexto.
Paso 1: Crear el CartContext.js
Este archivo contendrá toda nuestra lógica: el estado inicial, las acciones, el reducer y el proveedor del contexto.
// contexts/CartContext.js
import React, { createContext, useReducer, useContext } from 'react';
// --- Estado Inicial y Acciones ---
const initialState = {
items: [],
total: 0,
};
export const CartActions = {
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
CLEAR_CART: 'CLEAR_CART',
};
// --- El Reducer: El cerebro de nuestro carrito ---
function cartReducer(state, action) {
switch (action.type) {
case CartActions.ADD_ITEM: {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
let updatedItems;
if (existingItem) {
updatedItems = state.items.map(item =>
item.id === newItem.id ? { ...item, quantity: item.quantity + 1 } : item
);
} else {
updatedItems = [...state.items, { ...newItem, quantity: 1 }];
}
return {
...state,
items: updatedItems,
total: state.total + newItem.price,
};
}
case CartActions.REMOVE_ITEM: {
const itemToRemove = state.items.find(item => item.id === action.payload.id);
if (!itemToRemove) return state;
const updatedItems = state.items.filter(item => item.id !== itemToRemove.id);
return {
...state,
items: updatedItems,
total: state.total - (itemToRemove.price * itemToRemove.quantity),
};
}
case CartActions.CLEAR_CART:
return initialState;
default:
return state;
}
}
// --- Creación del Contexto y el Proveedor ---
const CartContext = createContext();
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
// El `value` del proveedor incluye tanto el estado como la función dispatch
const value = { state, dispatch };
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
// --- Hook Personalizado para Consumir el Contexto ---
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart debe ser usado dentro de un CartProvider');
}
return context;
}
Paso 2: Envolver la aplicación con el CartProvider
En tu archivo principal (App.js
o similar), envuelve los componentes que necesitan acceso al carrito.
// App.js
import { CartProvider } from './contexts/CartContext';
import ProductList from './components/ProductList';
import CartSummary from './components/CartSummary';
function App() {
return (
<CartProvider>
<h1>Mi Tienda</h1>
<ProductList />
<CartSummary />
</CartProvider>
);
}
Paso 3: Usar el hook useCart
en los componentes
Ahora, cualquier componente puede acceder y manipular el estado del carrito de forma muy limpia.
// components/ProductList.js
import { useCart, CartActions } from '../contexts/CartContext';
const products = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Teclado', price: 80 },
];
function ProductList() {
const { dispatch } = useCart();
const handleAddToCart = (product) => {
dispatch({ type: CartActions.ADD_ITEM, payload: product });
};
return (
<div>
<h2>Productos</h2>
{products.map(p => (
<div key={p.id}>
{p.name} - ${p.price}
<button onClick={() => handleAddToCart(p)}>Añadir al carrito</button>
</div>
))}
</div>
);
}
// components/CartSummary.js
import { useCart } from '../contexts/CartContext';
function CartSummary() {
const { state } = useCart(); // Solo necesitamos el estado aquí
return (
<div>
<h2>Resumen del Carrito</h2>
<p>Items: {state.items.length}</p>
<p>Total: ${state.total.toFixed(2)}</p>
</div>
);
}
Ventajas de este patrón
- Sin dependencias externas: Usa solo APIs de React.
- Lógica centralizada: El
reducer
es el único lugar donde se modifica el estado, haciéndolo predecible. - Código limpio: Los componentes son simples, solo despachan acciones o leen el estado.
Desventajas a considerar
- Optimización de Renders: Por defecto, cualquier cambio en el contexto (incluso en una parte del estado que un componente no usa) hará que todos los componentes que consumen el contexto se re-rendericen. Esto puede ser un problema de rendimiento en apps grandes y requiere optimizaciones manuales con
useMemo
o separando contextos. - Sin DevTools: No tienes herramientas avanzadas de depuración como el Redux DevTools para inspeccionar el historial de acciones y estados.
🔹 2. Redux con hooks: useSelector
y useDispatch
Redux es la librería más popular para gestionar el estado en aplicaciones JavaScript grandes. Aunque antes era conocido por su "boilerplate" (código repetitivo), Redux Toolkit (RTK) lo ha modernizado enormemente, haciéndolo mucho más simple y directo.
¿Cuándo dar el salto a Redux?
- Cuando el estado de tu aplicación es grande, complejo y compartido por muchas partes de la UI.
- Cuando necesitas una lógica de "middleware" para manejar efectos secundarios como llamadas a API de forma organizada (ej. Redux Thunk, Saga).
- Cuando la depuración se vuelve difícil y necesitas herramientas avanzadas para viajar en el tiempo, inspeccionar cada acción y ver cómo cambia el estado (Redux DevTools).
- Cuando el rendimiento se convierte en un problema y las optimizaciones de
useContext
no son suficientes. Redux está altamente optimizado para evitar re-renderizados innecesarios.
Ejemplo páctico: Carrito de Compras con Redux Toolkit
Vamos a reconstruir el mismo carrito de compras, pero esta vez con la potencia de Redux Toolkit.
Paso 1: Instalar las dependencias
npm install @reduxjs/toolkit react-redux
Paso 2: Crear un "Slice" del Carrito
En Redux Toolkit, un "slice" es una porción del estado de Redux que contiene la lógica del reducer y las acciones para ese estado específico. ¡Todo en un solo archivo!
// store/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
items: [],
total: 0,
};
const cartSlice = createSlice({
name: 'cart',
initialState,
// Los "reducers" aquí son como los "cases" del switch.
// RTK usa Immer por debajo, así que puedes "mutar" el estado de forma segura.
reducers: {
addItem(state, action) {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
if (existingItem) {
existingItem.quantity++;
} else {
state.items.push({ ...newItem, quantity: 1 });
}
state.total += newItem.price;
},
removeItem(state, action) {
const idToRemove = action.payload.id;
const itemToRemove = state.items.find(item => item.id === idToRemove);
if (itemToRemove) {
state.total -= itemToRemove.price * itemToRemove.quantity;
state.items = state.items.filter(item => item.id !== idToRemove);
}
},
clearCart(state) {
state.items = [];
state.total = 0;
},
},
});
// RTK genera automáticamente las acciones a partir de tus reducers
export const { addItem, removeItem, clearCart } = cartSlice.actions;
// Exportamos el reducer para añadirlo al store
export default cartSlice.reducer;
Paso 3: Configurar el Store de Redux
El "store" es el objeto global que contiene todo el estado de tu aplicación.
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
// Aquí puedes añadir otros slices, como: user: userReducer
},
});
Paso 4: Proveer el Store a la Aplicación
Similar a Context
, Redux tiene un Provider
que debe envolver tu aplicación.
// index.js (o App.js)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
Paso 5: Usar useSelector
y useDispatch
en los Componentes
Ahora, refactorizamos nuestros componentes para usar los hooks de Redux.
-
useSelector(selectorFn)
: Lee datos del store. La funciónselector
recibe el estado completo y devuelve la parte que te interesa. -
useDispatch()
: Devuelve la funcióndispatch
del store para que puedas enviar acciones.
// components/ProductList.js
import { useDispatch } from 'react-redux';
import { addItem } from '../store/cartSlice'; // Importamos la acción
const products = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Teclado', price: 80 },
];
function ProductList() {
const dispatch = useDispatch(); // Obtenemos la función dispatch
const handleAddToCart = (product) => {
// Despachamos la acción `addItem` con el producto como payload
dispatch(addItem(product));
};
return (
<div>
<h2>Productos</h2>
{products.map(p => (
<div key={p.id}>
{p.name} - ${p.price}
<button onClick={() => handleAddToCart(p)}>Añadir al carrito</button>
</div>
))}
</div>
);
}
// components/CartSummary.js
import { useSelector } from 'react-redux';
function CartSummary() {
// `useSelector` se suscribe a los cambios del store.
// El componente se re-renderizará solo si los valores devueltos cambian.
const cartItems = useSelector(state => state.cart.items);
const cartTotal = useSelector(state => state.cart.total);
return (
<div>
<h2>Resumen del Carrito</h2>
<p>Items: {cartItems.length}</p>
<p>Total: ${cartTotal.toFixed(2)}</p>
</div>
);
}
🔹 3. Tabla comparativa: useContext/Reducer
vs. Redux
Característica | Patrón nativo (useContext + useReducer ) |
Redux Toolkit con Hooks |
---|---|---|
Dependencias | 0 (integrado en React) |
@reduxjs/toolkit , react-redux
|
Ideal para | Apps pequeñas a medianas, estado "localizado" | Apps medianas a grandes, estado global complejo |
Rendimiento | Bueno, pero requiere optimización manual (useMemo ) para evitar renders innecesarios. |
Altamente optimizado. Los componentes solo se re-renderizan si la porción de datos que seleccionan cambia. |
DevTools | No disponibles | Redux DevTools: depuración avanzada, viaje en el tiempo, inspección de acciones. |
Middleware | No soportado de forma nativa. | Soportado (Thunk viene por defecto). Excelente para lógica asíncrona. |
Boilerplate | Moderado (crear contexto, reducer, proveedor). | Mínimo con Redux Toolkit, pero más archivos y conceptos que aprender. |
Curva de Aprendizaje | Fácil si ya conoces los hooks de React. | Moderada. Requiere entender slices, actions, store. |
Conclusión: ¿cuál elegir?
La recomendación moderna es:
- Empieza siempre con las herramientas de React: Comienza con
useState
. Si el estado se vuelve complejo, refactoriza auseReducer
. Si necesitas compartir ese estado, combínalo conuseContext
. - Adopta Redux cuando sientas la necesidad: No empieces un proyecto con Redux "por si acaso". Introdúcelo cuando te enfrentes a los problemas que Redux resuelve brillantemente: depuración de estado compleja, manejo avanzado de efectos secundarios o cuellos de botella de rendimiento relacionados con
useContext
.
Top comments (0)