Introduction
Why Architecture Matters in React Native
In the fast‑moving world of mobile development, React Native has become a go‑to framework for building cross‑platform experiences. However, many teams treat a new project as a collection of screens stitched together with quick‑and‑dirty code. That approach works for a prototype, but it quickly crumbles as the app grows, features multiply, and multiple engineers collaborate.
The Cost of Poor Structure
A monolithic codebase leads to:
- Long build times because changes trigger full recompilation.
- Hard‑to‑debug bugs caused by tangled dependencies.
- Reduced reusability of components across platforms or future projects.
By investing in a solid architecture from day one, you gain maintainability, testability, and the ability to add features without fear of regression. This guide walks you through the essential layers, patterns, and tooling choices that enable a robust React Native app.
Who Is This Guide For?
If you have built a few RN apps and feel the pressure of scaling, or you are a senior developer tasked with standardizing a codebase, this guide provides a structured roadmap. Beginners can still benefit from the high‑level concepts, but the examples assume familiarity with JavaScript/TypeScript, React components, and basic React Navigation.
Fundamental Building Blocks
Core Layers of a React Native App
A well‑architected RN application can be visualized as three concentric layers: Presentation, Business Logic, and Data. Each layer has a clear responsibility and communicates with adjacent layers through interfaces, not direct imports.
Presentation Layer
This is where UI components live-screens, reusable widgets, and theme definitions. The rule is simple: no business logic should be embedded in a component. Instead, components receive data via props and expose callbacks for user actions.
tsx // src/presentation/components/UserCard.tsx import React from 'react'; import { View, Text, TouchableOpacity } from 'react-native';
interface Props { name: string; onPress: () => void; }
export const UserCard: React.FC<Props> = ({ name, onPress }) => ( <TouchableOpacity onPress={onPress}> <View style={{ padding: 12, backgroundColor: '#fff' }}> <Text>{name}</Text> </View> </TouchableOpacity> ); );
Notice the component receives name and onPress from the parent, keeping it pure and testable.
Business Logic Layer
Often called the Domain or Service layer, this encapsulates use‑cases, validation, and interaction with the data layer. A typical pattern is the Feature Slice - a folder that groups a use‑case, its reducer (if using Redux), and associated TypeScript types.
ts // src/domain/users/getUserById.ts import { UserRepository } from '../repositories/UserRepository';
export const getUserById = async (id: string) => { const user = await UserRepository.findById(id); if (!user) throw new Error('User not found'); return user; };
The domain layer knows what to do, not how the UI displays it.
Data Layer
All external interactions-REST APIs, GraphQL, local storage, or device APIs-belong here. By isolating these concerns, you can swap a network client or introduce offline caching without touching UI or business code.
ts // src/data/repositories/UserRepository.ts import axios from 'axios'; import { User } from '../../domain/models/User';
export const UserRepository = {
async findById(id: string): Promise<User | null> {
const response = await axios.get(/users/${id});
return response.data ?? null;
},
// additional CRUD methods …
};
Communication Flow
- Component dispatches an action or calls a hook.
- Hook invokes a use‑case from the domain layer.
- Use‑case calls the repository in the data layer.
- Result bubbles back to the component via state (e.g., Redux or React Query).
By respecting these boundaries, you achieve a unidirectional data flow that is easier to reason about and unit test.
Scalable Architecture Patterns
Feature‑Based vs. Clean Architecture
Two dominant philosophies shape modern RN projects: Feature‑Based (or “slices”) and Clean (or Hexagonal) Architecture. Both aim for separation of concerns, but they differ in granularity.
Feature Slices
In a feature‑centric layout, each functional area-authentication, profile, shopping cart-has its own folder containing UI, state, and services.
src/ features/ auth/ presentation/ domain/ data/ cart/ presentation/ domain/ data/
Pros:
- Discoverability: All code for a feature lives together.
- Team autonomy: Teams can own a slice end‑to‑end.
Cons:
- Duplication risk when multiple features need the same utility; you must extract shared modules.
Clean (Hexagonal) Architecture
Clean Architecture introduces concentric circles: Entities → Use Cases → Interface Adapters → Frameworks. The inner circles never depend on outer ones, enforcing strict dependency direction.
src/ core/ entities/ usecases/ adapters/ repositories/ presenters/ infrastructure/ api/ storage/
Pros:
- Testability: Core logic has no external dependencies, making pure unit tests trivial.
- Flexibility: Swap a repository implementation (e.g., from REST to GraphQL) without touching use‑cases.
Cons:
- Initial overhead: More folders and indirection can feel heavy for small apps.
Choosing the Right Pattern
- Start‑ups / MVPs: Feature slices win because they are quick to set up.
- Enterprise / Long‑term products: Clean Architecture pays off with maintainability.
Both can coexist: core business logic lives in core/, while each feature slice imports that core.
Dependency Injection in React Native
Dependency Injection (DI) decouples class creation from usage. In RN, you can implement DI with simple factories or use libraries like inversify.
ts // src/infrastructure/di/container.ts import 'reflect-metadata'; import { Container } from 'inversify'; import { IUserRepository } from '../../domain/repositories/IUserRepository'; import { UserRepository } from '../repositories/UserRepository';
export const diContainer = new Container(); diContainer.bind<IUserRepository>('IUserRepository').to(UserRepository);
Components retrieve dependencies through hooks:
tsx // src/presentation/screens/ProfileScreen.tsx import { useInjection } from '../hooks/useInjection'; import { IUserRepository } from '../../domain/repositories/IUserRepository';
export const ProfileScreen = () => { const userRepo = useInjection<IUserRepository>('IUserRepository'); // use userRepo to fetch data … };
DI makes testing effortless-swap the real repository with a mock implementation in your test suite.
State Management and Navigation Strategy
Choosing the Right State Library
React Native offers several mature state‑management options. Picking the correct tool hinges on the app’s complexity, team familiarity, and performance goals.
Redux Toolkit (RTK)
RTK simplifies Redux boilerplate with createSlice, configureStore, and built‑in Immer support.
ts // src/state/cartSlice.ts import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { CartItem } from '../domain/models/CartItem';
interface CartState { items: CartItem[]; }
const initialState: CartState = { items: [] };
const cartSlice = createSlice({ name: 'cart', initialState, reducers: { addItem: (state, action: PayloadAction<CartItem>) => { state.items.push(action.payload); }, removeItem: (state, action: PayloadAction<string>) => { state.items = state.items.filter(i => i.id !== action.payload); }, }, });
export const { addItem, removeItem } = cartSlice.actions; export default cartSlice.reducer;
When to use: Large apps with many global states, strict debugging needs, and existing Redux expertise.
MobX
MobX leverages observable state and reacts automatically to changes.
ts // src/state/CartStore.ts import { makeAutoObservable } from 'mobx'; import { CartItem } from '../domain/models/CartItem';
class CartStore { items: CartItem[] = [];
constructor() { makeAutoObservable(this); }
add(item: CartItem) { this.items.push(item); }
remove(id: string) { this.items = this.items.filter(i => i.id !== id); } }
export const cartStore = new CartStore();
When to use: Apps that favor minimal boilerplate and rely heavily on reactive UI updates.
Recoil
Recoil provides atom‑based state with fine‑grained selectors, ideal for moderate‑size apps.
ts // src/state/atoms.ts import { atom } from 'recoil';
export const cartItemsState = atom<{ id: string; qty: number }[]>({ key: 'cartItems', default: [], });
When to use: Projects that need simple global state without the ceremony of Redux.
Navigation - React Navigation Deep Dive
React Navigation remains the de‑facto library for routing in RN. A robust navigation stack includes Stack, Tab, and Drawer navigators, combined with deep linking for external URL handling.
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 '../features/home/presentation/HomeScreen'; import ProfileScreen from '../features/profile/presentation/ProfileScreen'; import SettingsScreen from '../features/settings/presentation/SettingsScreen';
const Stack = createStackNavigator(); const Tab = createBottomTabNavigator();
function MainTabs() { return ( <Tab.Navigator> <Tab.Screen name="Home" component={HomeScreen} /> <Tab.Screen name="Profile" component={ProfileScreen} /> <Tab.Screen name="Settings" component={SettingsScreen} /> </Tab.Navigator> ); }
export default function AppNavigator() { return ( <NavigationContainer linking={/* deep link config /}> <Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Screen name="Main" component={MainTabs} /> {/ other modal screens */} </Stack.Navigator> </NavigationContainer> ); }
Deep Linking Configuration
ts // src/navigation/linking.ts export const linking = { prefixes: ['myapp://', 'https://myapp.com'], config: { screens: { Main: { screens: { Home: 'home', Profile: 'user/:id', // dynamic route Settings: 'settings', }, }, }, }, };
Add linking={linking} to NavigationContainer. This enables opening a specific user profile from an email or web URL.
Persisting State Across Sessions
State persistence is essential for a seamless user experience. Combining Redux Persist (for Redux) or AsyncStorage with React Query caches can keep auth tokens, theme preferences, and offline data ready on launch.
ts // src/state/store.ts import { configureStore } from '@reduxjs/toolkit'; import cartReducer from './cartSlice'; import { persistStore, persistReducer } from 'redux-persist'; import AsyncStorage from '@react-native-async-storage/async-storage';
const persistConfig = { key: 'root', storage: AsyncStorage, whitelist: ['cart'], // only cart will be persisted };
const persistedReducer = persistReducer(persistConfig, cartReducer);
export const store = configureStore({ reducer: { cart: persistedReducer }, }); export const persistor = persistStore(store);
By integrating persistence at the store level, the UI layer stays unaware of storage mechanics.
FAQs
Frequently Asked Questions
1. Should I use TypeScript for React Native architecture?
Yes. TypeScript provides static typing, which is invaluable when defining contracts between layers (e.g., repository interfaces). It catches mismatched data shapes early, reduces runtime errors, and improves IDE autocomplete across large codebases.
2. How do I test the business logic without a device or emulator?
Because the domain layer has no UI dependencies, you can write plain Jest unit tests. Mock the repository interfaces and verify use‑case outcomes.
ts // tests/domain/getUserById.test.ts import { getUserById } from '../../src/domain/getUserById'; import { UserRepository } from '../../src/data/repositories/UserRepository';
jest.mock('../../src/data/repositories/UserRepository');
const mockUser = { id: '1', name: 'Alice' }; (UserRepository.findById as jest.Mock).mockResolvedValue(mockUser);
test('returns user when found', async () => { const result = await getUserById('1'); expect(result).toEqual(mockUser); });
3. When is it appropriate to mix Redux Toolkit with React Query?
Use Redux (or another global store) for application‑wide state such as authentication status, UI preferences, or cart contents. Use React Query for server‑state-data fetched from APIs that benefits from caching, background refetching, and automatic retries. This separation avoids bloating the global store with transient data.
Conclusion
Bringing It All Together
Designing a React Native app with a disciplined architecture isn’t a luxury-it's a necessity for long‑term success. By separating the presentation, business logic, and data layers, you achieve a clean, testable codebase. Selecting a pattern-feature slices for speed or Clean Architecture for rigor-depends on project scope and team maturity.
State management and navigation are the veins that carry data throughout the app. Pair Redux Toolkit, MobX, or Recoil with React Navigation and deep linking to deliver smooth, predictable experiences. Integrate dependency injection and persistence mechanisms to keep your code decoupled and resilient.
Finally, remember that architecture evolves. Start with a pragmatic baseline, monitor pain points, and refactor toward greater modularity as the product matures. Armed with the concepts, code snippets, and best practices in this guide, you’re ready to build React Native applications that scale, perform, and delight users for years to come.
