Introducción: Arquitectura de un E-commerce Móvil
En esta lección práctica, integraremos todos los conceptos aprendidos para construir una aplicación de comercio electrónico completamente funcional. No se trata de un ejercicio aislado, sino de un proyecto integrador que simula las condiciones reales de desarrollo. Partiremos de una estructura de carpetas organizada y configuraremos las herramientas esenciales para un flujo de trabajo profesional. La aplicación, que llamaremos "ShopRN", contará con autenticación de usuarios, un catálogo de productos, un carrito de compras persistente, un proceso de checkout simulado y un perfil de usuario.
La arquitectura que seguiremos se basa en una separación clara de responsabilidades. Tendremos una capa de componentes visuales reutilizables (botones, tarjetas, headers), pantallas que componen esas vistas (Home, Detalle de Producto, Carrito), un estado global gestionado por Context API para el carrito y la sesión del usuario, y una capa de servicios para comunicarnos con una API simulada. Utilizaremos Expo Router para la navegación basada en archivos, lo que agiliza enormemente el desarrollo. Este enfoque modular no solo facilita el desarrollo, sino también el mantenimiento y la escalabilidad futura de la aplicación.
Antes de comenzar a codificar, es crucial entender el flujo de datos. El usuario navega por productos que se fetchean de una API. Al agregar un ítem al carrito, este dato se almacena en un contexto global, permitiendo su acceso desde cualquier pantalla. Al iniciar sesión, un token simulado se guarda de forma segura usando AsyncStorage. Este flujo unidireccional y centralizado es la clave para evitar estados inconsistentes en la interfaz, un problema común en aplicaciones complejas.
Concepto Clave: Estado Global y Gestión del Carrito
Imagina un carrito de compras físico en un supermercado. Sin importar en qué pasillo te encuentres (la pantalla de productos, la de ofertas, o la de lácteos), tu carrito te acompaña, manteniendo todos los productos que has decidido llevar. En React Native, el carrito es un estado global que debe ser accesible desde múltiples componentes y pantallas sin tener que pasar props manualmente a través de cada nivel del árbol de componentes (un proceso conocido como "prop drilling").
Para gestionar este estado, utilizaremos la Context API de React combinada con un useReducer. Esta combinación es poderosa para estados complejos que involucran transacciones. El Context provee el mecanismo para distribuir el estado, mientras que el reducer define de manera predecible cómo ese estado puede cambiar (por ejemplo, agregar un ítem, removerlo, cambiar la cantidad). Cada acción (como 'ADD_TO_CART') es un objeto que describe "qué pasó", y el reducer es una función pura que toma el estado anterior y esa acción para calcular el nuevo estado.
Tip Profesional: Aunque para aplicaciones muy grandes se podría considerar Redux Toolkit, para la mayoría de las apps de e-commerce, Context API + useReducer es más que suficiente, manteniendo la simplicidad y reduciendo la cantidad de código boilerplate.
Cómo Funciona en la Práctica: Configuración del Contexto del Carrito
Vamos a implementar esto paso a paso. Primero, crearemos un nuevo directorio context en la raíz de nuestro proyecto. Dentro, crearemos el archivo CartContext.jsx. Este archivo tendrá tres partes principales: 1) La definición del estado inicial y la función reductora (reducer), 2) La creación del Contexto (React.createContext), y 3) El componente proveedor (Provider) que envolverá nuestra aplicación.
El estado inicial será un objeto con una propiedad items (un array de productos en el carrito) y total (la suma del precio de todos los ítems). El reducer manejará las acciones: ADD_ITEM, REMOVE_ITEM, CLEAR_CART. Cada acción modificará el estado de forma inmutable, es decir, siempre devolviendo un nuevo objeto estado en lugar de mutar el existente. Esto es fundamental para que React detecte los cambios y re-renderice los componentes correctamente.
Finalmente, exportaremos un hook personalizado llamado useCart. Este hook utilizará el useContext para consumir nuestro contexto. La ventaja es que si intentamos usar useCart fuera del árbol envuelto por el Provider, obtendremos un error claro, lo que facilita la depuración. Luego, en el archivo raíz de nuestra app (app/_layout.jsx en Expo Router), importaremos y envolveremos toda la aplicación con el CartProvider.
Código en Acción: Implementación del CartContext
A continuación, el código completo y funcional para el contexto del carrito de compras. Este es el núcleo de la lógica de negocio de nuestra aplicación.
// context/CartContext.jsx
import React, { createContext, useContext, useReducer } from 'react';
// 1. Estado Inicial
const initialState = {
items: [],
total: 0,
};
// 2. Función Reductora
const cartReducer = (state, action) => {
switch (action.type) {
case 'ADD_ITEM': {
const existingItemIndex = state.items.findIndex(
item => item.id === action.payload.id
);
let updatedItems;
if (existingItemIndex > -1) {
// El item ya existe, incrementar cantidad
updatedItems = [...state.items];
updatedItems[existingItemIndex].quantity += action.payload.quantity || 1;
} else {
// Es un item nuevo
updatedItems = [...state.items, { ...action.payload, quantity: action.payload.quantity || 1 }];
}
// Calcular nuevo total
const newTotal = updatedItems.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
return { ...state, items: updatedItems, total: parseFloat(newTotal.toFixed(2)) };
}
case 'REMOVE_ITEM': {
const updatedItems = state.items.filter(item => item.id !== action.payload);
const newTotal = updatedItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return { ...state, items: updatedItems, total: parseFloat(newTotal.toFixed(2)) };
}
case 'UPDATE_QUANTITY': {
const { id, quantity } = action.payload;
if (quantity < 1) {
// Si la cantidad es menor a 1, removemos el item
return cartReducer(state, { type: 'REMOVE_ITEM', payload: id });
}
const updatedItems = state.items.map(item =>
item.id === id ? { ...item, quantity } : item
);
const newTotal = updatedItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return { ...state, items: updatedItems, total: parseFloat(newTotal.toFixed(2)) };
}
case 'CLEAR_CART':
return initialState;
default:
return state;
}
};
// 3. Creación del Contexto
const CartContext = createContext(undefined);
// 4. Componente Proveedor
export const CartProvider = ({ children }) => {
const [state, dispatch] = useReducer(cartReducer, initialState);
// Acciones (dispatchers) para facilitar el uso
const addItemToCart = (product) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
const removeItemFromCart = (itemId) => {
dispatch({ type: 'REMOVE_ITEM', payload: itemId });
};
const updateItemQuantity = (itemId, quantity) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id: itemId, quantity } });
};
const clearCart = () => {
dispatch({ type: 'CLEAR_CART' });
};
const value = {
items: state.items,
total: state.total,
addItemToCart,
removeItemFromCart,
updateItemQuantity,
clearCart,
};
return {children} ;
};
// 5. Hook personalizado para consumir el contexto
export const useCart = () => {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart debe ser usado dentro de un CartProvider');
}
return context;
};
Este contexto ahora puede ser usado en cualquier pantalla. Por ejemplo, en la pantalla de detalle de producto, llamaríamos a useCart().addItemToCart(product). En la pantalla del carrito, usaríamos useCart().items para listar los productos y useCart().total para mostrar el monto a pagar. La lógica de negocio está completamente aislada y es fácil de testear.
Integración de Pantallas y Navegación con Expo Router
Con nuestro estado global listo, el siguiente paso es construir las pantallas principales y conectarlas. Usaremos Expo Router, que se basa en un sistema de archivos: cada archivo en el directorio app se convierte en una ruta. Crearemos las siguientes pantallas: app/index.jsx (Home), app/products/[id].jsx (Detalle de Producto), app/cart.jsx (Carrito), app/profile.jsx (Perfil). La navegación se realiza mediante el componente Link o el hook useRouter.
La pantalla de Home será la más compleja. En ella, usaremos el hook useEffect para realizar una petición a una API falsa (por ejemplo, usando json-server o datos mock) al montar el componente. Los productos se mostrarán en un FlatList para un rendimiento óptimo, ya que solo renderiza los elementos visibles en pantalla. Cada producto en la lista será un componente táctil (Pressable o TouchableOpacity) que navegará a la pantalla de detalle, pasando el ID del producto como parámetro en la ruta.
// app/index.jsx (Pantalla de Home - Ejemplo simplificado)
import { useState, useEffect } from 'react';
import { View, Text, FlatList, Pressable, Image, ActivityIndicator, StyleSheet } from 'react-native';
import { Link } from 'expo-router';
import { useCart } from '../../context/CartContext';
export default function HomeScreen() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const { addItemToCart } = useCart();
useEffect(() => {
fetch('https://fakestoreapi.com/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
})
.catch(error => {
console.error('Error fetching products:', error);
setLoading(false);
});
}, []);
const renderProductItem = ({ item }) => (
{item.title}
${item.price}
addItemToCart({ ...item, quantity: 1 })}
>
Agregar al Carrito
);
if (loading) {
return ;
}
return (
item.id.toString()}
numColumns={2}
contentContainerStyle={styles.list}
/>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
loader: { flex: 1, justifyContent: 'center' },
list: { padding: 10 },
productCard: { flex: 1, margin: 5, backgroundColor: 'white', borderRadius: 8, padding: 10, alignItems: 'center' },
productImage: { width: 120, height: 120, resizeMode: 'contain' },
productTitle: { fontSize: 14, fontWeight: 'bold', marginTop: 8, textAlign: 'center' },
productPrice: { fontSize: 16, color: '#2ecc71', marginVertical: 5 },
addButton: { backgroundColor: '#3498db', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 5, marginTop: 10 },
buttonText: { color: 'white', fontWeight: '600' },
});
Observa cómo en la pantalla de Home consumimos el contexto para tener acceso a la función addItemToCart. También utilizamos Link para la navegación hacia el detalle. La pantalla de detalle (app/products/[id].jsx) accederá al parámetro de ruta id usando useLocalSearchParams de Expo Router, y con él fetcheará la información específica de ese producto para mostrar una descripción completa, galería de imágenes y botones para agregar al carrito o comprar ahora.
Persistencia de Datos y Manejo de Sesión
Una aplicación de e-commerce real necesita recordar el carrito y la sesión del usuario incluso después de cerrar la app. Para esto, utilizaremos AsyncStorage (o su equivalente más moderno en Expo, expo-secure-store para datos sensibles). La estrategia es simple: cada vez que el estado del carrito cambie (en nuestro reducer), guardaremos automáticamente los items en el almacenamiento local. Al iniciar la aplicación, cargaremos esos datos guardados en el estado inicial del contexto.
Para la sesión de usuario, simularemos un proceso de login que devuelve un token JWT. Este token se guardará de forma segura. Luego, podemos crear un contexto de autenticación (AuthContext) similar al del carrito, que provea el estado del usuario (si está logueado, sus datos) y funciones para login y logout. Cualquier petición a un backend real incluiría este token en los headers de autorización.
// hooks/usePersistedCart.js (Ejemplo de hook personalizado para persistencia)
import { useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const CART_STORAGE_KEY = '@shoprn_cart';
export const usePersistedCart = (cartState) => {
// Efecto para guardar el carrito cada vez que cambie
useEffect(() => {
const saveCart = async () => {
try {
const jsonValue = JSON.stringify({ items: cartState.items, total: cartState.total });
await AsyncStorage.setItem(CART_STORAGE_KEY, jsonValue);
} catch (e) {
console.error('Error guardando el carrito:', e);
}
};
saveCart();
}, [cartState.items, cartState.total]); // Se ejecuta cuando items o total cambian
// Función para cargar el carrito guardado (se llamaría al iniciar la app)
const loadCart = async () => {
try {
const jsonValue = await AsyncStorage.getItem(CART_STORAGE_KEY);
return jsonValue != null ? JSON.parse(jsonValue) : null;
} catch (e) {
console.error('Error cargando el carrito:', e);
return null;
}
};
return { loadCart };
};
// Modificación en CartProvider para cargar el estado inicial desde AsyncStorage
// Dentro de CartContext.jsx, en el componente CartProvider:
// const [state, dispatch] = useReducer(cartReducer, initialState);
// Se reemplazaría por una inicialización asíncrona. Una forma común es usar un estado de "cargando".
Tip de Seguridad: Nunca almacenes información sensible como números de tarjeta de crédito o contraseñas en AsyncStorage. Para tokens de sesión, considera usar expo-secure-store que utiliza el keystore cifrado del dispositivo.
Errores Comunes y Cómo Evitarlos
Al construir una app de e-commerce, varios errores son recurrentes. Identificarlos a tiempo ahorra horas de depuración.
1. Mutación Directa del Estado: Modificar directamente un array o objeto del estado (ej: state.items.push(newItem)) causa bugs silenciosos y no activa re-renders. Solución: Siempre trabaja de forma inmutable, creando nuevas referencias con el spread operator o métodos como map y filter.
2. Efectos Infinitos (Infinite Loops) en useEffect: Pasar un array de dependencias incorrecto o modificar el estado que activa el mismo efecto sin una condición de salida. Solución: Revisa cuidadosamente las dependencias del useEffect. Si necesitas basarte en un estado anterior, usa la forma funcional de setState o un useRef.
3. Problemas de Rendimiento en Listas Grandes: Usar ScrollView para listar cientos de productos renderiza todo a la vez, congelando la UI. Solución: Usa siempre FlatList o SectionList para listas largas. Implementa windowSize y maxToRenderPerBatch para un control fino.
4. Mala Gestión de los Estados de Carga y Error: No mostrar indicadores de carga o mensajes de error deja al usuario en la incertidumbre. Solución: Implementa estados de loading, error, y success para todas las operaciones asíncronas (fetch de productos, login, checkout).
5. Navegación y Parámetros Mal Tipados: Asumir que un parámetro de ruta siempre existe o es de un tipo específico puede crashear la app. Solución: Siempre valida y haz un manejo defensivo. Usa useLocalSearchParams de forma segura y provee valores por defecto o redirige a una pantalla de error.
Checklist de Dominio
Al completar esta lección práctica, debes ser capaz de verificar los siguientes puntos en tu proyecto funcional:
- La aplicación tiene una estructura de carpetas clara (context, components, screens, services, utils).
- El estado del carrito de compras está centralizado en un Contexto con useReducer y es persistente (sobrevive al cierre de la app).
- La navegación entre pantallas (Home, Detalle, Carrito, Perfil) funciona fluidamente usando Expo Router, incluyendo el paso de parámetros.
- La pantalla de Home muestra una lista de productos desde una API (real o mockeada) usando FlatList, con imágenes, precios y botón para agregar al carrito.
- El usuario puede agregar, remover y modificar cantidades de productos en el carrito, y el total se calcula y actualiza en tiempo real.
- Se ha implementado un flujo de autenticación simulado (login/logout) que guarda un token de sesión de forma segura y condiciona la visualización de ciertas pantallas.
- La UI es responsive y se adapta correctamente a diferentes tamaños de pantalla, utilizando Flexbox y dimensiones relativas donde sea necesario.
- La aplicación maneja adecuadamente los estados de carga y error en todas sus operaciones asíncronas, mostrando feedback visual al usuario.