React Native Overview
React Native renders real native UI components — not WebViews. Your JavaScript code runs in a separate thread and communicates with the native side via the New Architecture's JSI (JavaScript Interface), which replaced the legacy asynchronous bridge with synchronous, direct memory access.
- Zero native toolchain setup
- OTA updates via Expo Updates
- Pre-built native modules
- EAS Build for App Store
- Best for: most apps, fast iteration
- Full Xcode + Android Studio control
- Any native module available
- Custom native code (Swift/Kotlin)
- Requires iOS/Android dev environment
- Best for: complex native integrations
The vast majority of apps are well-served by Expo's managed workflow. You can always "eject" to a bare workflow later if you need custom native code. Starting bare means managing Xcode and Android Studio from day one — unnecessary overhead for most projects.
Project Setup
npx create-expo-app@latest MyApp --template blank-typescript
cd MyApp
npx expo install expo-router react-native-safe-area-context react-native-screens
npm install @tanstack/react-query zustand axios
{
"expo": {
"name": "MyApp",
"slug": "myapp",
"version": "1.0.0",
"scheme": "myapp",
"orientation": "portrait",
"icon": "./assets/icon.png",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#0e1828"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.myorg.myapp",
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "Used to show nearby content",
"NSCameraUsageDescription": "Used to capture photos"
}
},
"android": {
"package": "com.myorg.myapp",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0e1828"
},
"permissions": ["CAMERA", "ACCESS_FINE_LOCATION"]
},
"plugins": [
"expo-router",
["expo-notifications", { "sounds": ["./assets/notification.wav"] }]
]
}
}
Navigation
Expo Router uses a file-based routing system (similar to Next.js) built on React Navigation. Files in the app/ directory automatically become routes.
app/
├── _layout.tsx # Root layout (providers, auth guard)
├── index.tsx # "/" → Home screen
├── (tabs)/
│ ├── _layout.tsx # Tab bar layout
│ ├── feed.tsx # "/feed"
│ ├── search.tsx # "/search"
│ └── profile.tsx # "/profile"
└── post/
└── [id].tsx # "/post/:id" dynamic route
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
(
),
}}
/>
(
),
}}
/>
);
}
State Management
Separate server state (remote data — use TanStack Query) from client state (UI state — use Zustand or Context). This separation keeps components clean and avoids the complexity of managing cache invalidation manually.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface AuthState {
token: string | null;
userId: string | null;
setAuth: (token: string, userId: string) => void;
logout: () => void;
}
export const useAuthStore = create()(
persist(
(set) => ({
token: null,
userId: null,
setAuth: (token, userId) => set({ token, userId }),
logout: () => set({ token: null, userId: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
import { useInfiniteQuery } from '@tanstack/react-query';
import { api } from '../services/api';
export function useFeed() {
return useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam = 1 }) =>
api.get(`/posts?page=${pageParam}&limit=15`).then(r => r.data),
getNextPageParam: (lastPage) =>
lastPage.meta.page < lastPage.meta.pages
? lastPage.meta.page + 1
: undefined,
initialPageParam: 1,
staleTime: 2 * 60 * 1000, // 2 minutes
});
}
Native UI Components
React Native's core components map directly to native UI widgets. View = UIView / android.view.View. Layout uses Flexbox with the same properties as CSS, but with different defaults (column direction, no inherited styles).
import { FlatList, View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { useFeed } from '../hooks/useFeed';
import { PostCard } from './PostCard';
export function FeedScreen() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useFeed();
const posts = data?.pages.flatMap(p => p.data) ?? [];
if (isLoading) return ;
return (
item.id}
renderItem={({ item }) => }
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.5}
contentContainerStyle={styles.list}
ListFooterComponent={
isFetchingNextPage
?
: null
}
// Performance optimisations
removeClippedSubviews
maxToRenderPerBatch={8}
windowSize={10}
initialNumToRender={6}
/>
);
}
const styles = StyleSheet.create({
loader: { flex: 1, justifyContent: 'center' },
list: { paddingHorizontal: 16, paddingTop: 8 },
footer: { paddingVertical: 20 },
});
Native Module Integration
Expo provides JavaScript wrappers for most native APIs. For custom native functionality, use Expo Modules API to write Swift/Kotlin native modules with a clean TypeScript interface.
import * as Camera from 'expo-camera';
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';
// Camera
async function takePicture() {
const { status } = await Camera.requestCameraPermissionsAsync();
if (status !== 'granted') return;
// Camera component handles the rest
}
// Geolocation
async function getCurrentLocation() {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') throw new Error('Location denied');
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
return { lat: location.coords.latitude, lng: location.coords.longitude };
}
// Push notifications
async function registerForPushNotifications(): Promise {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return null;
const token = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
});
return token.data; // Send this to your server
}
// Handle foreground notifications
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
API Integration
Use Axios with TanStack Query for most API needs. Configure a base Axios instance that reads the auth token from Zustand and handles token refresh — the same pattern as the full-stack guide.
import axios from 'axios';
import { useAuthStore } from '../store/authStore';
export const api = axios.create({
baseURL: process.env.EXPO_PUBLIC_API_URL,
timeout: 10_000,
});
api.interceptors.request.use(config => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
res => res,
async err => {
if (err.response?.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(err);
}
);
// Offline support: queue failed requests
import NetInfo from '@react-native-community/netinfo';
NetInfo.addEventListener(state => {
if (state.isConnected) {
// flush queued requests
}
});
- Always set a timeout — mobile networks are unreliable. 10 seconds is a reasonable default.
- Use TanStack Query's
networkMode: 'offlineFirst'to serve stale cache data when offline. - Implement exponential backoff on retry for failed network requests.
Testing and Publishing
Test React Native apps with Jest (unit/integration) and Detox (E2E on real simulators/devices). Use EAS Build to produce App Store and Google Play binaries without managing Xcode or Android Studio locally.
import { render, screen, fireEvent } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginScreen } from '../screens/LoginScreen';
const wrapper = ({ children }: any) => (
{children}
);
describe('LoginScreen', () => {
it('shows validation error for empty email', async () => {
render( , { wrapper });
fireEvent.press(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/email is required/i)).toBeTruthy();
});
it('calls login with correct credentials', async () => {
const mockLogin = jest.fn();
render( , { wrapper });
fireEvent.changeText(screen.getByPlaceholderText(/email/i), 'test@example.com');
fireEvent.changeText(screen.getByPlaceholderText(/password/i), 'password123');
fireEvent.press(screen.getByRole('button', { name: /sign in/i }));
await screen.findByText(/welcome/i);
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
});
});
# Install EAS CLI
npm install -g eas-cli && eas login
# Configure builds
eas build:configure
# Build for both platforms (production)
eas build --platform all --profile production
# Submit to App Store Connect and Google Play
eas submit --platform ios --profile production
eas submit --platform android --profile production
# OTA update (no app store review needed for JS changes)
eas update --branch production --message "Fix login bug"