✨ Complete Guide to Adding New Features
Index
- General Process
- Add a New Screen
- Add a New Tab
- Add a Modal
- Add a New Component
- Add Global State
- Add a New Feature (Full Example)
- Integrate External APIs
- Add Push Notifications
- Add Analytics
- New Features Checklist
General Process
To add any new functionality, follow these steps:
1. Define types → types/index.ts
2. Create mocks → mocks/data.ts
3. Create components → components/
4. Add context → contexts/ (if you need global state)
5. Create screens → app/
6. Configure navigation → app/_layout.tsx or app/(tabs)/_layout.tsx
7. Test → iOS, Android, Web
Add a New Screen
Simple Screen (inside an existing Tab)
// app/(tabs)/explore/product-detail.tsx
import { View, Text, StyleSheet, ScrollView, Image } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { useTheme } from '@/contexts/ThemeContext';
import { Button } from '@/components/Button';
import { Spacing, FontSizes } from '@/constants/colors';
export default function ProductDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { colors } = useTheme();
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
title: 'Product Detail',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
}}
/>
<Image
source={{ uri: 'https://picsum.photos/400/300' }}
style={styles.image}
/>
<View style={styles.content}>
<Text style={[styles.title, { color: colors.text }]}>
Producto #{id}
</Text>
<Text style={[styles.description, { color: colors.textSecondary }]}>
Product description here...
</Text>
<Button
title="Add to cart"
onPress={() => console.log('Add to cart')}
variant="primary"
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
image: {
width: '100%',
height: 300,
},
content: {
padding: Spacing.lg,
gap: Spacing.md,
},
title: {
fontSize: FontSizes.xxl,
fontWeight: '700',
},
description: {
fontSize: FontSizes.md,
lineHeight: 24,
},
});
Navigate to the Screen
// From any component
import { useRouter } from 'expo-router';
function ProductCard({ product }) {
const router = useRouter();
return (
<TouchableOpacity
onPress={() => router.push({
pathname: '/(tabs)/explore/product-detail',
params: { id: product.id }
})}
>
{/* content */}
</TouchableOpacity>
);
}
Add a New Tab
1. Create the Folder Structure
app/(tabs)/
└── my-new-tab/
├── _layout.tsx # Inner stack layout
├── index.tsx # Tab main screen
└── detail.tsx # Detail screen (optional)
2. Create the Tab Layout
// app/(tabs)/mi-nuevo-tab/_layout.tsx
import { Stack } from 'expo-router';
import { useTheme } from '@/contexts/ThemeContext';
export default function MiNuevoTabLayout() {
const { colors } = useTheme();
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
headerShadowVisible: false,
}}
>
<Stack.Screen
name="index"
options={{ title: 'Mi Nuevo Tab' }}
/>
<Stack.Screen
name="detalle"
options={{ title: 'Detalle' }}
/>
</Stack>
);
}
3. Create the Main Screen
// app/(tabs)/mi-nuevo-tab/index.tsx
import { View, Text, StyleSheet, FlatList } from 'react-native';
import { useTheme } from '@/contexts/ThemeContext';
import { useI18n } from '@/contexts/I18nContext';
import { Card } from '@/components/Card';
import { EmptyState } from '@/components/EmptyState';
export default function MiNuevoTabScreen() {
const { colors } = useTheme();
const { t } = useI18n();
const items = []; // Tu data aquí
if (items.length === 0) {
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<EmptyState
icon="Package"
title="No items"
description="Add your first item to get started"
actionTitle="Add"
onAction={() => {}}
/>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Card item={item} onPress={() => {}} />
)}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
list: {
padding: 16,
gap: 12,
},
});
4. Register the Tab in the Layout
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Package } from 'lucide-react-native'; // Importar icono
export default function TabLayout() {
const { colors } = useTheme();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: { backgroundColor: colors.surface },
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textTertiary,
}}
>
{/* Tabs existentes */}
<Tabs.Screen name="(home)" options={{ /* ... */ }} />
<Tabs.Screen name="explore" options={{ /* ... */ }} />
{/* Nuevo Tab */}
<Tabs.Screen
name="mi-nuevo-tab"
options={{
title: 'Mi Tab',
tabBarIcon: ({ color, size }) => (
<Package size={size} color={color} />
),
}}
/>
{/* Más tabs */}
<Tabs.Screen name="profile" options={{ /* ... */ }} />
</Tabs>
);
}
Add a Modal
1. Create the Modal Screen
// app/crear-item.tsx (fuera de (tabs)/)
import { View, Text, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
import { useRouter, Stack } from 'expo-router';
import { useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useToast } from '@/contexts/ToastContext';
import { X } from 'lucide-react-native';
import { TouchableOpacity } from 'react-native';
export default function CrearItemModal() {
const router = useRouter();
const { colors } = useTheme();
const { showToast } = useToast();
const [titulo, setTitulo] = useState('');
const [descripcion, setDescripcion] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
if (!titulo.trim()) {
showToast('Title is required', 'error');
return;
}
setIsLoading(true);
try {
// Lógica para crear el item
await new Promise(resolve => setTimeout(resolve, 1000));
showToast('Item created successfully', 'success');
router.back();
} catch (error) {
showToast('Error creating item', 'error');
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<Stack.Screen
options={{
presentation: 'modal',
title: 'Create Item',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()}>
<X size={24} color={colors.text} />
</TouchableOpacity>
),
}}
/>
<View style={styles.content}>
<Input
label="Title"
value={titulo}
onChangeText={setTitulo}
placeholder="Enter title..."
/>
<Input
label="Description"
value={descripcion}
onChangeText={setDescripcion}
placeholder="Optional description..."
multiline
numberOfLines={4}
/>
<Button
title="Create"
onPress={handleSubmit}
loading={isLoading}
disabled={isLoading}
/>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 16,
gap: 16,
},
});
2. Register in the Root Layout
// app/_layout.tsx
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="crear-item"
options={{
presentation: 'modal',
headerShown: true,
}}
/>
</Stack>
3. Open the Modal
// From anywhere
const router = useRouter();
router.push('/crear-item');
Add a New Component
Full Component Template
// components/ProductCard.tsx
import React, { useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
ViewStyle,
} from 'react-native';
import { Heart, Star, ShoppingCart } from 'lucide-react-native';
import { useTheme } from '@/contexts/ThemeContext';
import { Colors, Spacing, FontSizes, BorderRadius } from '@/constants/colors';
import { triggerHaptic } from '@/utils/helpers';
// 1. Definir interface de Props
interface ProductCardProps {
product: {
id: string;
name: string;
price: number;
image: string;
rating: number;
reviewCount: number;
};
isFavorite?: boolean;
onPress?: () => void;
onFavorite?: () => void;
onAddToCart?: () => void;
variant?: 'default' | 'compact' | 'horizontal';
style?: ViewStyle;
testID?: string;
}
// 2. Componente con props desestructuradas
export function ProductCard({
product,
isFavorite = false,
onPress,
onFavorite,
onAddToCart,
variant = 'default',
style,
testID,
}: ProductCardProps) {
const { colors, isDark } = useTheme();
// 3. Handlers con haptics
const handleFavorite = useCallback(() => {
triggerHaptic('light');
onFavorite?.();
}, [onFavorite]);
const handleAddToCart = useCallback(() => {
triggerHaptic('medium');
onAddToCart?.();
}, [onAddToCart]);
// 4. Formatear precio
const formattedPrice = new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
}).format(product.price);
// 5. Render condicional por variante
if (variant === 'compact') {
return (
<TouchableOpacity
style={[
styles.compactContainer,
{ backgroundColor: colors.surface },
style,
]}
onPress={onPress}
activeOpacity={0.7}
testID={testID}
>
<Image source={{ uri: product.image }} style={styles.compactImage} />
<Text style={[styles.compactName, { color: colors.text }]} numberOfLines={1}>
{product.name}
</Text>
<Text style={[styles.compactPrice, { color: colors.primary }]}>
{formattedPrice}
</Text>
</TouchableOpacity>
);
}
if (variant === 'horizontal') {
return (
<TouchableOpacity
style={[
styles.horizontalContainer,
{ backgroundColor: colors.surface },
style,
]}
onPress={onPress}
activeOpacity={0.7}
testID={testID}
>
<Image source={{ uri: product.image }} style={styles.horizontalImage} />
<View style={styles.horizontalContent}>
<Text style={[styles.name, { color: colors.text }]} numberOfLines={2}>
{product.name}
</Text>
<View style={styles.ratingRow}>
<Star size={14} color={Colors.warning} fill={Colors.warning} />
<Text style={[styles.rating, { color: colors.textSecondary }]}>
{product.rating} ({product.reviewCount})
</Text>
</View>
<Text style={[styles.price, { color: colors.primary }]}>
{formattedPrice}
</Text>
</View>
<TouchableOpacity onPress={handleFavorite} style={styles.favoriteButton}>
<Heart
size={20}
color={isFavorite ? Colors.error : colors.textTertiary}
fill={isFavorite ? Colors.error : 'transparent'}
/>
</TouchableOpacity>
</TouchableOpacity>
);
}
// Default variant
return (
<TouchableOpacity
style={[
styles.container,
{ backgroundColor: colors.surface },
style,
]}
onPress={onPress}
activeOpacity={0.7}
testID={testID}
>
<View style={styles.imageContainer}>
<Image source={{ uri: product.image }} style={styles.image} />
<TouchableOpacity
style={[styles.favoriteOverlay, { backgroundColor: colors.surface }]}
onPress={handleFavorite}
>
<Heart
size={18}
color={isFavorite ? Colors.error : colors.textTertiary}
fill={isFavorite ? Colors.error : 'transparent'}
/>
</TouchableOpacity>
</View>
<View style={styles.content}>
<Text style={[styles.name, { color: colors.text }]} numberOfLines={2}>
{product.name}
</Text>
<View style={styles.ratingRow}>
<Star size={14} color={Colors.warning} fill={Colors.warning} />
<Text style={[styles.rating, { color: colors.textSecondary }]}>
{product.rating} ({product.reviewCount})
</Text>
</View>
<View style={styles.footer}>
<Text style={[styles.price, { color: colors.primary }]}>
{formattedPrice}
</Text>
{onAddToCart && (
<TouchableOpacity
style={[styles.cartButton, { backgroundColor: colors.primary }]}
onPress={handleAddToCart}
>
<ShoppingCart size={16} color={Colors.textInverse} />
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
);
}
// 6. Estilos con constantes
const styles = StyleSheet.create({
// Default
container: {
borderRadius: BorderRadius.lg,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
},
imageContainer: {
position: 'relative',
},
image: {
width: '100%',
height: 160,
},
favoriteOverlay: {
position: 'absolute',
top: Spacing.sm,
right: Spacing.sm,
padding: Spacing.xs,
borderRadius: BorderRadius.full,
},
content: {
padding: Spacing.md,
gap: Spacing.xs,
},
name: {
fontSize: FontSizes.md,
fontWeight: '600' as const,
},
ratingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
rating: {
fontSize: FontSizes.sm,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: Spacing.xs,
},
price: {
fontSize: FontSizes.lg,
fontWeight: '700' as const,
},
cartButton: {
padding: Spacing.sm,
borderRadius: BorderRadius.full,
},
favoriteButton: {
padding: Spacing.sm,
},
// Horizontal
horizontalContainer: {
flexDirection: 'row',
borderRadius: BorderRadius.lg,
overflow: 'hidden',
padding: Spacing.sm,
gap: Spacing.md,
},
horizontalImage: {
width: 80,
height: 80,
borderRadius: BorderRadius.md,
},
horizontalContent: {
flex: 1,
justifyContent: 'center',
gap: Spacing.xs,
},
// Compact
compactContainer: {
width: 140,
borderRadius: BorderRadius.lg,
overflow: 'hidden',
},
compactImage: {
width: '100%',
height: 100,
},
compactName: {
fontSize: FontSizes.sm,
fontWeight: '500' as const,
padding: Spacing.sm,
paddingBottom: 0,
},
compactPrice: {
fontSize: FontSizes.md,
fontWeight: '700' as const,
padding: Spacing.sm,
paddingTop: Spacing.xs,
},
});
Add Global State
Full Context with Persistence
// contexts/CartContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AsyncStorage from '@react-native-async-storage/async-storage';
// 1. Definir tipos
interface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
image: string;
}
interface CartState {
items: CartItem[];
isLoading: boolean;
}
const STORAGE_KEY = 'shopping_cart';
// 2. Crear contexto
export const [CartProvider, useCart] = createContextHook(() => {
const queryClient = useQueryClient();
const [items, setItems] = useState<CartItem[]>([]);
// Query para cargar del storage
const cartQuery = useQuery({
queryKey: ['cart'],
queryFn: async (): Promise<CartItem[]> => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
},
});
// Sincronizar estado local con query
useEffect(() => {
if (cartQuery.data) {
setItems(cartQuery.data);
}
}, [cartQuery.data]);
// Mutation para guardar
const saveMutation = useMutation({
mutationFn: async (newItems: CartItem[]) => {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newItems));
return newItems;
},
onSuccess: (data) => {
queryClient.setQueryData(['cart'], data);
},
});
// Agregar item
const addItem = useCallback((product: Omit<CartItem, 'id' | 'quantity'>) => {
const existingIndex = items.findIndex(i => i.productId === product.productId);
let newItems: CartItem[];
if (existingIndex >= 0) {
newItems = items.map((item, index) =>
index === existingIndex
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
newItems = [
...items,
{
...product,
id: Date.now().toString(),
quantity: 1,
},
];
}
setItems(newItems);
saveMutation.mutate(newItems);
}, [items, saveMutation]);
// Actualizar cantidad
const updateQuantity = useCallback((itemId: string, quantity: number) => {
if (quantity <= 0) {
removeItem(itemId);
return;
}
const newItems = items.map(item =>
item.id === itemId ? { ...item, quantity } : item
);
setItems(newItems);
saveMutation.mutate(newItems);
}, [items, saveMutation]);
// Remover item
const removeItem = useCallback((itemId: string) => {
const newItems = items.filter(item => item.id !== itemId);
setItems(newItems);
saveMutation.mutate(newItems);
}, [items, saveMutation]);
// Limpiar carrito
const clearCart = useCallback(() => {
setItems([]);
saveMutation.mutate([]);
}, [saveMutation]);
// Cálculos derivados
const totalItems = useMemo(() =>
items.reduce((sum, item) => sum + item.quantity, 0),
[items]
);
const totalPrice = useMemo(() =>
items.reduce((sum, item) => sum + (item.price * item.quantity), 0),
[items]
);
const isInCart = useCallback((productId: string) =>
items.some(item => item.productId === productId),
[items]
);
const getItemQuantity = useCallback((productId: string) => {
const item = items.find(i => i.productId === productId);
return item?.quantity ?? 0;
}, [items]);
return {
items,
isLoading: cartQuery.isLoading,
// Acciones
addItem,
updateQuantity,
removeItem,
clearCart,
// Helpers
totalItems,
totalPrice,
isInCart,
getItemQuantity,
};
});
// 3. Hook derivado para item específico
export function useCartItem(productId: string) {
const { items, updateQuantity, removeItem } = useCart();
const item = useMemo(() =>
items.find(i => i.productId === productId),
[items, productId]
);
return {
item,
quantity: item?.quantity ?? 0,
isInCart: !!item,
increment: () => item && updateQuantity(item.id, item.quantity + 1),
decrement: () => item && updateQuantity(item.id, item.quantity - 1),
remove: () => item && removeItem(item.id),
};
}
Use the Context
// In _layout.tsx
import { CartProvider } from '@/contexts/CartContext';
<QueryClientProvider client={queryClient}>
<CartProvider>
<AppProvider>
<RootLayoutNav />
</AppProvider>
</CartProvider>
</QueryClientProvider>
// In components
function ProductActions({ product }) {
const { addItem, isInCart, getItemQuantity } = useCart();
const quantity = getItemQuantity(product.id);
return (
<View>
{isInCart(product.id) ? (
<Text>In cart: {quantity}</Text>
) : (
<Button
title="Add to cart"
onPress={() => addItem({
productId: product.id,
name: product.name,
price: product.price,
image: product.image,
})}
/>
)}
</View>
);
}
Add a New Feature (Full Example)
Example: Reviews System
1. Definir Tipos
// types/index.ts
export interface Review {
id: string;
userId: string;
userName: string;
userAvatar?: string;
productId: string;
rating: number;
title: string;
content: string;
images?: string[];
helpful: number;
createdAt: string;
}
2. Crear Mock Data
// mocks/reviews.ts
import { Review } from '@/types';
export const mockReviews: Review[] = [
{
id: '1',
userId: 'user1',
userName: 'María García',
userAvatar: 'https://i.pravatar.cc/150?img=1',
productId: 'prod1',
rating: 5,
title: 'Excelente producto',
content: 'Superó mis expectativas. La calidad es increíble y llegó muy rápido.',
images: ['https://picsum.photos/200/200?random=1'],
helpful: 24,
createdAt: '2024-01-15T10:00:00Z',
},
// ... más reseñas
];
3. Crear Componente
// components/ReviewCard.tsx
import React from 'react';
import { View, Text, StyleSheet, Image, TouchableOpacity } from 'react-native';
import { Star, ThumbsUp, MoreHorizontal } from 'lucide-react-native';
import { Review } from '@/types';
import { Avatar } from './Avatar';
import { useTheme } from '@/contexts/ThemeContext';
import { Spacing, FontSizes, BorderRadius, Colors } from '@/constants/colors';
import { formatRelativeTime, triggerHaptic } from '@/utils/helpers';
interface ReviewCardProps {
review: Review;
onHelpful?: () => void;
onOptions?: () => void;
}
export function ReviewCard({ review, onHelpful, onOptions }: ReviewCardProps) {
const { colors } = useTheme();
const handleHelpful = () => {
triggerHaptic('light');
onHelpful?.();
};
return (
<View style={[styles.container, { backgroundColor: colors.surface }]}>
{/* Header */}
<View style={styles.header}>
<Avatar
source={review.userAvatar}
name={review.userName}
size="small"
/>
<View style={styles.headerContent}>
<Text style={[styles.userName, { color: colors.text }]}>
{review.userName}
</Text>
<Text style={[styles.date, { color: colors.textTertiary }]}>
{formatRelativeTime(review.createdAt)}
</Text>
</View>
{onOptions && (
<TouchableOpacity onPress={onOptions}>
<MoreHorizontal size={20} color={colors.textTertiary} />
</TouchableOpacity>
)}
</View>
{/* Rating */}
<View style={styles.ratingRow}>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={16}
color={Colors.warning}
fill={star <= review.rating ? Colors.warning : 'transparent'}
/>
))}
</View>
{/* Content */}
<Text style={[styles.title, { color: colors.text }]}>
{review.title}
</Text>
<Text style={[styles.content, { color: colors.textSecondary }]}>
{review.content}
</Text>
{/* Images */}
{review.images && review.images.length > 0 && (
<View style={styles.imagesRow}>
{review.images.map((image, index) => (
<Image
key={index}
source={{ uri: image }}
style={styles.reviewImage}
/>
))}
</View>
)}
{/* Footer */}
<TouchableOpacity style={styles.helpfulButton} onPress={handleHelpful}>
<ThumbsUp size={16} color={colors.textTertiary} />
<Text style={[styles.helpfulText, { color: colors.textTertiary }]}>
Útil ({review.helpful})
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
headerContent: {
flex: 1,
},
userName: {
fontSize: FontSizes.md,
fontWeight: '600' as const,
},
date: {
fontSize: FontSizes.xs,
},
ratingRow: {
flexDirection: 'row',
gap: 2,
},
title: {
fontSize: FontSizes.md,
fontWeight: '600' as const,
},
content: {
fontSize: FontSizes.sm,
lineHeight: 20,
},
imagesRow: {
flexDirection: 'row',
gap: Spacing.sm,
},
reviewImage: {
width: 80,
height: 80,
borderRadius: BorderRadius.md,
},
helpfulButton: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
paddingTop: Spacing.sm,
},
helpfulText: {
fontSize: FontSizes.sm,
},
});
4. Crear Contexto
// contexts/ReviewsContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Review } from '@/types';
import { mockReviews } from '@/mocks/reviews';
export const [ReviewsProvider, useReviews] = createContextHook(() => {
const queryClient = useQueryClient();
const reviewsQuery = useQuery({
queryKey: ['reviews'],
queryFn: async () => {
// Simular API call
await new Promise(resolve => setTimeout(resolve, 500));
return mockReviews;
},
});
const addReviewMutation = useMutation({
mutationFn: async (newReview: Omit<Review, 'id' | 'createdAt' | 'helpful'>) => {
await new Promise(resolve => setTimeout(resolve, 500));
const review: Review = {
...newReview,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
helpful: 0,
};
return review;
},
onSuccess: (newReview) => {
queryClient.setQueryData(['reviews'], (old: Review[] = []) =>
[newReview, ...old]
);
},
});
const markHelpfulMutation = useMutation({
mutationFn: async (reviewId: string) => {
await new Promise(resolve => setTimeout(resolve, 100));
return reviewId;
},
onMutate: async (reviewId) => {
await queryClient.cancelQueries({ queryKey: ['reviews'] });
queryClient.setQueryData(['reviews'], (old: Review[] = []) =>
old.map(review =>
review.id === reviewId
? { ...review, helpful: review.helpful + 1 }
: review
)
);
},
});
const getReviewsByProduct = (productId: string) => {
return reviewsQuery.data?.filter(r => r.productId === productId) || [];
};
const getAverageRating = (productId: string) => {
const reviews = getReviewsByProduct(productId);
if (reviews.length === 0) return 0;
return reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
};
return {
reviews: reviewsQuery.data || [],
isLoading: reviewsQuery.isLoading,
addReview: addReviewMutation.mutateAsync,
markHelpful: markHelpfulMutation.mutate,
getReviewsByProduct,
getAverageRating,
isAdding: addReviewMutation.isPending,
};
});
5. Usar en Pantalla
// app/(tabs)/explore/product-reviews.tsx
import { View, StyleSheet, FlatList } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useReviews } from '@/contexts/ReviewsContext';
import { ReviewCard } from '@/components/ReviewCard';
import { useTheme } from '@/contexts/ThemeContext';
export default function ProductReviewsScreen() {
const { productId } = useLocalSearchParams<{ productId: string }>();
const { getReviewsByProduct, markHelpful, isLoading } = useReviews();
const { colors } = useTheme();
const reviews = getReviewsByProduct(productId);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={reviews}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ReviewCard
review={item}
onHelpful={() => markHelpful(item.id)}
/>
)}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
list: { padding: 16 },
separator: { height: 12 },
});
Integrate External APIs
Structure for External API
// lib/api/weather.ts
const API_KEY = process.env.EXPO_PUBLIC_WEATHER_API_KEY;
const BASE_URL = 'https://api.weatherapi.com/v1';
export interface WeatherData {
location: string;
temperature: number;
condition: string;
icon: string;
}
export async function getCurrentWeather(city: string): Promise<WeatherData> {
const response = await fetch(
`${BASE_URL}/current.json?key=${API_KEY}&q=${city}`
);
if (!response.ok) {
throw new Error('Failed to fetch weather');
}
const data = await response.json();
return {
location: data.location.name,
temperature: data.current.temp_c,
condition: data.current.condition.text,
icon: data.current.condition.icon,
};
}
Hook for External API
// hooks/useWeather.ts
import { useQuery } from '@tanstack/react-query';
import { getCurrentWeather, WeatherData } from '@/lib/api/weather';
export function useWeather(city: string) {
return useQuery<WeatherData>({
queryKey: ['weather', city],
queryFn: () => getCurrentWeather(city),
staleTime: 1000 * 60 * 10, // 10 minutos
enabled: !!city,
});
}
Add Push Notifications
Instalación
npx expo install expo-notifications expo-device expo-constants
Configuración
// lib/notifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export async function registerForPushNotifications() {
if (!Device.isDevice) {
console.log('Must use physical device for Push Notifications');
return null;
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Failed to get push token for push notification!');
return null;
}
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
if (Platform.OS === 'android') {
Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
}
return token.data;
}
export async function scheduleLocalNotification(
title: string,
body: string,
trigger?: Notifications.NotificationTriggerInput
) {
await Notifications.scheduleNotificationAsync({
content: { title, body },
trigger: trigger || null,
});
}
Add Analytics
Con Expo Analytics
// lib/analytics.ts
import * as Analytics from 'expo-firebase-analytics';
export const analytics = {
logEvent: async (name: string, params?: Record<string, any>) => {
try {
await Analytics.logEvent(name, params);
} catch (error) {
console.error('Analytics error:', error);
}
},
logScreenView: async (screenName: string) => {
await analytics.logEvent('screen_view', { screen_name: screenName });
},
logPurchase: async (productId: string, price: number, currency: string) => {
await analytics.logEvent('purchase', {
item_id: productId,
value: price,
currency,
});
},
setUserId: async (userId: string) => {
try {
await Analytics.setUserId(userId);
} catch (error) {
console.error('Analytics error:', error);
}
},
};
New Features Checklist
Before You Start
- Understand the requirement fully
- Identify required data types
- Plan file structure
- Review existing reusable components
During Development
- Types defined in
types/index.ts - Mock data created for development
- Components with typed props
- State managed with React Query / Context
- Navigation configured correctly
- Styles using constants from
colors.ts - Dark mode supported
- Translations added (if using i18n)
Testing
- Works on iOS
- Works on Android
- Works on Web
- Loading states handled
- Error states handled
- Haptic feedback where appropriate
UX/UI
- Loading states with skeletons
- Informative empty states
- Error states with retry
- Subtle animations
- Responsive across screen sizes
Code
- No TypeScript errors
- testIDs added for testing
- Console.logs for debugging
- Comments where needed