← Back to all blogs
React Native App Architecture – Real World Example
Sat Feb 28 20268 minIntermediate

React Native App Architecture – Real World Example

A comprehensive guide to building a scalable React Native architecture with detailed code examples, a real‑world e‑commerce app walkthrough, FAQs, and best‑practice recommendations.

#react native#mobile architecture#best practices#state management#navigation

Introduction

Why Architecture Matters in React Native

In mobile development, the line between a prototype and a production‑ready app is often drawn by architecture. A well‑defined structure reduces technical debt, accelerates feature delivery, and simplifies onboarding for new developers. React Native, with its JavaScript core and native bridges, gives you flexibility, but that flexibility can become a source of chaos if the project grows without a clear layout.

Benefits of a Layered Approach

  • Separation of concerns - UI, business logic, and data access live in distinct folders.
  • Testability - Pure functions in the domain layer are easy to unit‑test.
  • Scalability - Adding new screens or services does not require refactoring existing modules.
  • Team autonomy - Front‑end and back‑end developers can work in parallel on presentation and data layers.

The following sections walk through a proven layered architecture, illustrate it with a real‑world e‑commerce app, and provide hands‑on code snippets you can copy into your own project.

Core Principles of React Native Architecture

The Four‑Layer Model

A production‑grade React Native app can be visualised as four concentric layers:

  1. Presentation Layer - React components, screens, and UI widgets.
  2. Domain Layer - Business rules, use‑case classes, and data transformations.
  3. Data Layer - Repositories, API clients, and local storage.
  4. Infrastructure Layer - Dependency injection, navigation configuration, and app entry point.

Each layer communicates only with its immediate neighbour, enforcing a unidirectional dependency flow.

Presentation Layer Details

  • Uses functional components with hooks (useEffect, useState).
  • Relies on a state‑management library (Redux Toolkit, MobX, or Zustand) for global state.
  • UI components are dumb; they receive props and dispatch actions.

tsx // src/presentation/screens/ProductListScreen.tsx import React from 'react'; import {FlatList, Text, View, TouchableOpacity} from 'react-native'; import {useSelector, useDispatch} from 'react-redux'; import {fetchProducts} from '../../domain/usecases/productUsecase';

export const ProductListScreen = () => { const dispatch = useDispatch(); const {products, loading, error} = useSelector(state => state.product);

React.useEffect(() => { dispatch(fetchProducts()); }, [dispatch]);

if (loading) return <Text>Loading…</Text>; if (error) return <Text>{error}</Text>;

return ( <FlatList data={products} keyExtractor={item => item.id} renderItem={({item}) => ( <TouchableOpacity onPress={() => {/* navigate to details */}}> <View style={{padding: 10}}> <Text>{item.title}</Text> <Text>${item.price}</Text> </View> </TouchableOpacity> )} /> ); };

Domain Layer Details

  • Contains use‑case functions that orchestrate repository calls.
  • Is pure JavaScript/TypeScript; no React imports.
  • Returns typed results (e.g., Result<Product[]>).

ts // src/domain/usecases/productUsecase.ts import {productRepository} from '../../data/repositories/productRepository'; import {createAsyncThunk} from '@reduxjs/toolkit';

export const fetchProducts = createAsyncThunk('product/fetch', async (_, thunkAPI) => { try { const products = await productRepository.getAll(); return products; } catch (err) { return thunkAPI.rejectWithValue('Unable to load products'); } });

Data Layer Details

  • Implements repositories that hide API clients, SQLite, or async storage.
  • Each repository follows an interface defined in the domain layer, enabling mock implementations for testing.

ts // src/data/repositories/productRepository.ts import {apiClient} from '../services/apiClient'; import {Product} from '../../domain/models/Product';

export const productRepository = { async getAll(): Promise<Product[]> { const response = await apiClient.get('/products'); return response.data.map(item => ({ id: item.id, title: item.title, price: item.price, imageUrl: item.image, })); }, // Additional methods like getById, create, update, delete can be added here. };

Infrastructure Layer Details

  • Sets up dependency injection (using inversify or a simple context provider).
  • Configures React Navigation and registers screens.
  • Contains the App.tsx entry point.

tsx // src/infrastructure/App.tsx import React from 'react'; import {NavigationContainer} from '@react-navigation/native'; import {createStackNavigator} from '@react-navigation/stack'; import {ProductListScreen} from '../presentation/screens/ProductListScreen'; import {ProductDetailScreen} from '../presentation/screens/ProductDetailScreen';

const Stack = createStackNavigator();

export const App = () => ( <NavigationContainer> <Stack.Navigator initialRouteName="ProductList"> <Stack.Screen name="ProductList" component={ProductListScreen} /> <Stack.Screen name="ProductDetail" component={ProductDetailScreen} /> </Stack.Navigator> </NavigationContainer> );

By keeping each layer independent, the codebase stays maintainable, testable, and ready for future platforms (e.g., React Native Web or native iOS/Android modules).

Real‑World Example: Scalable E‑Commerce App

Project Layout Overview

Below is a practical folder structure derived from the four‑layer model. The layout mirrors many open‑source React Native starter kits but adds explicit domain and data separation.

my‑shop-app/ ├─ src/ │ ├─ infrastructure/ # Navigation, DI, entry points │ │ ├─ App.tsx │ │ └─ diContainer.ts │ ├─ presentation/ # UI components and screens │ │ ├─ components/ │ │ │ ├─ Card.tsx │ │ │ └─ Loader.tsx │ │ └─ screens/ │ │ ├─ ProductListScreen.tsx │ │ └─ ProductDetailScreen.tsx │ ├─ domain/ # Business logic, models, use‑cases │ │ ├─ models/ │ │ │ └─ Product.ts │ │ └─ usecases/ │ │ └─ productUsecase.ts │ └─ data/ # Repositories, API clients, storage │ ├─ services/ │ │ └─ apiClient.ts │ └─ repositories/ │ └─ productRepository.ts ├─ ios/ # Native iOS project (generated by React Native CLI) ├─ android/ # Native Android project └─ package.

Dependency Injection with a Simple Container

A lightweight DI container keeps the implementation details of the data layer away from the domain layer.

ts // src/infrastructure/diContainer.ts import {productRepository} from '../data/repositories/productRepository'; import {ProductRepository} from '../domain/repositories/ProductRepository';

export const container = { productRepository: productRepository as ProductRepository, };

The domain use‑case imports the repository via the container, which can be swapped for a mock during tests.

ts // src/domain/usecases/productUsecase.ts (updated) import {container} from '../../infrastructure/diContainer'; import {createAsyncThunk} from '@reduxjs/toolkit';

export const fetchProducts = createAsyncThunk('product/fetch', async (_, thunkAPI) => { try { const products = await container.productRepository.getAll(); return products; } catch (e) { return thunkAPI.rejectWithValue('Network error'); } });

State Management with Redux Toolkit

A slice encapsulates product state, selectors, and reducers in a single file.

ts // src/presentation/store/productSlice.ts import {createSlice, PayloadAction} from '@reduxjs/toolkit'; import {Product} from '../../domain/models/Product'; import {fetchProducts} from '../../domain/usecases/productUsecase';

interface ProductState { products: Product[]; loading: boolean; error?: string; }

const initialState: ProductState = { products: [], loading: false, };

const productSlice = createSlice({ name: 'product', initialState, reducers: {}, extraReducers: builder => { builder .addCase(fetchProducts.pending, state => { state.loading = true; state.error = undefined; }) .addCase(fetchProducts.fulfilled, (state, action: PayloadAction<Product[]>) => { state.loading = false; state.products = action.payload; }) .addCase(fetchProducts.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; }); }, });

export default productSlice.reducer;

The slice is then added to the store root reducer.

ts // src/presentation/store/index.ts import {configureStore} from '@reduxjs/toolkit'; import productReducer from './productSlice';

export const store = configureStore({ reducer: { product: productReducer, }, });

export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;

Navigation with Type‑Safe Params

React Navigation v6 supports TypeScript‑driven route definitions. This eliminates runtime navigation errors.

ts // src/infrastructure/navigation/types.ts export type RootStackParamList = { ProductList: undefined; ProductDetail: {productId: string}; };

tsx // src/infrastructure/App.tsx (updated) import {createStackNavigator} from '@react-navigation/stack'; import {RootStackParamList} from './navigation/types';

const Stack = createStackNavigator<RootStackParamList>();

// ...inside App component <Stack.Screen name="ProductDetail" component={ProductDetailScreen} />

Running the Example

  1. Install dependencies - yarn install or npm i.
  2. Start Metro - npx react-native start.
  3. Run on iOS - npx react-native run-ios.
  4. Run on Android - npx react-native run-android.

The app launches with a product list fetched from a mock API (https://fakestoreapi.com/products). Selecting a product navigates to a detail screen that pulls the same product from the Redux store, demonstrating a single source of truth.

FAQs

Frequently Asked Questions

1️⃣ How does this architecture handle code sharing between iOS, Android, and Web?

The layered approach isolates business logic (domain and data layers) from the UI. Those layers are pure TypeScript and can be imported by React Native Web or even a Node.js backend with minimal changes. Only the presentation layer (screens and native UI components) needs platform‑specific adjustments.

2️⃣ When should I choose Redux Toolkit over Context API or Zustand?

  • Use Redux Toolkit when you need a predictable global state, time‑travel debugging, and strong tooling (Redux DevTools).
  • Opt for Context API for small apps with limited shared state.
  • Choose Zustand if you prefer a lightweight store with minimal boilerplate. The architecture presented works with any of these; you simply replace the store implementation and adjust the selectors in the presentation layer.

3️⃣ Can I replace the API client with GraphQL without breaking the architecture?

Absolutely. The API client lives in the data/services folder and implements a thin wrapper (apiClient). Swapping a REST‑based axios instance for a GraphQL client (e.g., urql or Apollo) only requires changes inside that service and possibly the repository mapping. The domain layer continues to receive the same typed entities, keeping the rest of the code untouched.

Conclusion

Bringing It All Together

A disciplined architecture turns a React Native codebase from a collection of screens into a maintainable product. By embracing the four‑layer model, separating concerns, and leveraging TypeScript for type safety, developers gain:

  • Predictable data flow (Redux Toolkit + async thunks)
  • Easy testing (mock repositories via the DI container)
  • Scalability (adding new features without entangling existing modules)
  • Cross‑platform adaptability (shared domain/data layers for Web, iOS, Android)

The real‑world e‑commerce example demonstrates how these concepts translate into actual folders, components, and code snippets you can run today. Implement the structure, iterate on your own business rules, and watch your app evolve from a prototype to a production‑grade product with confidence.