Thursday, June 12, 2025
Building a Responsive E-commerce App: React Native with Live API Data, React Query, Zustand
Posted by
Building a Responsive E-commerce App: React Native (Expo) with Live API Data, React Query, Zustand & Nativewind
Introduction
In modern mobile app development, managing data, global state, and styling efficiently is crucial. This guide will walk you through implementing a robust architecture:
- TanStack React Query: Simplifies data fetching, caching, and synchronization with your server, providing intelligent features like background refetching and stale-while-revalidate. This means your app will feel faster and more responsive.
- Zustand: A small, fast, and scalable state management solution that offers a hook-based API, making global state management straightforward and performant. We'll leverage its persistence middleware to save your cart data locally across app sessions.
- Nativewind: Brings the power of Tailwind CSS directly to React Native. It allows you to style your components using utility classes, leading to faster development, consistent design, and easier maintenance compared to traditional StyleSheet objects.
- FlashList: A high-performance list component from Shopify, replacing React Native's built-in FlatList for smoother scrolling and better memory efficiency, especially with large datasets.
- We will fetch product data from the provided API endpoint: https://inventory-app-ten-gilt.vercel.app/api/v1/products.
Step 1: Project Setup and Dependencies
This documentation assumes you already have a React Native Expo project set up and Nativewind configured. If not, please refer to the Nativewind documentation for initial setup.
- Now, let's install the necessary additional dependencies for data fetching, state management, and the high-performance list component.
pnpm install @tanstack/react-query zustand @react-native-async-storage/async-storage @shopify/flash-list
Step 2: Implement React Query for Data Fetching
We'll define the Product interface based on a typical e-commerce product structure, and then create an asynchronous function to fetch data from the provided API.
// --- Product Data Interface ---
interface Product {
_id: string; // Assuming API uses _id
name: string;
description: string;
price: number;
image: string; // Assuming 'image' field for imageUrl
category: string;
stock: number;
// You might add more fields based on actual API response like rating, reviews, etc.
// For demonstration, we'll map existing fields to the UI needs.
}
// --- API Fetch Function ---
const API_URL = "https://inventory-app-ten-gilt.vercel.app/api/v1/products";
const fetchProducts = async (): Promise<Product[]> => {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Assuming the API returns an array of products directly
return data;
} catch (error) {
console.error("Failed to fetch products:", error);
// Fallback to dummy data in case of API error for better UX during development/offline
return [
{
_id: "1",
name: "Xbox Series X",
description:
"The Microsoft Xbox Series X gaming console is capable of impressing with minimal boot times and mesmerizing visual effects when playing games at up to 120 frames per second",
price: 570.0,
image: "https://placehold.co/300x300/1A202C/FFFFFF?text=Xbox+Series+X",
category: "Gaming",
stock: 10,
},
{
_id: "2",
name: "MacBook Air",
description: "Lightweight and powerful laptop for everyday tasks.",
price: 1100.0,
image: "https://placehold.co/300x300/4A5568/FFFFFF?text=MacBook+Air",
category: "Laptop",
stock: 15,
},
{
_id: "3",
name: "AirPods Pro",
description: "Active Noise Cancellation for immersive sound.",
price: 132.0,
image: "https://placehold.co/300x300/A0AEC0/FFFFFF?text=AirPods+Pro",
category: "Headphones",
stock: 20,
},
{
_id: "4",
name: "Wireless Controller",
description: "Next-gen gaming controller with haptic feedback.",
price: 77.0,
image: "https://placehold.co/300x300/2D3748/FFFFFF?text=Controller",
category: "Gaming",
stock: 25,
},
{
_id: "5",
name: "Razer Kaira Pro",
description: "Gaming headset with clear audio and comfortable fit.",
price: 153.0,
image: "https://placehold.co/300x300/718096/FFFFFF?text=Razer+Kaira",
category: "Headphones",
stock: 12,
},
];
}
};
// --- React Query Client Setup ---
const queryClient = new QueryClient();
Step 3: Implement Add to Cart Functionality using Zustand
The Zustand store will manage the shopping cart state, including adding, removing, and updating item quantities. Crucially, we will integrate persist to save the cart state to AsyncStorage, so it remains even if the user closes the app.
// --- Zustand Store for Cart ---
interface CartItem {
id: string; // Maps to product._id
name: string;
price: number;
quantity: number;
imageUrl: string; // For cart display
}
interface CartState {
items: CartItem[];
addToCart: (product: {
id: string;
name: string;
price: number;
imageUrl: string;
}) => void;
incrementQuantity: (productId: string) => void;
decrementQuantity: (productId: string) => void;
removeFromCart: (productId: string) => void;
getCartTotalItems: () => number;
getCartTotalPrice: () => number;
clearCart: () => void;
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addToCart: (product) => {
set((state) => {
const existingItem = state.items.find(
(item) => item.id === product.id
);
if (existingItem) {
return {
items: state.items.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
} else {
return {
items: [...state.items, { ...product, quantity: 1 }],
};
}
});
},
incrementQuantity: (productId) => {
set((state) => ({
items: state.items.map((item) =>
item.id === productId
? { ...item, quantity: item.quantity + 1 }
: item
),
}));
},
decrementQuantity: (productId) => {
set((state) => ({
items: state.items
.map(
(item) =>
item.id === productId
? { ...item, quantity: Math.max(0, item.quantity - 1) }
: item // Ensure quantity doesn't go below 0
)
.filter((item) => item.quantity > 0), // Remove if quantity becomes 0
}));
},
removeFromCart: (productId) => {
set((state) => ({
items: state.items.filter((item) => item.id !== productId),
}));
},
getCartTotalItems: () => {
return get().items.reduce((total, item) => total + item.quantity, 0);
},
getCartTotalPrice: () => {
return get().items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
clearCart: () => {
set({ items: [] });
},
}),
{
name: "ecommerce-cart-storage", // Unique name for your storage
storage: createJSONStorage(() => AsyncStorage), // Use AsyncStorage for React Native
}
)
);
Step 4: UI Implementation with Nativewind and Navigation
This section contains the full App.js code. It incorporates:
Global state for navigation. The DiscoverScreen (product listing), ProductDetailScreen, and CartScreen. Nativewind styling throughout the components. FlashList for efficient product display. Integration of React Query for data fetching and Zustand for cart management. Heroicons for a clean UI.
import React, { useState } from "react";
import {
View,
Text,
Pressable,
Image,
ActivityIndicator,
Dimensions,
} from "react-native";
import { styled } from "nativewind";
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { FlashList } from "@shopify/flash-list"; // Import FlashList
import {
ChevronLeftIcon,
ShoppingCartIcon,
MagnifyingGlassIcon,
Bars3Icon,
HeartIcon,
UserIcon,
} from "react-native-heroicons/outline"; // For icons
import { StarIcon } from "react-native-heroicons/solid"; // For solid star icon
// --- Styled Components for Nativewind ---
// Using `styled` from Nativewind allows for cleaner code by creating reusable styled components.
const StyledView = styled(View);
const StyledText = styled(Text);
const StyledPressable = styled(Pressable);
const StyledImage = styled(Image);
const StyledFlashList = styled(FlashList); // Use FlashList styled component
const StyledScrollView = styled(View); // For ProductDetail and Cart screens, we'll use a regular View with dynamic height if needed or a dedicated ScrollView, but FlashList is only for lists.
// --- Product Data Interface (Matches API and UI needs) ---
interface Product {
_id: string;
name: string;
description: string;
price: number;
image: string; // 'image' field from API
category: string;
stock: number;
// Adding UI-specific mock data fields for consistency with the design
rating?: number; // Optional, as it might not come from the API
reviews?: number; // Optional
onSale?: boolean; // Optional
}
// --- API Fetch Function ---
const API_URL = "https://inventory-app-ten-gilt.vercel.app/api/v1/products";
const fetchProducts = async (): Promise<Product[]> => {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Map API data to our Product interface and add mock UI fields
return data.map((item: any) => ({
_id: item._id,
name: item.name,
description: item.description,
price: item.price,
image: item.image,
category: item.category,
stock: item.stock,
// Add mock data for UI consistency as per the image
rating: Math.floor(Math.random() * (50 - 35) + 35) / 10, // 3.5 to 5.0
reviews: Math.floor(Math.random() * (300 - 50) + 50),
onSale: Math.random() > 0.7, // 30% chance of being on sale
}));
} catch (error) {
console.error("Failed to fetch products from API:", error);
// Fallback to dummy data in case of API error for better UX during development/offline
return [
{
_id: "65f6c8d7b3e1c6f9d0a1b2c3",
name: "Xbox Series X",
description:
"The Microsoft Xbox Series X gaming console is capable of impressing with minimal boot times and mesmerizing visual effects when playing games at up to 120 frames per second",
price: 570.0,
image: "https://placehold.co/300x300/1A202C/FFFFFF?text=Xbox+Series+X",
category: "Gaming",
stock: 10,
rating: 4.8,
reviews: 117,
onSale: true,
},
{
_id: "65f6c8d7b3e1c6f9d0a1b2c4",
name: "MacBook Air",
description: "Lightweight and powerful laptop for everyday tasks.",
price: 1100.0,
image: "https://placehold.co/300x300/4A5568/FFFFFF?text=MacBook+Air",
category: "Laptop",
stock: 15,
rating: 4.9,
reviews: 135,
onSale: false,
},
{
_id: "65f6c8d7b3e1c6f9d0a1b2c5",
name: "AirPods Pro",
description: "Active Noise Cancellation for immersive sound.",
price: 132.0,
image: "https://placehold.co/300x300/A0AEC0/FFFFFF?text=AirPods+Pro",
category: "Headphones",
stock: 20,
rating: 4.7,
reviews: 200,
onSale: false,
},
{
_id: "65f6c8d7b3e1c6f9d0a1b2c6",
name: "Wireless Controller",
description: "Next-gen gaming controller with haptic feedback.",
price: 77.0,
image: "https://placehold.co/300x300/2D3748/FFFFFF?text=Controller",
category: "Gaming",
stock: 25,
rating: 4.6,
reviews: 88,
onSale: false,
},
{
_id: "65f6c8d7b3e1c6f9d0a1b2c7",
name: "Razer Kaira Pro",
description: "Gaming headset with clear audio and comfortable fit.",
price: 153.0,
image: "https://placehold.co/300x300/718096/FFFFFF?text=Razer+Kaira",
category: "Headphones",
stock: 12,
rating: 4.5,
reviews: 65,
onSale: false,
},
{
_id: "65f6c8d7b3e1c6f9d0a1b2c8",
name: "Samsung Galaxy S24",
description: "Latest flagship smartphone with advanced camera.",
price: 999.0,
image: "https://placehold.co/300x300/EDF2F7/000000?text=Galaxy+S24",
category: "Smartphones",
stock: 8,
rating: 4.8,
reviews: 180,
onSale: true,
},
{
_id: "65f6c8d7b3e1c6f9d0a1b2c9",
name: "Sony WH-1000XM5",
description: "Industry-leading noise canceling headphones.",
price: 349.0,
image: "https://placehold.co/300x300/CBD5E0/000000?text=Sony+XM5",
category: "Headphones",
stock: 7,
rating: 4.9,
reviews: 300,
onSale: false,
},
];
}
};
// --- React Query Client Setup ---
const queryClient = new QueryClient();
// --- Zustand Store for Cart (from Step 3) ---
interface CartItem {
id: string; // Maps to product._id
name: string;
price: number;
quantity: number;
imageUrl: string; // For cart display
}
interface CartState {
items: CartItem[];
addToCart: (product: {
id: string;
name: string;
price: number;
imageUrl: string;
}) => void;
incrementQuantity: (productId: string) => void;
decrementQuantity: (productId: string) => void;
removeFromCart: (productId: string) => void;
getCartTotalItems: () => number;
getCartTotalPrice: () => number;
clearCart: () => void;
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addToCart: (product) => {
set((state) => {
const existingItem = state.items.find(
(item) => item.id === product.id
);
if (existingItem) {
return {
items: state.items.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
} else {
return {
items: [...state.items, { ...product, quantity: 1 }],
};
}
});
},
incrementQuantity: (productId) => {
set((state) => ({
items: state.items.map((item) =>
item.id === productId
? { ...item, quantity: item.quantity + 1 }
: item
),
}));
},
decrementQuantity: (productId) => {
set((state) => ({
items: state.items
.map(
(item) =>
item.id === productId
? { ...item, quantity: Math.max(0, item.quantity - 1) }
: item // Ensure quantity doesn't go below 0
)
.filter((item) => item.quantity > 0), // Remove if quantity becomes 0
}));
},
removeFromCart: (productId) => {
set((state) => ({
items: state.items.filter((item) => item.id !== productId),
}));
},
getCartTotalItems: () => {
return get().items.reduce((total, item) => total + item.quantity, 0);
},
getCartTotalPrice: () => {
return get().items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
clearCart: () => {
set({ items: [] });
},
}),
{
name: "ecommerce-cart-storage",
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// --- UI Categories ---
const categories = ["All", "Smartphones", "Headphones", "Laptop", "Gaming"]; // Example categories matching the image
// --- Discover Screen Component ---
const DiscoverScreen = ({ navigateToProductDetail, navigateToCart }) => {
const {
data: products,
isLoading,
isError,
error,
} = useQuery<Product[], Error>({
queryKey: ["products"],
queryFn: fetchProducts,
});
const cartTotalItems = useCartStore((state) => state.getCartTotalItems());
if (isLoading) {
return (
<StyledView className="flex-1 justify-center items-center bg-gray-50">
<ActivityIndicator size="large" color="#4A5568" />
<StyledText className="mt-4 text-lg text-gray-700">
Loading products...
</StyledText>
</StyledView>
);
}
if (isError) {
return (
<StyledView className="flex-1 justify-center items-center bg-gray-50 p-4">
<StyledText className="text-red-600 text-lg text-center">
Error fetching products: {error?.message}
</StyledText>
<StyledText className="text-blue-500 text-sm mt-2">
Displaying fallback data.
</StyledText>
</StyledView>
);
}
const renderProductCard = ({ item }: { item: Product }) => (
<StyledPressable
className="bg-white rounded-xl shadow-md p-3 m-2 flex-1 items-center"
onPress={() => navigateToProductDetail(item._id)} // Use _id from API
style={{ width: Dimensions.get("window").width / 2 - 20 }} // Two columns, some margin
>
<StyledImage
source={{ uri: item.image }}
className="w-24 h-24 rounded-lg mb-2 object-contain"
/>
<StyledText className="text-gray-800 font-semibold text-base text-center">
{item.name}
</StyledText>
<StyledView className="flex-row items-center mt-1">
<StarIcon size={14} color="#FBBF24" /> {/* Solid star icon */}
<StyledText className="text-yellow-600 text-sm ml-1 mr-1">
{item.rating?.toFixed(1)}
</StyledText>
<StyledText className="text-gray-500 text-xs">
({item.reviews} reviews)
</StyledText>
</StyledView>
<StyledText className="text-green-600 font-bold text-lg mt-2">
${item.price.toFixed(2)}
</StyledText>
</StyledPressable>
);
return (
<StyledView className="flex-1 bg-gray-50 pt-10">
{/* Header */}
<StyledView className="flex-row items-center justify-between px-4 pb-4">
<StyledText className="text-3xl font-bold text-gray-900">
Discover
</StyledText>
<StyledPressable
onPress={navigateToCart}
className="relative p-2 rounded-full bg-white shadow-sm"
>
<ShoppingCartIcon size={24} color="#4A5568" />
{cartTotalItems > 0 && (
<StyledView className="absolute -top-1 -right-1 bg-red-500 rounded-full w-5 h-5 flex justify-center items-center">
<StyledText className="text-white text-xs font-bold">
{cartTotalItems}
</StyledText>
</StyledView>
)}
</StyledPressable>
</StyledView>
{/* Search Bar */}
<StyledView className="flex-row items-center mx-4 p-3 bg-white rounded-xl shadow-sm mb-4">
<MagnifyingGlassIcon size={20} color="#718096" className="mr-2" />
<StyledText className="flex-1 text-gray-500">Search</StyledText>
</StyledView>
{/* Clearance Sales Banner */}
<StyledView className="mx-4 bg-purple-600 rounded-xl shadow-lg p-4 flex-row items-center justify-between mb-4">
<StyledView>
<StyledText className="text-white text-xl font-bold">
Clearance Sales
</StyledText>
<StyledText className="text-white text-sm mt-1">
Up to 50% Off
</StyledText>
</StyledView>
<StyledImage
source={{
uri: "https://placehold.co/100x100/A0AEC0/FFFFFF?text=Sales",
}}
className="w-20 h-20 rounded-lg"
/>
</StyledView>
{/* Categories */}
<StyledView className="px-4 mb-4">
<StyledView className="flex-row justify-between items-center mb-2">
<StyledText className="text-lg font-semibold text-gray-800">
Categories
</StyledText>
<StyledPressable>
<StyledText className="text-blue-600 font-medium">
See all
</StyledText>
</StyledPressable>
</StyledView>
<StyledScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="py-2"
>
{categories.map((category, index) => (
<StyledPressable
key={index}
className={`px-4 py-2 rounded-full mr-3 ${
category === "All" ? "bg-blue-600" : "bg-gray-200"
}`}
>
<StyledText
className={`${
category === "All" ? "text-white" : "text-gray-700"
} font-medium`}
>
{category}
</StyledText>
</StyledPressable>
))}
</StyledScrollView>
</StyledView>
{/* Product Grid using FlashList */}
<StyledFlashList
data={products}
numColumns={2}
renderItem={renderProductCard}
keyExtractor={(item) => item._id}
contentContainerClassName="px-2 pb-4"
estimatedItemSize={200} // Essential for FlashList performance
/>
{/* Bottom Navigation */}
<StyledView className="absolute bottom-0 left-0 right-0 bg-white shadow-lg p-4 flex-row justify-around items-center rounded-t-xl border-t border-gray-200">
<StyledPressable className="items-center">
<Bars3Icon size={24} color="#2D3748" />
<StyledText className="text-xs text-gray-700 mt-1">Home</StyledText>
</StyledPressable>
<StyledPressable className="items-center">
<MagnifyingGlassIcon size={24} color="#718096" />
<StyledText className="text-xs text-gray-500 mt-1">Search</StyledText>
</StyledPressable>
<StyledPressable className="items-center">
<HeartIcon size={24} color="#718096" />
<StyledText className="text-xs text-gray-500 mt-1">
Favorites
</StyledText>
</StyledPressable>
<StyledPressable className="items-center">
<UserIcon size={24} color="#718096" />
<StyledText className="text-xs text-gray-500 mt-1">
Profile
</StyledText>
</StyledPressable>
</StyledView>
</StyledView>
);
};
// --- Product Detail Screen Component ---
const ProductDetailScreen = ({ productId, navigateBack, addToCart }) => {
const { data: products } = useQuery<Product[], Error>({
queryKey: ["products"],
queryFn: fetchProducts,
});
const product = products?.find((p) => p._id === productId); // Find by _id
const cartTotalItems = useCartStore((state) => state.getCartTotalItems());
if (!product) {
return (
<StyledView className="flex-1 justify-center items-center bg-gray-50">
<StyledText className="text-red-600 text-lg">
Product not found.
</StyledText>
<StyledPressable
onPress={navigateBack}
className="mt-4 px-6 py-3 bg-blue-600 rounded-full"
>
<StyledText className="text-white font-bold">Go Back</StyledText>
</StyledPressable>
</StyledView>
);
}
return (
<StyledView className="flex-1 bg-gray-50">
{/* Header */}
<StyledView className="flex-row items-center justify-between px-4 py-4 pt-12 bg-white shadow-sm border-b border-gray-200">
<StyledPressable
onPress={navigateBack}
className="p-2 rounded-full bg-gray-100"
>
<ChevronLeftIcon size={24} color="#2D3748" />
</StyledPressable>
<StyledText className="text-xl font-semibold text-gray-900">
Product Details
</StyledText>
<StyledPressable
onPress={() => {
/* Navigate to cart if needed */
}}
className="relative p-2 rounded-full bg-gray-100"
>
<ShoppingCartIcon size={24} color="#4A5568" />
{cartTotalItems > 0 && (
<StyledView className="absolute -top-1 -right-1 bg-red-500 rounded-full w-5 h-5 flex justify-center items-center">
<StyledText className="text-white text-xs font-bold">
{cartTotalItems}
</StyledText>
</StyledView>
)}
</StyledPressable>
</StyledView>
<StyledScrollView className="flex-1">
{" "}
{/* Use ScrollView here for content */}
<StyledView className="p-4">
<StyledImage
source={{ uri: product.image }}
className="w-full h-80 rounded-xl mb-6 object-contain bg-white"
/>
<StyledView className="bg-white rounded-xl shadow-md p-4">
<StyledText className="text-3xl font-bold text-gray-900 mb-2">
{product.name}
</StyledText>
<StyledView className="flex-row items-center mb-4">
<StarIcon size={18} color="#FBBF24" />
<StyledText className="text-yellow-600 text-lg ml-1 mr-2">
{product.rating?.toFixed(1)}
</StyledText>
<StyledText className="text-gray-500 text-sm">
({product.reviews} reviews)
</StyledText>
{product.onSale && (
<StyledText className="ml-auto bg-red-500 text-white text-xs px-2 py-1 rounded-full font-bold">
On sale
</StyledText>
)}
</StyledView>
<StyledText className="text-gray-700 text-base leading-relaxed mb-6">
{product.description}
</StyledText>
{/* Storage Options (Mock from UI image, not from API) */}
<StyledView className="mb-6">
<StyledText className="text-lg font-semibold text-gray-800 mb-3">
Storage
</StyledText>
<StyledView className="flex-row flex-wrap">
{["1 TB", "825 GB", "512 GB"].map((storage, index) => (
<StyledPressable
key={index}
className={`px-5 py-2 mr-3 mb-3 rounded-full border ${
storage === "1 TB"
? "border-blue-600 bg-blue-50"
: "border-gray-300 bg-gray-100"
}`}
>
<StyledText
className={`${
storage === "1 TB"
? "text-blue-700 font-bold"
: "text-gray-700"
}`}
>
{storage}
</StyledText>
</StyledPressable>
))}
</StyledView>
</StyledView>
<StyledView className="flex-row items-center justify-between mt-6">
<StyledView>
<StyledText className="text-xl text-gray-500">Price</StyledText>
<StyledText className="text-4xl font-bold text-green-600">
${product.price.toFixed(2)}
</StyledText>
</StyledView>
<StyledPressable
onPress={() =>
addToCart({
id: product._id,
name: product.name,
price: product.price,
imageUrl: product.image,
})
}
className="bg-green-600 px-8 py-4 rounded-full shadow-lg"
>
<StyledText className="text-white font-bold text-lg">
Add to Cart
</StyledText>
</StyledPressable>
</StyledView>
</StyledView>
</StyledView>
</StyledScrollView>
</StyledView>
);
};
// --- My Cart Screen Component ---
const CartScreen = ({ navigateBack }) => {
const cartItems = useCartStore((state) => state.items);
const totalItems = useCartStore((state) => state.getCartTotalItems());
const subtotal = useCartStore((state) => state.getCartTotalPrice());
const incrementQuantity = useCartStore((state) => state.incrementQuantity);
const decrementQuantity = useCartStore((state) => state.decrementQuantity);
// removeFromCart not directly used in this UI, but kept in store
const deliveryFee = 5.0; // Mock value
const discountPercentage = 0.4; // 40% discount as per image
const discountAmount = subtotal * discountPercentage;
const finalTotal = subtotal + deliveryFee - discountAmount;
const renderCartItem = ({ item }: { item: CartItem }) => (
<StyledView className="flex-row items-center bg-white rounded-xl shadow-sm p-3 mb-4">
<StyledImage
source={{ uri: item.imageUrl }}
className="w-20 h-20 rounded-lg mr-4 object-contain"
/>
<StyledView className="flex-1">
<StyledText className="text-gray-800 font-semibold text-lg">
{item.name}
</StyledText>
<StyledText className="text-gray-500 text-sm mt-1">1 TB</StyledText> {/* Mock variant as per image */}
<StyledText className="text-green-600 font-bold text-lg mt-2">
${item.price.toFixed(2)}
</StyledText>
</StyledView>
<StyledView className="flex-row items-center">
<StyledPressable
onPress={() => decrementQuantity(item.id)}
className="p-2"
>
<StyledText className="text-gray-700 text-xl font-bold">-</StyledText>
</StyledPressable>
<StyledText className="text-gray-800 text-lg font-bold mx-2">
{item.quantity}
</StyledText>
<StyledPressable
onPress={() => incrementQuantity(item.id)}
className="p-2"
>
<StyledText className="text-gray-700 text-xl font-bold">+</StyledText>
</StyledPressable>
</StyledView>
</StyledView>
);
return (
<StyledView className="flex-1 bg-gray-50 pt-10">
{/* Header */}
<StyledView className="flex-row items-center px-4 pb-4 border-b border-gray-200 bg-white">
<StyledPressable
onPress={navigateBack}
className="p-2 rounded-full bg-gray-100 mr-4"
>
<ChevronLeftIcon size={24} color="#2D3748" />
</StyledPressable>
<StyledText className="text-2xl font-bold text-gray-900">
My cart
</StyledText>
</StyledView>
{/* Cart Items List */}
{cartItems.length === 0 ? (
<StyledView className="flex-1 justify-center items-center">
<ShoppingCartIcon size={80} color="#CBD5E0" />
<StyledText className="text-lg text-gray-600 mt-4">
Your cart is empty.
</StyledText>
</StyledView>
) : (
<StyledFlashList
data={cartItems}
renderItem={renderCartItem}
keyExtractor={(item) => item.id}
contentContainerClassName="px-4 pb-4"
estimatedItemSize={120} // Essential for FlashList performance
/>
)}
{/* Cart Summary */}
<StyledView className="bg-white rounded-t-3xl shadow-lg p-6 mt-auto border-t border-gray-200">
<StyledView className="mb-4">
<StyledView className="flex-row justify-between items-center mb-2">
<StyledText className="text-lg font-semibold text-gray-800">
Promocode applied
</StyledText>
<StyledText className="text-green-600 font-bold text-lg">
ADJ3AK
</StyledText>
</StyledView>
</StyledView>
<StyledView className="flex-row justify-between items-center mb-3">
<StyledText className="text-gray-700 text-base">
Subtotal :
</StyledText>
<StyledText className="text-gray-900 text-lg font-semibold">
${subtotal.toFixed(2)}
</StyledText>
</StyledView>
<StyledView className="flex-row justify-between items-center mb-3">
<StyledText className="text-gray-700 text-base">
Delivery Fee :
</StyledText>
<StyledText className="text-gray-900 text-lg font-semibold">
${deliveryFee.toFixed(2)}
</StyledText>
</StyledView>
<StyledView className="flex-row justify-between items-center mb-4">
<StyledText className="text-gray-700 text-base">
Discount :
</StyledText>
<StyledText className="text-red-500 text-lg font-semibold">
- {Math.round(discountPercentage * 100)}%
</StyledText>
</StyledView>
<StyledPressable className="bg-green-600 py-4 rounded-full flex-row justify-center items-center shadow-lg">
<StyledText className="text-white font-bold text-xl">
Checkout for ${finalTotal.toFixed(2)}
</StyledText>
</StyledPressable>
</StyledView>
</StyledView>
);
};
// --- Main App Component with Navigation ---
const App = () => {
const [currentScreen, setCurrentScreen] = useState("Discover"); // 'Discover', 'ProductDetail', 'Cart'
const [selectedProductId, setSelectedProductId] = useState<string | null>(
null
);
const navigateToProductDetail = (productId: string) => {
setSelectedProductId(productId);
setCurrentScreen("ProductDetail");
};
const navigateToCart = () => {
setCurrentScreen("Cart");
};
const navigateBack = () => {
if (currentScreen === "ProductDetail") {
setCurrentScreen("Discover");
setSelectedProductId(null);
} else if (currentScreen === "Cart") {
setCurrentScreen("Discover"); // Or to the previous screen if tracking history
}
};
const addToCartAction = useCartStore((state) => state.addToCart); // Pass directly to ProductDetail
return (
<QueryClientProvider client={queryClient}>
{currentScreen === "Discover" && (
<DiscoverScreen
navigateToProductDetail={navigateToProductDetail}
navigateToCart={navigateToCart}
/>
)}
{currentScreen === "ProductDetail" && selectedProductId && (
<ProductDetailScreen
productId={selectedProductId}
navigateBack={navigateBack}
addToCart={addToCartAction}
/>
)}
{currentScreen === "Cart" && <CartScreen navigateBack={navigateBack} />}
</QueryClientProvider>
);
};
export default App;
FLASHLIST USAGE
<View className="w-full h-full ">
<FlashList
numColumns={2}
data={products}
estimatedItemSize={187}
renderItem={({ item }) => <Product item={item} />}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
/>
</View>
The Product Component
import { Link } from "expo-router";
import React from "react";
import { Image, Text, TouchableOpacity, View } from "react-native";
export interface ItemProps {
id: string;
name: string;
price: number;
color: string;
image: string;
}
export default function Product({ item }: { item: ItemProps }) {
function handleAddToCart() {
console.log("Button clicked");
}
return (
<View className="w-[95%] overflow-hidden rounded-2xl border border-gray-300 pb-2 mr-2">
<Link
href={{
pathname: "/(tabs)/products/[id]",
params: {
id: item.id,
color: item.color,
},
}}
className="overflow-hidden rounded-2xl "
>
<Image
className=" w-full h-32 object-cover"
source={{ uri: item.image }}
/>
</Link>
<View className="px-3 pb-2">
<View>
<Text className="py-2">{item.name}</Text>
</View>
<View className="flex flex-row items-center justify-between">
<Text className="font-bold">${item.price}</Text>
<TouchableOpacity onPress={handleAddToCart} className="">
<Text className="font-bold">Add</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
CART PAGE
import { CartScreen } from "@/premium-apps/ecommerce-app/screens/CartScreen";
import { useRouter } from "expo-router";
import React from "react";
export default function Cart() {
const router = useRouter();
return (
<CartScreen
onBackPress={() => {
router.back();
}}
onCheckout={() => {
console.log("Proceed to checkout");
router.push("/checkout");
}}
/>
);
}
CART SCREEEN COMPONENT
// premium-apps/ecommerce-app/screens/CartScreen.tsx
import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react";
import {
SafeAreaView,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { CartItemCard } from "../components/ui/CartItemCard";
import { CartItem, PromoCode } from "../types";
// Mock cart data
const mockCartItems: CartItem[] = [
{
id: "1",
product: {
id: "1",
name: "Xbox series X",
brand: "Microsoft",
price: 570.0,
rating: 4.8,
reviewCount: 117,
category: "Gaming",
images: [
"https://images.unsplash.com/photo-1606144042614-b2417e99c4e3?w=300&h=300&fit=crop",
],
description: "Next-gen gaming console",
specifications: [],
variants: [{ id: "1", name: "Storage", value: "1 TB", inStock: true }],
inStock: true,
},
variant: { id: "1", name: "Storage", value: "1 TB", inStock: true },
quantity: 1,
addedAt: new Date(),
},
{
id: "2",
product: {
id: "2",
name: "Wireless Controller",
brand: "Microsoft",
price: 77.0,
rating: 4.6,
reviewCount: 89,
category: "Gaming",
images: [
"https://img.freepik.com/free-photo/view-3d-video-game-controller_23-2151005826.jpg?ga=GA1.1.852679718.1745466476&semt=ais_hybrid&w=740",
],
description: "Xbox wireless controller",
specifications: [],
variants: [{ id: "1", name: "Color", value: "Blue", inStock: true }],
inStock: true,
},
variant: { id: "1", name: "Color", value: "Blue", inStock: true },
quantity: 1,
addedAt: new Date(),
},
{
id: "3",
product: {
id: "3",
name: "Razer Kaira Pro",
brand: "Razer",
price: 153.0,
rating: 4.7,
reviewCount: 156,
category: "Gaming",
images: [
"https://images.unsplash.com/photo-1599669454699-248893623440?w=300&h=300&fit=crop",
],
description: "Gaming headset with surround sound",
specifications: [],
variants: [{ id: "1", name: "Color", value: "Green", inStock: true }],
inStock: true,
},
variant: { id: "1", name: "Color", value: "Green", inStock: true },
quantity: 1,
addedAt: new Date(),
},
];
interface CartScreenProps {
onBackPress: () => void;
onCheckout: () => void;
}
export const CartScreen: React.FC<CartScreenProps> = ({
onBackPress,
onCheckout,
}) => {
const [cartItems, setCartItems] = useState(mockCartItems);
const [promoCode, setPromoCode] = useState("");
const [appliedPromo, setAppliedPromo] = useState<PromoCode | null>({
code: "ADJ3AK",
discount: 40,
type: "percentage",
isApplied: true,
});
const updateQuantity = (id: string, quantity: number) => {
setCartItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, quantity } : item))
);
};
const removeItem = (id: string) => {
setCartItems((prev) => prev.filter((item) => item.id !== id));
};
const applyPromoCode = () => {
if (promoCode.trim()) {
// Mock promo code validation
const newPromo: PromoCode = {
code: promoCode,
discount: 20,
type: "percentage",
isApplied: true,
};
setAppliedPromo(newPromo);
setPromoCode("");
}
};
const subtotal = cartItems.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);
const deliveryFee = 5.0;
const discount = appliedPromo ? (subtotal * appliedPromo.discount) / 100 : 0;
const total = subtotal + deliveryFee - discount;
if (cartItems.length === 0) {
return (
<View className="flex-1 bg-gray-50">
<SafeAreaView className="flex-1">
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={onBackPress}>
<Ionicons name="arrow-back" size={24} color="#000000" />
</TouchableOpacity>
<Text className="text-gray-900 text-xl font-bold ml-4">
My cart
</Text>
</View>
<View className="flex-1 items-center justify-center px-6">
<View className="w-24 h-24 bg-gray-200 rounded-full items-center justify-center mb-6">
<Ionicons name="bag-outline" size={40} color="#9ca3af" />
</View>
<Text className="text-gray-900 text-xl font-bold mb-2">
Your cart is empty
</Text>
<Text className="text-gray-500 text-center mb-8">
Add some products to get started with your order.
</Text>
<TouchableOpacity
onPress={onBackPress}
className="bg-green-500 px-8 py-4 rounded-2xl"
>
<Text className="text-white font-semibold text-lg">
Continue Shopping
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
return (
<View className="flex-1 bg-gray-50">
<SafeAreaView className="flex-1">
{/* Header */}
<View className="flex-row items-center justify-between px-4 py-3 bg-white border-b border-gray-100 mt-10">
<View className="flex-row items-center">
<TouchableOpacity onPress={onBackPress}>
<Ionicons name="arrow-back" size={24} color="#000000" />
</TouchableOpacity>
<Text className="text-gray-900 text-xl font-bold ml-4">
My cart
</Text>
</View>
<TouchableOpacity>
<Ionicons name="ellipsis-vertical" size={24} color="#000000" />
</TouchableOpacity>
</View>
<ScrollView
className="flex-1 px-4 py-4"
showsVerticalScrollIndicator={false}
>
{/* Cart Items */}
{cartItems.map((item) => (
<CartItemCard
key={item.id}
item={item}
onUpdateQuantity={updateQuantity}
onRemove={removeItem}
/>
))}
{/* Promo Code */}
<View className="bg-white rounded-2xl p-4 mb-4">
{appliedPromo ? (
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text className="text-gray-900 font-semibold text-base mb-1">
{appliedPromo.code}
</Text>
<View className="flex-row items-center">
<Text className="text-green-500 font-medium mr-2">
Promocode applied
</Text>
<Ionicons
name="checkmark-circle"
size={16}
color="#10b981"
/>
</View>
</View>
<TouchableOpacity
onPress={() => setAppliedPromo(null)}
className="p-2"
>
<Ionicons name="close" size={20} color="#ef4444" />
</TouchableOpacity>
</View>
) : (
<View>
<Text className="text-gray-900 font-semibold text-base mb-3">
Promo Code
</Text>
<View className="flex-row">
<TextInput
value={promoCode}
onChangeText={setPromoCode}
placeholder="Enter promo code"
className="flex-1 bg-gray-100 rounded-l-xl px-4 py-3 text-gray-900"
placeholderTextColor="#9ca3af"
/>
<TouchableOpacity
onPress={applyPromoCode}
className="bg-green-500 px-6 rounded-r-xl items-center justify-center"
>
<Text className="text-white font-semibold">Apply</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
{/* Order Summary */}
<View className="bg-white rounded-2xl p-4 mb-4">
<Text className="text-gray-900 font-semibold text-lg mb-4">
Order Summary
</Text>
<View className="space-y-3">
<View className="flex-row justify-between">
<Text className="text-gray-600">Subtotal:</Text>
<Text className="text-gray-900 font-semibold">
${subtotal.toFixed(2)}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-gray-600">Delivery Fee:</Text>
<Text className="text-gray-900 font-semibold">
${deliveryFee.toFixed(2)}
</Text>
</View>
{appliedPromo && (
<View className="flex-row justify-between">
<Text className="text-gray-600">Discount:</Text>
<Text className="text-green-500 font-semibold">
-{appliedPromo.discount}%
</Text>
</View>
)}
<View className="h-px bg-gray-200" />
<View className="flex-row justify-between">
<Text className="text-gray-900 font-bold text-lg">Total:</Text>
<Text className="text-gray-900 font-bold text-lg">
${total.toFixed(2)}
</Text>
</View>
</View>
</View>
</ScrollView>
{/* Checkout Button */}
<View className="px-4 pb-6 bg-white border-t border-gray-100">
<TouchableOpacity
onPress={onCheckout}
className="bg-green-500 rounded-2xl py-4 items-center"
>
<Text className="text-white text-lg font-semibold">
Checkout for ${total.toFixed(2)}
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
};
CART CARD COMPONENT
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Image, Text, TouchableOpacity, View } from "react-native";
import { CartItem } from "../../types";
interface CartItemCardProps {
item: CartItem;
onUpdateQuantity: (id: string, quantity: number) => void;
onRemove: (id: string) => void;
}
export const CartItemCard: React.FC<CartItemCardProps> = ({
item,
onUpdateQuantity,
onRemove,
}) => {
const increaseQuantity = () => {
onUpdateQuantity(item.id, item.quantity + 1);
};
const decreaseQuantity = () => {
if (item.quantity > 1) {
onUpdateQuantity(item.id, item.quantity - 1);
}
};
return (
<View className="flex-row bg-white rounded-2xl p-4 mb-3 border border-gray-100">
<Image
source={{ uri: item.product.images[0] }}
className="w-20 h-20 rounded-xl bg-gray-50"
resizeMode="contain"
/>
<View className="flex-1 ml-4">
<View className="flex-row justify-between items-start mb-2">
<View className="flex-1 pr-2">
<Text className="text-gray-900 font-semibold text-base mb-1">
{item.product.name}
</Text>
{item.variant && (
<Text className="text-gray-500 text-sm">
{item.variant.name}: {item.variant.value}
</Text>
)}
</View>
<TouchableOpacity onPress={() => onRemove(item.id)} className="p-1">
<Ionicons name="close" size={20} color="#ef4444" />
</TouchableOpacity>
</View>
<View className="flex-row justify-between items-center">
<Text className="text-gray-900 font-bold text-lg">
${(item.product.price * item.quantity).toFixed(2)}
</Text>
<View className="flex-row items-center">
<TouchableOpacity
onPress={decreaseQuantity}
className="w-8 h-8 rounded-full bg-gray-100 items-center justify-center"
>
<Ionicons name="remove" size={16} color="#6b7280" />
</TouchableOpacity>
<Text className="mx-4 text-gray-900 font-semibold text-base">
{item.quantity}
</Text>
<TouchableOpacity
onPress={increaseQuantity}
className="w-8 h-8 rounded-full bg-green-500 items-center justify-center"
>
<Ionicons name="add" size={16} color="#ffffff" />
</TouchableOpacity>
</View>
</View>
</View>
</View>
);
};
Subscribe to My Latest Guides
All the latest Guides and tutorials, straight from the team.