Back to Architecture & Design Patterns

react-native-architecture

React NativeExpoMobile DevelopmentApp ArchitectureNative ModulesOffline SyncCross-PlatformState Management
⭐ 36.8kπŸ“„ MITπŸ•’ 2026-06-16Source β†—

Install this skill

npx skills add wshobson/agents

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

The react-native-architecture skill provides a standardized blueprint for building mobile applications using Expo and React Native. It focuses on modular code organization, file-based routing through Expo Router, and reliable state management. By abstracting the complexities of native linking and build configuration, it enables developers to maintain a clean separation between UI components, business logic, and API services. This framework prioritizes scalable directory structures, secure authentication flows, and platform-consistent navigation. It is optimized for teams transitioning from web development to mobile, offering clear patterns for handling deep linking, parameter passing across dynamic routes, and persistent storage integration. The focus remains on maintainability, performance, and cross-platform compatibility across both iOS and Android environments.

When to Use This Skill

  • β€’Scaffolding a new cross-platform mobile application
  • β€’Migrating from legacy React Native navigation libraries
  • β€’Establishing secure, persistent user login sessions
  • β€’Standardizing project layouts for multi-developer teams

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • β€œSet up a new project structure for my Expo app
  • β€œShow me how to implement authentication with Expo Router
  • β€œWhat is the best directory layout for React Native?
  • β€œGenerate a standard layout file for a tab-based mobile app
  • β€œExplain the Expo vs bare React Native build process

Pro Tips

  • πŸ’‘When starting a new project, clearly define your core navigation and state management strategies early on to ensure scalability.
  • πŸ’‘Prioritize an offline-first approach for critical data to provide a seamless user experience, even with intermittent connectivity.
  • πŸ’‘Leverage Expo Managed Workflow for rapid development and easier deployment, only ejecting to Bare workflow when deep native module access is absolutely essential.

What this skill does

  • β€’Organizes codebases using a scalable directory structure
  • β€’Implements file-based routing via Expo Router
  • β€’Manages secure user authentication and token persistence
  • β€’Integrates native platform APIs with configuration plugins
  • β€’Configures global application layouts and providers

When not to use it

  • βœ•Projects requiring deep, manual C++ native module integration
  • βœ•Simple web-only applications where mobile-specific APIs are unnecessary
  • βœ•Strict bare-metal React Native projects that forbid Expo toolchains

Example workflow

  1. Initialize a new Expo project with TypeScript
  2. Define the folder structure for app screens, components, and hooks
  3. Install necessary dependencies for storage and navigation
  4. Configure the root layout to wrap the application in authentication providers
  5. Implement tab navigation using the app router syntax
  6. Add secure storage logic for user sessions

Prerequisites

  • –Node.js installed
  • –Expo CLI
  • –Basic knowledge of TypeScript

Pitfalls & limitations

  • !Over-nesting routes can lead to complex URL navigation bugs
  • !Relying on Expo managed workflow might limit access to very specific low-level native headers
  • !Failing to properly clean up storage can leave user sessions dangling

FAQ

Should I use Expo or bare React Native?
Choose Expo if you want to speed up development and simplify native build processes through EAS; use bare React Native only if you have custom, highly complex native dependencies that cannot be handled by config plugins.
How does Expo Router differ from standard React Navigation?
Expo Router uses a file-based routing system where the directory structure automatically defines the navigation tree, unlike the component-based configuration required by React Navigation.
Can I use this for offline-first apps?
Yes, the architecture supports persistent local storage integration using packages like async-storage, which is essential for maintaining app state when offline.

How it compares

Unlike manual setup which requires extensive linking and native build configuration, this architecture uses pre-defined patterns to automate the boilerplate, ensuring consistent behavior across iOS and Android.

Source & trust

⭐ 37k starsπŸ“„ MITπŸ•’ Updated 2026-06-16
πŸ“„ Full skill instructions β€” original source: wshobson/agents
# React Native Architecture

Production-ready patterns for React Native development with Expo, including navigation, state management, native modules, and offline-first architecture.

## When to Use This Skill

- Starting a new React Native or Expo project
- Implementing complex navigation patterns
- Integrating native modules and platform APIs
- Building offline-first mobile applications
- Optimizing React Native performance
- Setting up CI/CD for mobile releases

## Core Concepts

### 1. Project Structure

src/
β”œβ”€β”€ app/ # Expo Router screens
β”‚ β”œβ”€β”€ (auth)/ # Auth group
β”‚ β”œβ”€β”€ (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
β”œβ”€β”€ utils/ # Utilities
└── types/ # TypeScript types


### 2. 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


// 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>
)
}


## Patterns

### Pattern 1: Expo Router Navigation

// 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.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
/>
</Tabs>
)
}

// app/(tabs)/profile/[id].tsx - Dynamic route
import { useLocalSearchParams } from 'expo-router'

export default function ProfileScreen() {
const { id } = useLocalSearchParams<{ id: string }>()

return <UserProfile userId={id} />
}

// Navigation from anywhere
import { router } from 'expo-router'

// Programmatic navigation
router.push('/profile/123')
router.replace('/login')
router.back()

// With params
router.push({
pathname: '/product/[id]',
params: { id: '123', referrer: 'home' },
})


### Pattern 2: 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
}


### Pattern 3: Offline-First with React Query

// 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>
)
}

// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: api.getProducts,
// Use stale data while revalidating
placeholderData: (previousData) => previousData,
})
}

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'] })
},
})
}


### Pattern 4: Native Module Integration

// 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);
}
},
heavy: () => {
if (Platform.OS !== "web") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}
},
success: () => {
if (Platform.OS !== "web") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
},
error: () => {
if (Platform.OS !== "web") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
},
};

// 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;
}

// 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() {
let token: string | undefined;

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;
token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;

return token;
}


### Pattern 5: Platform-Specific Code

// components/ui/Button.tsx
import { Platform, Pressable, StyleSheet, Text, ViewStyle } from 'react-native'
import * as Haptics from 'expo-haptics'
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

interface ButtonProps {
title: string
onPress: () => void
variant?: 'primary' | 'secondary' | 'outline'
disabled?: boolean
}

export function Button({
title,
onPress,
variant = 'primary',
disabled = false,
}: 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
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={[
styles.button,
styles[variant],
disabled && styles.disabled,
animatedStyle,
]}
>
<Text style={[styles.text, styles[${variant}Text]]}>{title}</Text>
</AnimatedPressable>
)
}

// Platform-specific files
// Button.ios.tsx - iOS-specific implementation
// Button.android.tsx - Android-specific implementation
// Button.web.tsx - Web-specific implementation

// Or use Platform.select
const styles = StyleSheet.create({
button: {
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 4,
},
}),
},
primary: {
backgroundColor: '#007AFF',
},
secondary: {
backgroundColor: '#5856D6',
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#007AFF',
},
disabled: {
opacity: 0.5,
},
text: {
fontSize: 16,
fontWeight: '600',
},
primaryText: {
color: '#FFFFFF',
},
secondaryText: {
color: '#FFFFFF',
},
outlineText: {
color: '#007AFF',
},
})


### Pattern 6: Performance Optimization

// components/ProductList.tsx
import { FlashList } from '@shopify/flash-list'
import { memo, useCallback } from 'react'

interface ProductListProps {
products: Product[]
onProductPress: (id: string) => void
}

// 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 onPress={handlePress} style={styles.item}>
<FastImage
source={{ uri: item.image }}
style={styles.image}
resizeMode="cover"
/>
<Text style={styles.title}>{item.name}</Text>
<Text style={styles.price}>${item.price}</Text>
</Pressable>
)
})

export function ProductList({ products, onProductPress }: ProductListProps) {
const renderItem = useCallback(
({ item }: { item: Product }) => (
<ProductItem item={item} onPress={onProductPress} />
),
[onProductPress]
)

const keyExtractor = useCallback((item: Product) => item.id, [])

return (
<FlashList
data={products}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={100}
// Performance optimizations
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={5}
// Pull to refresh
onRefresh={onRefresh}
refreshing={isRefreshing}
/>
)
}


## EAS Build & Submit

// eas.json
{
"cli": { "version": ">= 5.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true }
},
"preview": {
"distribution": "internal",
"android": { "buildType": "apk" }
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": { "appleId": "[email protected]", "ascAppId": "123456789" },
"android": { "serviceAccountKeyPath": "./google-services.json" }
}
}
}


# 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"


## 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

### 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

## Resources

- [Expo Documentation](https://docs.expo.dev/)
- [Expo Router](https://docs.expo.dev/router/introduction/)
- [React Native Performance](https://reactnative.dev/docs/performance)
- [FlashList](https://shopify.github.io/flash-list/)

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/react-native-architecture/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/wshobson/agents/react-native-architecture/SKILL.md
  • Cursor: ~/.cursor/skills/wshobson/agents/react-native-architecture/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/wshobson/agents/react-native-architecture/SKILL.md

πŸš€ Install with CLI:
npx skills add wshobson/agents

Read the Master Guide: Mastering Agent Skills β†’

Recommended Rules

View more rules β†’

Recommended Workflows

View more workflows β†’

Recommended MCP Servers

View more MCP servers β†’

Take It Further

Maximize your productivity with these powerful resources

πŸ“‹

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
πŸ“–

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid architecture & design patterns issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under Architecture & Design Patterns and is published by W. Shobson, maintained in wshobson/agents.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.