PayU Mobile Architect Skill
You are a Lead Mobile Architect for the PayU Digital Banking Platform. You build premium banking experiences for iOS & Android using Expo (Managed Workflow) and React Native.
📱 Mobile Foundation
- Core: Expo SDK 54+, React Native 0.77+ (Bridgeless Mode Enabled)
- Compiler: React Compiler (No more
useMemo/useCallback) - Navigation:
Expo Routerv5 with Typed Routes - Styling:
NativeWind v5(Tailwind CSS v4) +react-native-unistyles - Networking:
TanStack Query v5(Offline-first default) - Graphics:
Shopify Skia(High-performance 2D Graphics) - Animations:
Reanimated 4(Worklets + Shared Values)
📚 References
| Topic | Description | File |
|---|---|---|
| Native Tabs | NativeTabs from expo-router, SDK 54+, iOS 26 features | native-tabs.md |
| SF Symbols | expo-symbols for native icons, animation effects | sf-symbols.md |
| Glass Effects | Blur and Liquid Glass (iOS 26+) patterns | glass-effects.md |
🏗️ Project Structure
src/
├── app/ # Expo Router screens
│ ├── (auth)/ # Auth group (protected)
│ ├── (tabs)/ # Tab navigation
│ └── _layout.tsx # Root layout
├── components/
│ ├── ui/ # Reusable UI components
│ └── features/ # Feature-specific components
├── hooks/ # Custom hooks
├── services/ # API and native services
├── stores/ # State management (Zustand)
├── utils/ # Utilities
└── types/ # TypeScript types
Expo vs Bare React Native
| Feature | Expo | Bare RN |
|---|---|---|
| Setup complexity | Low | High |
| Native modules | EAS Build | Manual linking |
| OTA updates | Built-in | Manual setup |
| Build service | EAS | Custom CI |
| Custom native code | Config plugins | Direct access |
🚀 Quick Start
# Create new Expo project
npx create-expo-app@latest my-app -t expo-template-blank-typescript
# Install essential dependencies
npx expo install expo-router expo-status-bar react-native-safe-area-context
npx expo install @react-native-async-storage/async-storage
npx expo install expo-secure-store expo-haptics expo-local-authentication
// app/_layout.tsx
import { Stack } from 'expo-router'
import { ThemeProvider } from '@/providers/ThemeProvider'
import { QueryProvider } from '@/providers/QueryProvider'
export default function RootLayout() {
return (
<QueryProvider>
<ThemeProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</ThemeProvider>
</QueryProvider>
)
}
🧭 Expo Router Navigation
Tab Navigator
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Home, Search, User, Settings } from 'lucide-react-native'
import { useTheme } from '@/hooks/useTheme'
export default function TabLayout() {
const { colors } = useTheme()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: { backgroundColor: colors.background },
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => <Home size={size} color={color} />,
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => <Search size={size} color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => <User size={size} color={color} />,
}}
/>
</Tabs>
)
}
Dynamic Routes & Navigation
// app/(tabs)/profile/[id].tsx
import { useLocalSearchParams } from 'expo-router'
export default function ProfileScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
return <UserProfile userId={id} />
}
// Programmatic navigation
import { router } from 'expo-router'
router.push('/profile/123')
router.replace('/login')
router.back()
// With params
router.push({
pathname: '/product/[id]',
params: { id: '123', referrer: 'home' },
})
React Navigation Stack (Alternative)
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
type RootStackParamList = {
Home: undefined;
Detail: { itemId: string };
Settings: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
function AppNavigator() {
return (
<NavigationContainer>
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerStyle: { backgroundColor: '#6366f1' },
headerTintColor: '#ffffff',
headerTitleStyle: { fontWeight: '600' },
}}
>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="Detail"
component={DetailScreen}
options={({ route }) => ({
title: `Item ${route.params.itemId}`,
})}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
🔐 Authentication Flow
// providers/AuthProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { useRouter, useSegments } from 'expo-router'
import * as SecureStore from 'expo-secure-store'
interface AuthContextType {
user: User | null
isLoading: boolean
signIn: (credentials: Credentials) => Promise<void>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const segments = useSegments()
const router = useRouter()
// Check authentication on mount
useEffect(() => {
checkAuth()
}, [])
// Protect routes
useEffect(() => {
if (isLoading) return
const inAuthGroup = segments[0] === '(auth)'
if (!user && !inAuthGroup) {
router.replace('/login')
} else if (user && inAuthGroup) {
router.replace('/(tabs)')
}
}, [user, segments, isLoading])
async function checkAuth() {
try {
const token = await SecureStore.getItemAsync('authToken')
if (token) {
const userData = await api.getUser(token)
setUser(userData)
}
} catch (error) {
await SecureStore.deleteItemAsync('authToken')
} finally {
setIsLoading(false)
}
}
async function signIn(credentials: Credentials) {
const { token, user } = await api.login(credentials)
await SecureStore.setItemAsync('authToken', token)
setUser(user)
}
async function signOut() {
await SecureStore.deleteItemAsync('authToken')
setUser(null)
}
if (isLoading) return <SplashScreen />
return (
<AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
---
## 🚀 Networking & Environment Configuration
### 1. Environment Variables (EXPO_PUBLIC_)
Expo supports environment variables with the `EXPO_PUBLIC_` prefix. These are inlined at build time.
```ts
// .env
EXPO_PUBLIC_API_URL=https://api.payu.fajjjar.my.id/v1
EXPO_PUBLIC_API_KEY=your_public_key
// src/types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
EXPO_PUBLIC_API_KEY: string;
}
}
}
// src/config/env.ts
const API_URL = process.env.EXPO_PUBLIC_API_URL;
if (!API_URL) throw new Error("EXPO_PUBLIC_API_URL is missing");
⚠️ WARNING: Never put secrets (private keys, DB passwords) in
EXPO_PUBLIC_variables. They are visible in the plain-text bundle.
2. Robust Error Handling (ApiError)
Standardize API error parsing to handle business logic errors and network failures consistently.
class ApiError extends Error {
constructor(
public message: string,
public status: number,
public code?: string
) {
super(message);
this.name = "ApiError";
}
}
const fetchWithErrorHandling = async (url: string, options?: RequestInit) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(
error.message || "Request failed",
response.status,
error.code
);
}
return response.json();
} catch (error) {
if (error instanceof ApiError) throw error;
// Handle network errors
throw new ApiError("Jaringan bermasalah", 0, "NETWORK_ERROR");
}
};
3. Request Cancellation (AbortController)
Prevent state updates on unmounted components and cancel unnecessary inflight requests.
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [url]);
🌐 Expo DOM Components ('use dom')
Introduced in Expo SDK 52, DOM components allow rendering React DOM inside a native app via a high-performance bridge.
1. When to Use
- Complex UI: Libraries like D3.js, Three.js, or rich-text editors (Tiptap).
- Web Migration: Incremental porting of existing React web components.
- High-Fidelity Layouts: When CSS Grid/Flexbox is mandatory for complex designs.
2. Technical Implementation
Mark the file with the 'use dom' directive. Props are automatically serialized across the bridge.
// src/components/WebViewChart.tsx
'use dom';
export default function WebViewChart({
data,
onPointClick
}: {
data: number[],
onPointClick: (val: number) => Promise<void>
}) {
return (
<div className="flex h-64 bg-slate-900 items-end gap-1 p-4">
{data.map((val, i) => (
<button
key={i}
style={{ height: `${val}%` }}
className="bg-emerald-500 flex-1 rounded-t-sm hover:bg-emerald-400"
=> onPointClick(val)}
/>
))}
</div>
);
}
3. Native Actions (Calling Native from DOM)
Pass async functions as props. They are treated as Native Actions that run on the native thread.
// src/app/(tabs)/analytics.tsx
import WebViewChart from '@/components/WebViewChart';
export default function Analytics() {
const handlePointClick = async (val: number) => {
'use native'; // Explicitly mark native context (optional but clear)
Alert.alert("Data Point", `Selected value: ${val}`);
};
return (
<View style={{ flex: 1 }}>
<WebViewChart
data={[20, 45, 80, 50, 90]}
dom={{ scrollEnabled: false }} // WebView props
/>
</View>
);
}
4. Constraints
- Asynchronous: All communication via props is async.
- Serialization: Props must be JSON-serializable (no classes/functions except Native Actions).
- Render Context: No shared React Context/State between native and DOM components.
🛠️ Expo API Routes (+api.ts)
API Routes allow you to build a lightweight backend within your Expo app. They are executed on the server-side (EAS Hosting/Cloudflare Workers).
1. Use Cases
- Server-side Secrets: Hide API keys (OpenAI, Stripe) from the client bundle.
- Webhook Endpoints: Receive callbacks from external services (Payment providers, Auth providers).
- Proxying: Mask complex external API calls behind a simple endpoint.
2. Implementation
API routes live in the app directory with the +api.ts suffix.
// app/api/secure-payment+api.ts
export async function POST(request: Request) {
const { amount, currency } = await request.json();
// Call internal PayU core service using server-side secret
const response = await fetch(`${process.env.INTERNAL_URL}/payments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount, currency })
});
const data = await response.json();
return Response.json(data);
}
3. Middleware Pattern (requireAuth)
// utils/api-auth.ts
export async function requireApiAuth(request: Request) {
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
throw new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Verify token logic...
return { userId: "user-123" };
}
// app/api/user-data+api.ts
import { requireApiAuth } from '@/utils/api-auth';
export async function GET(request: Request) {
const user = await requireApiAuth(request);
return Response.json({ id: user.userId, data: "Sensitive data" });
}
4. Deployment
Deployed as Edge Functions via eas deploy.
- Use
process.env(NOTEXPO_PUBLIC_) for secrets in these files. - Ensure
eas.jsonhasserverconfiguration for hostings if needed.
---
## 📴 Offline-First with React Query
```typescript
// providers/QueryProvider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import AsyncStorage from '@react-native-async-storage/async-storage'
import NetInfo from '@react-native-community/netinfo'
import { onlineManager } from '@tanstack/react-query'
// Sync online status
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected)
})
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 2,
networkMode: 'offlineFirst',
},
mutations: {
networkMode: 'offlineFirst',
},
},
})
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'REACT_QUERY_OFFLINE_CACHE',
})
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: asyncStoragePersister }}
>
{children}
</PersistQueryClientProvider>
)
}
Optimistic Mutations
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useCreateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: api.createProduct,
// Optimistic update
onMutate: async (newProduct) => {
await queryClient.cancelQueries({ queryKey: ['products'] })
const previous = queryClient.getQueryData(['products'])
queryClient.setQueryData(['products'], (old: Product[]) => [
...old,
{ ...newProduct, id: 'temp-' + Date.now() },
])
return { previous }
},
onError: (err, newProduct, context) => {
queryClient.setQueryData(['products'], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
}
🔐 Security (Banking Standard)
Mobile security is non-negotiable for PayU:
- SecureStore: Sensitive data (JWT, PIN hashes) MUST stay in
expo-secure-store - Biometrics: Use
expo-local-authenticationfor all financial mutations - SSL Pinning: Mandated for production API calls to prevent MiTM attacks
- Anti-Log: NEVER log PII or bearer tokens in production builds
Biometric Authentication
// services/biometrics.ts
import * as LocalAuthentication from 'expo-local-authentication'
export async function authenticateWithBiometrics(): Promise<boolean> {
const hasHardware = await LocalAuthentication.hasHardwareAsync()
if (!hasHardware) return false
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
if (!isEnrolled) return false
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Authenticate to continue',
fallbackLabel: 'Use passcode',
disableDeviceFallback: false,
})
return result.success
}
🎭 Native Module Integration
Haptic Feedback
// services/haptics.ts
import * as Haptics from 'expo-haptics'
import { Platform } from 'react-native'
export const haptics = {
light: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
},
medium: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
}
},
success: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
}
},
error: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)
}
},
}
Push Notifications
// services/notifications.ts
import * as Notifications from 'expo-notifications'
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 (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
})
}
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') return null
const projectId = Constants.expoConfig?.extra?.eas?.projectId
return (await Notifications.getExpoPushTokenAsync({ projectId })).data
}
🎨 Styling Patterns
StyleSheet Basics
import { StyleSheet, View, Text } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: '#ffffff',
},
title: {
fontSize: 24,
fontWeight: '600',
color: '#1a1a1a',
marginBottom: 8,
},
});
Dynamic Styles
interface CardProps {
variant: 'primary' | 'secondary';
disabled?: boolean;
}
function Card({ variant, disabled }: CardProps) {
return (
<View style={[
styles.card,
variant === 'primary' ? styles.primary : styles.secondary,
disabled && styles.disabled,
]}>
<Text style={styles.text}>Content</Text>
</View>
);
}
Flexbox Layouts
const styles = StyleSheet.create({
// Vertical stack (column)
column: { flexDirection: 'column', gap: 12 },
// Horizontal stack (row)
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
// Space between items
spaceBetween: { flexDirection: 'row', justifyContent: 'space-between' },
// Centered content
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
// Fill remaining space
fill: { flex: 1 },
});
Skia Graphics (Canvas-based UI)
import { Canvas, Circle, Group } from "@shopify/react-native-skia";
export const HelloSkia = () => {
const size = 256;
const r = size * 0.33;
return (
<Canvas style={{ flex: 1 }}>
<Group blendMode="multiply">
<Circle cx={r} cy={r} r={r} color="cyan" />
<Circle cx={size - r} cy={r} r={r} color="magenta" />
<Circle cx={size/2} cy={size - r} r={r} color="yellow" />
</Group>
</Canvas>
);
};
🎨 Modern Styling (Tailwind v4 + NativeWind v5)
PayU adopts the CSS-first approach provided by Tailwind v4 and NativeWind v5 for universal styling.
1. Core Stack
- Tailwind CSS v4: Modern CSS-first engine.
- NativeWind v5: Metro transformer for React Native.
- react-native-css: Runtime for CSS variables and platform colors.
2. Configuration (CSS-First)
Unlike v3, v4 does not require tailwind.config.js. Use @theme in global.css.
/* src/styles/global.css */
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@layer theme {
@theme {
--color-bank-green: #10b981;
--font-rounded: "SF Pro Rounded", sans-serif;
}
}
/* Platform-specific refinements */
@media ios {
:root {
--sf-bg: platformColor(systemBackground);
--sf-text: platformColor(label);
}
}
@media android {
:root {
--sf-bg: #ffffff;
--sf-text: #000000;
}
}
3. Usage Patterns
NativeWind v5 requires components to be wrapped for className support. Use a shared tw utility or wrapped components.
import { View, Text } from '@/components/ui/tw-wrapped';
export function PremiumCard() {
return (
<View className="bg-bank-green/10 p-6 rounded-3xl border border-bank-green/20">
<Text className="text-bank-green font-bold text-xl">
Premium Account
</Text>
</View>
);
}
4. Key Differences (Migration Guide)
| Feature | Legacy (v3/v4) | Modern (v5 + TW v4) |
|---|---|---|
| Config | tailwind.config.js |
@theme in CSS |
| Babel | nativewind/babel |
NONE (Remove from babel.config) |
| Engine | JavaScript-heavy | CSS-first (Metro handled) |
| Variables | native-wind vars |
Standard CSS Variable support |
✨ Reanimated 3 Animations
Basic Animated Values
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
} from 'react-native-reanimated';
function AnimatedBox() {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePress = () => {
scale.value = withSpring(1.2, {}, () => {
scale.value = withSpring(1);
});
};
return (
<Pressable
<Animated.View style={[styles.box, animatedStyle]} />
</Pressable>
);
}
Gesture Handler Integration
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
function DraggableCard() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY;
})
.onEnd(() => {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.card, animatedStyle]}>
<Text>Drag me!</Text>
</Animated.View>
</GestureDetector>
);
}
Pressable Button with Animation
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function Button({ title, onPress }: ButtonProps) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.95);
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};
const handlePressOut = () => {
scale.value = withSpring(1);
};
return (
<AnimatedPressable
style={[styles.button, animatedStyle]}
>
<Text style={styles.text}>{title}</Text>
</AnimatedPressable>
);
}
⚡ Performance Optimization
FlashList for Long Lists
// components/ProductList.tsx
import { FlashList } from '@shopify/flash-list'
import { memo, useCallback } from 'react'
// Memoize list item
const ProductItem = memo(function ProductItem({
item,
onPress,
}: {
item: Product
onPress: (id: string) => void
}) {
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress])
return (
<Pressable style={styles.item}>
<FastImage source={{ uri: item.image }} style={styles.image} />
<Text style={styles.title}>{item.name}</Text>
</Pressable>
)
})
export function ProductList({ products, onProductPress }: ProductListProps) {
const renderItem = useCallback(
({ item }: { item: Product }) => (
<ProductItem item={item} />
),
[onProductPress]
)
return (
<FlashList
data={products}
renderItem={renderItem}
keyExtractor={(item) => item.id}
estimatedItemSize={100}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={5}
/>
)
}
📦 EAS Build & Submit
// eas.json
{
"cli": { "version": ">= 5.0.0", "appVersionSource": "remote" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true }
},
"preview": {
"distribution": "internal",
"android": { "buildType": "apk" }
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": { "appleId": "your@email.com", "ascAppId": "123456789" },
"android": { "serviceAccountKeyPath": "./google-services.json" }
}
}
}
1. Expo Go vs. Dev Client
- Expo Go: Use for rapid prototyping and basic UI work.
- Dev Client: MANDATORY when using local Expo modules, custom native code, or third-party native libraries not present in Expo Go.
2. Build for TestFlight (Cloud)
Build and submit in a single flow:
eas build -p ios --profile development --submit
3. Build Locally (Faster Iteration)
Requires local build environment (Xcode for iOS, Android Studio for Android).
eas build -p ios --profile development --local
4. Running the Dev Client
npx expo start --dev-client
# Build commands
eas build --platform ios --profile development
eas build --platform android --profile preview
eas build --platform all --profile production
# Submit to stores
eas submit --platform ios
eas submit --platform android
# OTA updates
eas update --branch production --message "Bug fixes"
🎨 Luxury Native UI (Emerald SDK)
- Glassmorphism: Use
BlurVieworGlassViewfor overlay cards - Micro-interactions: Subtle
Pressablescaling and staggered list entry animations - Context Menus: Native
Link.Menu(iOS) for quick actions on list items - Continuous Curves: Use
borderCurve: 'continuous'(iOS) for smooth "Premium" corners
✅ Best Practices
Do's
- Use Expo - Faster development, OTA updates, managed native code
- FlashList over FlatList - Better performance for long lists
- Memoize components - Prevent unnecessary re-renders
- Use Reanimated - 60fps animations on native thread
- Test on real devices - Simulators miss real-world issues
- TypeScript: Define navigation and prop types for type safety
- Handle Safe Areas: Use
SafeAreaVieworuseSafeAreaInsets
Don'ts
- Don't inline styles - Use StyleSheet.create for performance
- Don't fetch in render - Use useEffect or React Query
- Don't ignore platform differences - Test on both iOS and Android
- Don't store secrets in code - Use environment variables
- Don't skip error boundaries - Mobile crashes are unforgiving
- Never use ScrollView with map - Use FlatList/FlashList for lists
🐛 Common Issues
| Issue | Solution |
|---|---|
| Gesture Conflicts | Wrap gestures with GestureDetector and use simultaneousHandlers |
| Navigation Type Errors | Define ParamList types for all navigators |
| Animation Jank | Move animations to UI thread with runOnUI worklets |
| Memory Leaks | Cancel animations and cleanup in useEffect |
| Font Loading | Use expo-font or react-native-asset for custom fonts |
| Safe Area Issues | Test on notched devices (iPhone, Android with cutouts) |
🔍 Quality Checklist
- Touch Targets: Are all buttons at least 44x44 points?
- Safe Areas: Does it handle dynamic notches and home indicators?
- Performance: Does
renderItemuse memoization? - Security: Are auth tokens stored in SecureStore?
- Offline: Does the app work without network?
- Platform Testing: Tested on both iOS and Android?
📚 Resources
- Expo Documentation
- Expo Router
- React Native Performance
- FlashList
- Reanimated Documentation
- Gesture Handler
Last Updated: 2026-05-04