| name | mobile |
| description | This skill should be used when the user asks about "React Native", "Expo", "mobile app", "iOS", "Android", "native module", "Expo Router", "mobile navigation", "mobile UI", "app store", "push notifications mobile", "mobile performance", "native APIs", "EAS Build", "mobile testing", or needs React Native and Expo development knowledge. |
| keywords | ["React Native","Expo","mobile app","iOS","Android","native module","Expo Router","mobile navigation","mobile UI","app store","push notifications mobile","mobile performance","native APIs","EAS Build","mobile testing"] |
React Native & Expo
Mobile-layer skill covering React Native development with Expo, navigation, styling, native modules, performance, and app store deployment. Extends React knowledge for mobile platforms.
Expo vs Bare React Native Decision Tree
Starting a new mobile app?
├── Need native modules not in Expo?
│ ├── Check Expo SDK first — most modules are covered
│ ├── Still need custom native code? → Expo with dev client (prebuild)
│ └── Extremely specialized native code? → Bare React Native (rare)
│
├── Team experience?
│ ├── Web developers → Expo (familiar tooling, fast start)
│ ├── Native developers → Either (Expo still recommended)
│ └── Solo developer → Expo (less config, more building)
│
└── Default answer: Use Expo
├── Managed workflow for most apps
├── Dev client (prebuild) when custom native needed
└── Eject only as absolute last resort
Expo vs Bare Comparison:
| Feature | Expo (Managed) | Expo (Dev Client) | Bare RN |
|---|
| Setup time | Minutes | ~30 min | Hours |
| OTA updates | Built-in | Built-in | CodePush |
| Native modules | Expo SDK only | Any | Any |
| Build service | EAS Build | EAS Build | Manual (Xcode/Gradle) |
| Config | app.json / app.config.ts | + native files | Native files only |
| Best for | Most apps | Custom native | Brownfield apps |
Project Setup
npx create-expo-app@latest my-app
cd my-app
npx expo start
npx expo install expo-router expo-image expo-secure-store
npx expo install react-native-reanimated react-native-gesture-handler
npx expo install @react-native-async-storage/async-storage
Navigation with Expo Router
File-Based Routing
app/
_layout.tsx # Root layout (providers, fonts)
index.tsx # Home screen (/)
settings.tsx # Settings screen (/settings)
(tabs)/
_layout.tsx # Tab navigator layout
home.tsx # Tab: Home
profile.tsx # Tab: Profile
(auth)/
_layout.tsx # Auth group layout
login.tsx # /login
register.tsx # /register
users/
[id].tsx # Dynamic: /users/123
index.tsx # /users
+not-found.tsx # 404 screen
Root Layout
import { Stack } from 'expo-router'
import { QueryClientProvider } from '@tanstack/react-query'
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
</QueryClientProvider>
)
}
Tab Layout
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#007AFF' }}>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
)
}
Core Components Reference
| Component | Web Equivalent | Notes |
|---|
<View> | <div> | Flexbox by default (column direction) |
<Text> | <p> / <span> | All text must be inside <Text> |
<ScrollView> | Scrollable <div> | For short lists only |
<FlatList> | Virtual scrollable list | For long lists (virtualized) |
<TextInput> | <input> | Controlled with onChangeText |
<Pressable> | <button> | Replaces TouchableOpacity |
<Image> / expo-image | <img> | Use expo-image for caching |
<Modal> | Dialog | Built-in modal component |
<SafeAreaView> | N/A | Respects notch/status bar |
<KeyboardAvoidingView> | N/A | Shifts content above keyboard |
Component Rules:
- All text must be wrapped in
<Text> (no raw strings in <View>)
- Use
<FlatList> for lists > 20 items (not ScrollView + .map())
- Use
<Pressable> instead of TouchableOpacity (more flexible)
- Use
expo-image instead of <Image> (better caching, formats)
Styling Patterns
StyleSheet (Default)
import { StyleSheet, View, Text } from 'react-native'
export function Card({ title, children }) {
return (
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
{children}
</View>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
})
NativeWind (Tailwind for RN)
import { View, Text } from 'react-native'
export function Card({ title, children }) {
return (
<View className="bg-white rounded-xl p-4 shadow-md">
<Text className="text-lg font-semibold mb-2">{title}</Text>
{children}
</View>
)
}
Styling decision: Use NativeWind if team knows Tailwind. Use StyleSheet for more control or if avoiding extra dependencies.
Platform-Specific Code
import { Platform } from 'react-native'
const styles = StyleSheet.create({
shadow: Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 8 },
android: { elevation: 3 },
}),
})
State Management
Same patterns as React web:
- Local state →
useState / useReducer
- Global UI state → Zustand
- Server state → TanStack Query
- Form state → React Hook Form
- Secure storage →
expo-secure-store (for tokens, not AsyncStorage)
- Persistent storage →
@react-native-async-storage/async-storage
Performance Optimization
FlatList Optimization
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ItemCard item={item} />}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews={true}
initialNumToRender={10}
/>
const ItemCard = React.memo(function ItemCard({ item }) {
return (
<View style={styles.item}>
<Text>{item.title}</Text>
</View>
)
})
Performance Rules
| Issue | Solution |
|---|
| Slow list scrolling | FlatList + React.memo + getItemLayout |
| Large images | expo-image with caching, resize on server |
| Slow animations | react-native-reanimated (runs on UI thread) |
| Bundle size | Tree-shake, lazy load screens |
| Slow startup | Minimize initial imports, splash screen |
| Memory leaks | Clean up subscriptions, listeners in useEffect |
| JS thread blocking | Move heavy computation to InteractionManager.runAfterInteractions |
EAS Build & Submit
npm install -g eas-cli
eas build:configure
eas build --profile development --platform ios
eas build --profile production --platform all
eas submit --platform ios
eas submit --platform android
eas update --branch production --message "Fix typo on home screen"
eas.json Configuration
{
"cli": { "version": ">= 12.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}
Push Notifications
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
async function registerForPushNotifications() {
if (!Device.isDevice) return null
const { status: existing } = await Notifications.getPermissionsAsync()
let finalStatus = existing
if (existing !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') return null
const token = await Notifications.getExpoPushTokenAsync({
projectId: 'your-project-id',
})
return token.data
}
Common Mobile Mistakes
| Mistake | Symptom | Fix |
|---|
ScrollView for long lists | Janky scroll, high memory | Use FlatList with virtualization |
| Animations on JS thread | 30fps, stuttering | Use react-native-reanimated |
| Storing tokens in AsyncStorage | Security vulnerability | Use expo-secure-store |
| Not handling keyboard | Input hidden behind keyboard | KeyboardAvoidingView + scroll |
| Ignoring safe areas | Content under notch/status bar | SafeAreaView or useSafeAreaInsets |
| No offline handling | Crashes without network | TanStack Query offline support + error states |
| Testing only on simulator | Real device issues missed | Test on physical devices regularly |
| No splash screen config | White flash on launch | Configure expo-splash-screen |
| Not requesting permissions properly | Permission denied permanently | Check status before requesting, explain why |
Raw string outside <Text> | Crash: "Text strings must be rendered within a Text component" | Wrap all text in <Text> |
Non-route files inside src/app/ | Warning wall: Route "X" is missing the required default export; junk deep-links; polluted typed-routes Href union | Expo Router routes ONLY in src/app/; screen-private files go in src/screens/<route-path>/ (see references/expo-router-guide.md) |
Adding dummy export default to silence the route warning | Warning gone but file becomes a REAL navigable route (sitemap, deep-link, typed routes) | Move the file out of src/app/ instead — never fake the export |
Flex <Spacer /> next to a flex-1 scroll region | Region renders half-height; content clipped with dead space below | Two flex-1 siblings split the leftover height — drop the Spacer; the flex-1 region already pins the CTA to the bottom |
Pre-Delivery Checklist
References
references/expo-router-guide.md — navigation patterns, deep linking, typed routes
references/rn-performance.md — profiling, Hermes, reanimated, FlatList optimization
references/native-modules.md — Expo modules API, config plugins, native integration
references/eas-workflow.md — EAS Build, Submit, Update complete workflow
examples/expo-app-setup.md — complete Expo app with navigation, auth, and data fetching
examples/mobile-form-pattern.md — mobile form with validation, keyboard handling, and accessibility