← Back to all blogs
React Native App Architecture – Best Practices for Scalable Mobile Development
Sat Feb 28 20268 minIntermediate

React Native App Architecture – Best Practices for Scalable Mobile Development

A comprehensive guide to building maintainable, scalable React Native apps using modern architecture best practices, complete with code samples and FAQs.

#react native#mobile architecture#state management#navigation#clean code#performance

Understanding React Native Architecture

React Native bridges JavaScript and native platforms (iOS/Android) using a JavaScript thread, a bridge, and native modules. Grasping this runtime model is the first step toward a resilient architecture.

The Three Core Layers

  1. JavaScript Layer - Runs your React components, business logic, and state management. It is single‑threaded, so heavy computation can block UI updates.
  2. Bridge - Serialises messages between JavaScript and native code via JSON. Over‑using the bridge leads to performance bottlenecks.
  3. Native Layer - Executes UI rendering on the native UI thread. Native modules expose platform‑specific APIs to JavaScript.

Why architecture matters - A well‑structured codebase isolates UI, business logic, and platform concerns, reducing bridge traffic and simplifying testing.

Common Pitfalls

  • Monolithic components that mix UI, state, and side‑effects.
  • Tight coupling between screens and services, making refactoring risky.
  • Uncontrolled global state that forces unnecessary re‑renders.

The rest of this guide shows how to avoid these traps with a modular, layered architecture.

Sample Project Layout

text src/ ├─ assets/ # Images, fonts, icons ├─ components/ # Re‑usable UI widgets ├─ screens/ # Feature‑level screens ├─ navigation/ # React Navigation configuration ├─ services/ # API clients, native wrappers ├─ store/ # Redux/Context slices & store setup ├─ utils/ # Helpers, constants └─ App.tsx # Root component

Each folder represents a logical layer, keeping concerns separated and the bridge usage minimal.

Modular Layered Architecture: Best Practices

Adopting a layered approach-Presentation, Domain, Data-mirrors proven patterns from backend development and scales effortlessly.

1. Presentation Layer (UI)

  • Stateless functional components that receive props and callbacks.
  • Use React.memo for pure UI components to prevent unnecessary re‑renders.
  • Keep layout separate from business logic.

tsx // src/components/Button.tsx import React from 'react'; import { TouchableOpacity, Text, StyleSheet } from 'react-native';

type Props = { label: string; onPress: () => void; disabled?: boolean; };

const Button: React.FC<Props> = React.memo(({ label, onPress, disabled }) => ( <TouchableOpacity style={styles.base} onPress={onPress} disabled={disabled}> <Text style={styles.text}>{label}</Text> </TouchableOpacity> ));

const styles = StyleSheet.create({ base: { padding: 12, backgroundColor: '#0066ff', borderRadius: 4 }, text: { color: '#fff', textAlign: 'center' } });

export default Button;

2. Domain Layer (Business Logic)

Encapsulate use‑cases in service classes or hooks. This layer does not know anything about UI components.

tsx // src/services/UserService.ts import apiClient from '../utils/apiClient';

type User = { id: string; name: string; email: string };

export default class UserService { static async fetchProfile(userId: string): Promise<User> { const response = await apiClient.get(/users/${userId}); return response.data; } }

A screen can now call UserService.fetchProfile without embedding API details.

3. Data Layer (API & Persistence)

  • Centralise Axios (or fetch) instances with interceptors for auth tokens.
  • Optionally integrate React Query or RTK Query for caching and background refetch.

tsx // src/utils/apiClient.ts import axios from 'axios';

const apiClient = axios.create({ baseURL: 'https://api.example.com', timeout: 8000 });

apiClient.interceptors.request.use(config => { // Attach JWT token if available const token = /* retrieve token from secure storage */ ''; if (token) config.headers.Authorization = Bearer ${token}; return config; });

export default apiClient;

Dependency Injection (DI) via Context

Rather than importing services directly, expose them through a React Context. This improves testability.

tsx // src/context/ServiceProvider.tsx import React, { createContext, useContext } from 'react'; import UserService from '../services/UserService';

type ServiceContextProps = { userService: typeof UserService };

const ServiceContext = createContext<ServiceContextProps | undefined>(undefined);

export const ServiceProvider: React.FC = ({ children }) => ( <ServiceContext.Provider value={{ userService: UserService }}> {children} </ServiceContext.Provider> );

export const useServices = (): ServiceContextProps => { const ctx = useContext(ServiceContext); if (!ctx) throw new Error('ServiceProvider is missing'); return ctx; };

Now any component can call const { userService } = useServices(); without hard‑coding imports.

Reducing Bridge Overhead

  • Batch UI updates using unstable_batchedUpdates when multiple state changes happen together.
  • Prefer native modules for heavy computation (e.g., image processing) instead of JavaScript loops.
  • Keep JSON payloads small; avoid passing whole objects through the bridge.

State Management and Navigation Strategies

Choosing the right state management solution and navigation library is vital for a predictable architecture.

1. Global State with Redux Toolkit

Redux Toolkit (RTK) provides a concise API, immutable updates via Immer, and built‑in devtools.

tsx // src/store/userSlice.ts import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import { UserService } from '../services/UserService';

type UserState = { profile: null | any; status: 'idle' | 'loading' | 'succeeded' | 'failed'; };

const initialState: UserState = { profile: null, status: 'idle' };

export const fetchUserProfile = createAsyncThunk('user/fetchProfile', async (userId: string) => { const data = await UserService.fetchProfile(userId); return data; });

const userSlice = createSlice({ name: 'user', initialState, reducers: {}, extraReducers: builder => { builder.addCase(fetchUserProfile.pending, state => { state.status = 'loading'; }) .addCase(fetchUserProfile.fulfilled, (state, action) => { state.status = 'succeeded'; state.profile = action.payload; }) .addCase(fetchUserProfile.rejected, state => { state.status = 'failed'; }); } });

export default userSlice.reducer;

Configure the store once in src/store/index.ts and wrap the app with <Provider>.

2. Local Component State & React Query

For data that does not need to persist across screens, React Query (or RTK Query) offers caching, background refetch, and optimistic updates without global Redux boilerplate.

tsx // src/screens/ProfileScreen.tsx import React from 'react'; import { View, Text, ActivityIndicator } from 'react-native'; import { useQuery } from 'react-query'; import UserService from '../services/UserService';

export default function ProfileScreen({ route }) { const { userId } = route.params; const { data, error, isLoading } = useQuery(['userProfile', userId], () => UserService.fetchProfile(userId));

if (isLoading) return <ActivityIndicator />; if (error) return <Text>Failed to load profile</Text>;

return ( <View> <Text>Name: {data.name}</Text> <Text>Email: {data.email}</Text> </View> ); }

3. Navigation with React Navigation v6

React Navigation separates navigators, screens, and navigation containers. Combine a stack navigator for authentication flow with a bottom‑tab navigator for the main app.

tsx // src/navigation/AppNavigator.tsx import React from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import HomeScreen from '../screens/HomeScreen'; import SettingsScreen from '../screens/SettingsScreen'; import LoginScreen from '../screens/LoginScreen';

const Stack = createStackNavigator(); const Tab = createBottomTabNavigator();

function MainTabs() { return ( <Tab.Navigator> <Tab.Screen name="Home" component={HomeScreen} /> <Tab.Screen name="Settings" component={SettingsScreen} /> </Tab.Navigator> ); }

export default function AppNavigator() { return ( <NavigationContainer> <Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Screen name="Login" component={LoginScreen} /> <Stack.Screen name="Main" component={MainTabs} /> </Stack.Navigator> </NavigationContainer> ); }

Deep Linking & Type Safety

  • Define a type for route params to prevent runtime errors.
  • Use LinkingConfiguration to enable deep links (e.g., myapp://profile/123).

tsx // src/navigation/types.ts export type RootStackParamList = { Login: undefined; Main: undefined; };

export type MainTabParamList = { Home: undefined; Settings: undefined; };

4. Testing the Architecture

  • Unit test services with Jest and mock the apiClient.
  • Component test UI with React Native Testing Library, providing the ServiceProvider via context.
  • E2E test navigation using Detox to simulate real user flows.

By keeping state, navigation, and side‑effects isolated, you gain a codebase that scales from a handful of screens to a full‑featured product.

FAQs

Q1: When should I choose Redux Toolkit over React Query?

A: Use Redux Toolkit when you need a single source of truth that spans multiple unrelated screens (e.g., authentication state, user preferences). React Query shines for data fetching with built‑in caching, pagination, and background refetch, especially when the data does not need to be globally shared.

Q2: How can I minimise bridge traffic in a large React Native app?

A: Follow these guidelines:

  1. Move CPU‑intensive work to native modules or TurboModules.
  2. Batch state updates using unstable_batchedUpdates.
  3. Serialize only the necessary fields when passing objects to native code.
  4. Keep UI logic in the JavaScript thread and let the native thread handle animations via the Animated API.

Q3: What folder structure works best for team collaboration?

A: A feature‑first layout (grouping components, screens, and services by feature) improves ownership and reduces merge conflicts. Example:

text src/ ├─ features/ │ ├─ auth/ │ │ ├─ components/ │ │ ├─ screens/ │ │ └─ authSlice.ts │ └─ feed/ │ ├─ components/ │ ├─ screens/ │ └─ feedService.ts ├─ shared/ │ ├─ components/ │ └─ utils/ └─ App.tsx

This structure keeps related code together while still allowing shared utilities in a common folder.

Conclusion

Designing a robust React Native app hinges on clear separation of concerns, modular folder organization, and thoughtful state & navigation choices. By leveraging a layered architecture, encapsulating business logic in services, using Redux Toolkit or React Query for state, and configuring React Navigation with type‑safe routes, developers can mitigate bridge bottlenecks, improve performance, and streamline maintenance.

Implement the patterns shown in this guide, adopt dependency injection via context, and enforce unit/e2e testing early. The result is a codebase that scales gracefully, accelerates onboarding, and delivers a fluid user experience across iOS and Android.