Fundamentos del Catálogo y el Estado del Carrito
En el núcleo de cualquier aplicación de comercio electrónico se encuentran dos componentes esenciales: el catálogo de productos y el carrito de compras. El catálogo es la interfaz de presentación, la vitrina digital donde los productos se muestran, se filtran y se exploran. Su implementación va más allá de una simple lista; involucra la gestión eficiente de datos, renderizado optimizado de listas y una experiencia de usuario fluida. Por otro lado, el carrito de compras representa el estado transaccional del usuario. Es el lugar donde las intenciones de compra se materializan y donde la aplicación debe gestionar con precisión y consistencia operaciones como agregar, remover, modificar cantidades y calcular totales.
La conexión entre ambos es el flujo de datos. El catálogo consume datos, típicamente desde una API o un estado global, y ofrece al usuario la acción de "agregar al carrito". Esta acción no debe ser un simple salto entre pantallas, sino una actualización predecible y manejable del estado de la aplicación. Aquí es donde arquitecturas de estado como Context API o bibliotecas como Redux Toolkit se vuelven cruciales, especialmente a medida que la aplicación escala. La elección entre una solución de estado local (useState) y global depende de la necesidad de acceso a los datos del carrito desde múltiples componentes profundamente anidados, como el ícono del carrito en el header, la pantalla del catálogo y la pantalla de checkout.
Un error conceptual común es tratar el carrito como un mero array de productos. En realidad, es un array de ítems del carrito, donde cada ítem referencia un producto y añade metadatos específicos de la transacción, como la cantidad seleccionada, variantes (color, talla), y posiblemente el precio en el momento de la adición (para manejar cambios de precio). Esta distinción es vital para la integridad de los datos y la lógica de negocio.
Concepto Clave: Estado Global vs. Estado Local y el Flujo de Datos Unidireccional
Imagina una biblioteca pública (tu aplicación). El catálogo de productos es el índice de tarjetas (fichas bibliográficas) disponible para todos en la mesa central. Cualquiera puede consultarlo. El carrito de compras, sin embargo, es como la carretilla personal que cada usuario toma al entrar. Solo ese usuario puede añadir o quitar libros de su carretilla. En React Native, el índice de tarjetas (catálogo) podría ser un estado local de esa pantalla o un estado global compartido si otras pantallas también lo necesitan. La carretilla (carrito) es, casi sin excepción, un estado global porque múltiples "asistentes" (componentes) necesitan interactuar con ella: la pantalla de listado de libros, la pantalla de detalles de un libro, el ícono con el contador en la esquina superior, y la pantalla de pago.
El flujo de datos unidireccional es la regla de la biblioteca: los libros solo salen del estante hacia la carretilla, y la información de la carretilla solo se actualiza en un registro central. En términos técnicos, los componentes hijos (como una tarjeta de producto) no modifican el estado global del carrito directamente. En su lugar, disparan acciones (como `addToCart(producto)`). Estas acciones son procesadas por un "reductor" (reducer) o un gestor de estado (como un Context Provider), que es el único responsable de actualizar el estado central de la carretilla. Este patrón previene efectos secundarios inesperados y hace que la depuración sea mucho más sencilla, ya que el flujo de cambios es claro y trazable.
La analogía se extiende a la persistencia: cuando el usuario sale de la biblioteca (cierra la app), ¿dejamos su carretilla en un rincón para que la encuentre mañana? Eso es persistencia del estado, típicamente lograda con AsyncStorage o MMKV. Al reabrir la app, se recupera el estado serializado del carrito, manteniendo la experiencia del usuario.
Cómo Funciona en la Práctica: Arquitectura Paso a Paso
Vamos a desglosar la implementación en pasos concretos. Primero, define la estructura de datos. Necesitarás un array de `productos`, donde cada objeto tenga `id`, `nombre`, `descripcion`, `precio`, `imagenUrl`, etc. Para el carrito, define un estado que sea un array de `items`. Cada `item` debe tener un `productId` (para referenciar el producto), el `producto` completo o un subconjunto de sus datos, una `cantidad`, y quizás un `id` único para el ítem del carrito si soportas múltiples variantes del mismo producto.
Segundo, establece el mecanismo de gestión de estado. Para una app de escala intermedia, la Context API combinada con `useReducer` es una excelente opción nativa de React. Crearás un `CartContext` que proveerá el estado del carrito y funciones despachadoras (dispatch) como `addItem`, `removeItem`, `updateQuantity`, y `clearCart`. Este contexto envolverá tu componente de navegación principal (`App` o `Root`).
Tercero, construye el catálogo. Utiliza un `` para renderizar los productos de manera eficiente. Cada ítem de la lista será un componente `` que recibe un `producto` como prop y tiene un botón "Agregar al carrito". La acción de este botón llamará a la función `addItem` obtenida del `CartContext`. Cuarto, crea una pantalla o componente dedicado para el carrito. Este componente consumirá el `CartContext` para listar los ítems, mostrar cantidades, permitir modificaciones y calcular el total sumando `item.precio * item.cantidad` para cada ítem.
Finalmente, implementa la persistencia. Dentro de tu reducer o en el efecto de inicialización del contexto, lee los datos guardados de AsyncStorage al montar la app, y guarda el estado del carrito en AsyncStorage cada vez que este cambie (usando `useEffect`).
Código en Acción: Contexto del Carrito y Catálogo Básico
A continuación, un ejemplo funcional y completo de un `CartContext` implementado con `useReducer` y persistencia, junto con un componente de catálogo simplificado.
1. Estructuras de Tipos y el Reducer (CartContext.jsx)
// CartContext.jsx
import React, { createContext, useReducer, useContext, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Definir tipos (en TypeScript se usarían interfaces)
// Product: { id: string, name: string, price: number, image: string }
// CartItem: { id: string, productId: string, product: Product, quantity: number }
// Estado inicial
const initialState = {
items: [],
total: 0,
};
// Crear Contexto
const CartContext = createContext(initialState);
// Tipos de acciones
const CartActionTypes = {
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
UPDATE_QUANTITY: 'UPDATE_QUANTITY',
CLEAR_CART: 'CLEAR_CART',
LOAD_CART: 'LOAD_CART',
};
// Función reductora (reducer)
const cartReducer = (state, action) => {
switch (action.type) {
case CartActionTypes.LOAD_CART:
return {
...action.payload,
};
case CartActionTypes.ADD_ITEM: {
const { product, quantity = 1 } = action.payload;
const existingItemIndex = state.items.findIndex(item => item.productId === product.id);
let newItems;
if (existingItemIndex > -1) {
// Item existe, actualizar cantidad
newItems = [...state.items];
newItems[existingItemIndex].quantity += quantity;
} else {
// Item nuevo
const newItem = {
id: Date.now().toString(), // ID simple para el item del carrito
productId: product.id,
product: product,
quantity: quantity,
};
newItems = [...state.items, newItem];
}
const newTotal = calculateTotal(newItems);
return { items: newItems, total: newTotal };
}
case CartActionTypes.REMOVE_ITEM: {
const { itemId } = action.payload;
const newItems = state.items.filter(item => item.id !== itemId);
const newTotal = calculateTotal(newItems);
return { items: newItems, total: newTotal };
}
case CartActionTypes.UPDATE_QUANTITY: {
const { itemId, newQuantity } = action.payload;
if (newQuantity < 1) {
// Si cantidad es menor a 1, eliminar el item
return cartReducer(state, { type: CartActionTypes.REMOVE_ITEM, payload: { itemId } });
}
const newItems = state.items.map(item =>
item.id === itemId ? { ...item, quantity: newQuantity } : item
);
const newTotal = calculateTotal(newItems);
return { items: newItems, total: newTotal };
}
case CartActionTypes.CLEAR_CART:
return initialState;
default:
return state;
}
};
// Función helper para calcular total
const calculateTotal = (items) => {
return items.reduce((sum, item) => sum + (item.product.price * item.quantity), 0);
};
// Proveedor del Contexto
export const CartProvider = ({ children }) => {
const [state, dispatch] = useReducer(cartReducer, initialState);
// Cargar carrito persistido al iniciar
useEffect(() => {
const loadCart = async () => {
try {
const savedCart = await AsyncStorage.getItem('@cart');
if (savedCart) {
dispatch({ type: CartActionTypes.LOAD_CART, payload: JSON.parse(savedCart) });
}
} catch (error) {
console.error('Error al cargar el carrito:', error);
}
};
loadCart();
}, []);
// Guardar carrito cada vez que cambie
useEffect(() => {
const saveCart = async () => {
try {
await AsyncStorage.setItem('@cart', JSON.stringify(state));
} catch (error) {
console.error('Error al guardar el carrito:', error);
}
};
saveCart();
}, [state]);
// Acciones (funciones que despachan)
const addItem = (product, quantity = 1) => {
dispatch({ type: CartActionTypes.ADD_ITEM, payload: { product, quantity } });
};
const removeItem = (itemId) => {
dispatch({ type: CartActionTypes.REMOVE_ITEM, payload: { itemId } });
};
const updateQuantity = (itemId, newQuantity) => {
dispatch({ type: CartActionTypes.UPDATE_QUANTITY, payload: { itemId, newQuantity } });
};
const clearCart = () => {
dispatch({ type: CartActionTypes.CLEAR_CART });
};
const value = {
items: state.items,
total: state.total,
addItem,
removeItem,
updateQuantity,
clearCart,
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
};
// Hook personalizado para usar el contexto
export const useCart = () => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart debe ser usado dentro de un CartProvider');
}
return context;
};
2. Componente de Catálogo de Productos (ProductCatalog.jsx)
// ProductCatalog.jsx
import React from 'react';
import { View, Text, Image, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import { useCart } from './CartContext'; // Ajusta la ruta
// Datos de ejemplo (en una app real vendrían de una API)
const PRODUCTS_DATA = [
{ id: '1', name: 'Camiseta React', price: 24.99, image: 'https://...' },
{ id: '2', name: 'Taza Expo', price: 12.50, image: 'https://...' },
{ id: '3', name: 'Libro Redux', price: 32.00, image: 'https://...' },
// ... más productos
];
const ProductCard = ({ product }) => {
const { addItem } = useCart();
const handleAddToCart = () => {
addItem(product);
// Podrías añadir un feedback visual aquí (ej: un toast)
console.log(`Añadido: ${product.name}`);
};
return (
<View style={styles.card}>
<Image source={{ uri: product.image }} style={styles.image} />
<View style={styles.info}>
<Text style={styles.name}>{product.name}</Text>
<Text style={styles.price}>${product.price.toFixed(2)}</Text>
<TouchableOpacity style={styles.button} onPress={handleAddToCart}>
<Text style={styles.buttonText}>Agregar al carrito</Text>
</TouchableOpacity>
</View>
</View>
);
};
const ProductCatalog = () => {
return (
<FlatList
data={PRODUCTS_DATA}
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={item => item.id}
contentContainerStyle={styles.container}
numColumns={2} // Para un grid de 2 columnas
/>
);
};
const styles = StyleSheet.create({
container: { padding: 10 },
card: { flex: 1, margin: 5, backgroundColor: '#fff', borderRadius: 8, padding: 10, elevation: 2 },
image: { width: '100%', height: 150, borderRadius: 4, marginBottom: 8 },
info: { flex: 1 },
name: { fontSize: 14, fontWeight: 'bold', marginBottom: 4 },
price: { fontSize: 16, color: '#2ecc71', marginBottom: 8 },
button: { backgroundColor: '#3498db', paddingVertical: 8, borderRadius: 4, alignItems: 'center' },
buttonText: { color: 'white', fontWeight: '600' },
});
export default ProductCatalog;
3. Componente de Vista del Carrito (CartScreen.jsx)
// CartScreen.jsx
import React from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Button } from 'react-native';
import { useCart } from './CartContext'; // Ajusta la ruta
const CartItem = ({ item }) => {
const { updateQuantity, removeItem } = useCart();
return (
<View style={styles.itemContainer}>
<View style={styles.itemInfo}>
<Text style={styles.itemName}>{item.product.name}</Text>
<Text style={styles.itemPrice}>${item.product.price.toFixed(2)} c/u</Text>
<View style={styles.quantityContainer}>
<TouchableOpacity onPress={() => updateQuantity(item.id, item.quantity - 1)}>
<Text style={styles.quantityButton}>-</Text>
</TouchableOpacity>
<Text style={styles.quantity}>{item.quantity}</Text>
<TouchableOpacity onPress={() => updateQuantity(item.id, item.quantity + 1)}>
<Text style={styles.quantityButton}>+</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.itemSubtotal}>
<Text style={styles.subtotalText}>${(item.product.price * item.quantity).toFixed(2)}</Text>
<TouchableOpacity onPress={() => removeItem(item.id)} style={styles.removeButton}>
<Text style={styles.removeText}>Eliminar</Text>
</TouchableOpacity>
</View>
</View>
);
};
const CartScreen = () => {
const { items, total, clearCart } = useCart();
if (items.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Tu carrito está vacío</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={items}
renderItem={({ item }) => <CartItem item={item} />}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
/>
<View style={styles.footer}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total:</Text>
<Text style={styles.totalAmount}>${total.toFixed(2)}</Text>
</View>
<Button title="Proceder al Pago" onPress={() => alert('Redirigiendo a checkout...')} color="#2ecc71" />
<TouchableOpacity onPress={clearCart} style={styles.clearButton}>
<Text style={styles.clearText}>Vaciar Carrito</Text>
</TouchableOpacity>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f8f9fa' },
list: { padding: 15 },
itemContainer: { flexDirection: 'row', backgroundColor: 'white', padding: 15, borderRadius: 8, marginBottom: 10, elevation: 1 },
itemInfo: { flex: 3 },
itemName: { fontSize: 16, fontWeight: 'bold', marginBottom: 5 },
itemPrice: { fontSize: 14, color: '#7f8c8d', marginBottom: 10 },
quantityContainer: { flexDirection: 'row', alignItems: 'center' },
quantityButton: { fontSize: 20, paddingHorizontal: 15, backgroundColor: '#ecf0f1', borderRadius: 4 },
quantity: { fontSize: 18, marginHorizontal: 15, fontWeight: 'bold' },
itemSubtotal: { flex: 1, justifyContent: 'space-between', alignItems: 'flex-end' },
subtotalText: { fontSize: 18, fontWeight: 'bold', color: '#e74c3c' },
removeButton: { marginTop: 10 },
removeText: { color: '#c0392b', fontSize: 14 },
footer: { padding: 20, borderTopWidth: 1, borderTopColor: '#ddd', backgroundColor: 'white' },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
totalLabel: { fontSize: 18, fontWeight: '600' },
totalAmount: { fontSize: 24, fontWeight: 'bold', color: '#2ecc71' },
clearButton: { marginTop: 15, alignItems: 'center' },
clearText: { color: '#e74c3c', fontSize: 16 },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
emptyText: { fontSize: 20, color: '#95a5a6' },
});
export default CartScreen;
Tip Profesional: En una aplicación de producción, nunca almacenes el precio *solo* en el objeto del producto que viene de la API. Al añadirlo al carrito, captura y guarda el precio en ese momento en el ítem del carrito. Esto evita problemas si el precio del producto cambia entre el momento en que el usuario lo agrega y cuando finaliza la compra. Es una buena práctica de negocio.
Errores Comunes y Cómo Evitarlos
1. Mutación Directa del Estado: Modificar directamente un array o objeto del estado (ej: `state.items.push(newItem)`) es el error más común en React. Esto causa renders impredecibles y bugs difíciles de rastrear. Solución: Siempre crea copias nuevas del estado. Usa el operador de propagación (`...`) o métodos que devuelvan nuevos arrays/objetos como `.map()`, `.filter()`, y `.reduce()`. El reducer en nuestro ejemplo lo hace correctamente.
2. Claves (Keys) No Estables en FlatList: Usar el índice del array o valores no únicos como `key` en un `` de productos o ítems del carrito puede causar rendimiento pobre y comportamientos erráticos al reordenar o filtrar. Solución: Asegúrate de que cada producto e ítem del carrito tenga un `id` único y estable (de tu backend). Úsalo en la prop `keyExtractor`.
3. Lógica de Negocio Esparcida en Componentes: Calcular el total del carrito dentro del componente `CartScreen` o verificar si un producto ya está en el carrito dentro de `ProductCard` lleva a duplicación de código y dificulta el mantenimiento. Solución: Centraliza toda la lógica en el reducer o en funciones selectoras dentro del contexto. Nuestra función `calculateTotal` y la lógica para encontrar ítems existentes están dentro del reducer.
4. No Manejar Estados de Carga y Error en la Persistencia: Asumir que `AsyncStorage` siempre funcionará y no proveer un estado de carga o manejo de errores puede llevar a una experiencia de usuario frustrante (carrito vacío al recargar cuando sí tenía items). Solución: Añade un estado `isLoading` en tu contexto. Muestra un indicador de carga mientras se lee del almacenamiento, y considera una estrategia de reintento o un mensaje amigable en caso de error.
5. Olvidar la Serialización de Datos Complejos: `AsyncStorage` solo guarda strings. Si intentas guardar directamente un objeto que contiene funciones, instancias de clases o referencias circulares, fallará silenciosamente o guardará `[object Object]`. Solución: Siempre usa `JSON.stringify()` al guardar y `JSON.parse()` al leer. Asegúrate de que tu estado del carrito sea serializable (solo datos planos, sin funciones).
Checklist de Dominio
Para verificar que has comprendido e implementado correctamente esta lección, asegúrate de poder marcar cada uno de los siguientes puntos:
- He definido estructuras de datos claras para `Producto` e `Ítem del Carrito`, diferenciando entre el ID del producto y el ID único del ítem en el carrito.
- He implementado un sistema de gestión de estado global (Context API + useReducer o Redux Toolkit) para el carrito, que provee funciones para añadir, remover, actualizar cantidad y limpiar.
- Mi catálogo de productos utiliza `` para un renderizado eficiente, y cada tarjeta de producto despacha la acción `addToCart` al contexto global.
- La pantalla del carrito consume el estado global, lista los ítems, permite modificar cantidades, eliminar productos y muestra un total calculado dinámicamente.
- He implementado persistencia del estado del carrito usando AsyncStorage (o MMKV), cargando los datos al inicio de la app y guardándolos tras cada modificación.
- La lógica de negocio (cálculo de totales, manejo de ítems duplicados) reside dentro del reducer o selector del estado global, no en los componentes UI.
- He manejado los estados de borde: carrito vacío (con