I've shipped six React Native apps to production over the past four years, and the architecture decisions we made early on determined whether we'd be iterating quickly or drowning in technical debt by month six. The React Native ecosystem loves to talk about bridge optimization and JSI, but the real battle is won or lost in how you structure your codebase. Here's what actually matters when you're building for scale.
The Module Layer Pattern
Most React Native apps start as a monolith and die as a monolith. The biggest architectural win I've implemented is treating major features as isolated modules with explicit dependency contracts. Each module owns its navigation, state, and native bridges. This isn't about microservices—it's about cognitive load and parallel development.
// modules/payments/index.ts
export interface PaymentsModule {
screens: {
CheckoutScreen: React.ComponentType<CheckoutProps>;
PaymentMethodsScreen: React.ComponentType;
};
hooks: {
usePaymentMethods: () => PaymentMethod[];
useCheckout: () => CheckoutAPI;
};
services: {
processPayment: (params: PaymentParams) => Promise<PaymentResult>;
};
}
// modules/payments/PaymentsModule.tsx
class PaymentsModuleImpl implements PaymentsModule {
constructor(
private analytics: AnalyticsService,
private api: APIClient
) {}
screens = {
CheckoutScreen: lazy(() => import('./screens/CheckoutScreen')),
PaymentMethodsScreen: lazy(() => import('./screens/PaymentMethodsScreen')),
};
hooks = {
usePaymentMethods: () => {
// Module-scoped hook implementation
return useQuery(['payment-methods'], () =>
this.api.get('/payment-methods')
);
},
useCheckout: () => createCheckoutHook(this.api, this.analytics),
};
services = {
processPayment: async (params) => {
this.analytics.track('payment_initiated', params);
return this.api.post('/payments', params);
},
};
}
Native Bridge Organization
The new architecture (Fabric + TurboModules) is great, but you still need a coherent strategy for native code. I organize native modules by capability, not by platform. Each capability gets a TypeScript interface, platform-specific implementations, and a unified JS API. This makes mocking trivial for testing and keeps platform differences contained.
// native/capabilities/biometrics/index.ts
export interface BiometricsCapability {
isAvailable(): Promise<boolean>;
authenticate(reason: string): Promise<BiometricResult>;
getSupportedTypes(): Promise<BiometricType[]>;
}
// native/capabilities/biometrics/BiometricsModule.ts
import { NativeModules } from 'react-native';
const { RNBiometrics } = NativeModules;
export class BiometricsModule implements BiometricsCapability {
async isAvailable(): Promise<boolean> {
if (!RNBiometrics) return false;
const { available } = await RNBiometrics.isSensorAvailable();
return available;
}
async authenticate(reason: string): Promise<BiometricResult> {
const { success, error } = await RNBiometrics.simplePrompt({
promptMessage: reason,
cancelButtonText: 'Cancel',
});
return {
authenticated: success,
error: error ? this.mapNativeError(error) : undefined,
};
}
async getSupportedTypes(): Promise<BiometricType[]> {
const { biometryType } = await RNBiometrics.isSensorAvailable();
return biometryType ? [biometryType as BiometricType] : [];
}
private mapNativeError(error: string): BiometricError {
// Map platform-specific errors to unified error types
if (error.includes('cancelled')) return 'USER_CANCELLED';
if (error.includes('lockout')) return 'LOCKED_OUT';
return 'UNKNOWN';
}
}
Navigation as Infrastructure
React Navigation is the de facto standard, but treat it as infrastructure, not application code. I create a routing layer that decouples navigation logic from screens. This means you can deep link to any screen with type-safe parameters, and navigation logic lives in one place instead of scattered across 50 navigation.navigate() calls.
// navigation/Router.ts
export class Router {
constructor(private navRef: NavigationContainerRef) {}
// Type-safe navigation methods
goToCheckout(params: { cartId: string; promoCode?: string }) {
this.navRef.navigate('Payments', {
screen: 'Checkout',
params,
});
}
goToProductDetails(productId: string) {
this.navRef.navigate('Catalog', {
screen: 'ProductDetails',
params: { productId },
});
}
// Deep linking handled centrally
handleDeepLink(url: string) {
const route = this.parseDeepLink(url);
if (route.path === '/products/:id') {
this.goToProductDetails(route.params.id);
} else if (route.path === '/checkout') {
this.goToCheckout({ cartId: route.params.cartId });
}
}
private parseDeepLink(url: string): ParsedRoute {
// Centralized deep link parsing logic
return parseURL(url, this.routeConfig);
}
}
Performance Boundaries
React Native performance issues are almost always about unnecessary re-renders or bridge congestion. I establish performance boundaries using React.memo aggressively at the screen level and use separate bridge channels for high-frequency events. For lists, FlatList with proper keyExtractor and getItemLayout is non-negotiable.
- Screen-level memoization: Wrap every screen component in React.memo with a custom comparison function that checks route params
- Bridge batching: Never send individual bridge calls in loops—batch them or use a dedicated high-frequency channel
- Image optimization: Use FastImage with aggressive caching and always specify dimensions to prevent layout thrashing
- Hermes + RAM bundles: Enable Hermes and use RAM bundle format for faster startup on Android
- Monitor with Flipper: Use Flipper's performance plugins in development to catch bridge bottlenecks before they hit production
The Build System
Your CI/CD pipeline will make or break your velocity. I use Fastlane for both platforms with environment-specific build configurations managed through .env files and react-native-config. The key is making builds reproducible and keeping build times under 15 minutes. Cache your Gradle/CocoaPods dependencies aggressively, and use EAS Build if you can't maintain your own build infrastructure.
The architecture patterns that matter in React Native aren't exotic—they're the boring fundamentals applied consistently. Modular boundaries, explicit interfaces, centralized infrastructure, and performance monitoring. Get these right early, and you'll spend your time shipping features instead of untangling dependencies.