← Back to Lessons

Modular Architecture in React Native CLI with TypeScript

What does "modular architecture" mean?
Monolithic vs Modular: a simple contrast

When you started working with React Native, everything seemed manageable. A couple of screens, some components, maybe a form or two. Everything lived happily in a screens folder, another components folder, and that was it. It worked. But then the project grew. Suddenly you had 20 screens, 50 components, authentication logic mixed with profile logic, and every time you needed to change something, you spent 10 minutes looking for where you had put that file.

In this article we're going to talk about modular architecture: what it is, why it matters, and how to implement it in React Native CLI with TypeScript in a way that actually makes sense.

What does "modular architecture" mean?

Imagine you're organizing your house. You can have everything piled up in one giant room; clothes, books, dishes, tools... or you can have separate rooms, kitchen, bedroom, study, each with their own things and their own function.

Modular architecture is exactly that, but for your code. Instead of having everything mixed in generic folders like screens or components, you organize your application by features or functionalities. Each feature is like its own mini-application within the large application.

For example:

  • Everything related to authentication (login, registration, password recovery) lives in an auth module
  • Everything related to the user profile lives in a profile module
  • The dashboard with its charts and statistics has its own module

Each module contains its own screens, components, hooks, and state logic. It's self-contained and doesn't unnecessarily depend on other modules.

Monolithic vs Modular: a simple contrast

Monolithic approach (what you do at the beginning)

1/src 2 /screens 3 LoginScreen.tsx 4 RegisterScreen.tsx 5 ProfileScreen.tsx 6 DashboardScreen.tsx 7 SettingsScreen.tsx 8 // ... 20 more screens 9 /components 10 Button.tsx 11 Input.tsx 12 ProfileHeader.tsx 13 DashboardCard.tsx 14 // ... 50 more components 15 /hooks 16 useAuth.ts 17 useProfile.ts 18 // ... everything mixed up

This works when it's just you and you have 5 screens. But when the project grows:

  • You don't know which components belong to which screen
  • The hooks are all mixed up
  • Changing something in "auth" requires touching files in 4 different folders
  • Teamwork becomes chaos: who is editing what?

Modular approach (scalable and organized)

1/src 2 /features 3 /auth 4 /screens 5 LoginScreen.tsx 6 RegisterScreen.tsx 7 /components 8 LoginForm.tsx 9 SocialLoginButtons.tsx 10 /hooks 11 useAuth.ts 12 /store 13 authStore.ts 14 /navigation 15 AuthNavigator.tsx 16 index.ts 17 /profile 18 /screens 19 ProfileScreen.tsx 20 /components 21 ProfileHeader.tsx 22 ProfileStats.tsx 23 /hooks 24 useProfile.ts 25 index.ts 26 /dashboard 27 // ... its own structure 28 /components 29 // Shared components (Button, Input, Card) 30 /hooks 31 // Shared hooks (useKeyboard, useDebounce) 32 /store 33 // Global store if using Zustand, Redux, etc. 34 /navigation 35 RootNavigator.tsx 36 /utils 37 /types

Building a modular structure step by step

Let's create a practical example. Suppose you're building a fitness app with authentication, user profile, and a statistics dashboard.

1. Basic structure of a module

Each feature has this basic structure:

1/features 2 /auth 3 /screens # Screens of this module 4 /components # Components specific to this module 5 /hooks # Hooks specific to this module 6 /store # Local state of the module 7 /types # TypeScript types of the module 8 /utils # Specific utilities 9 index.ts # Public exports of the module

The index.ts file is crucial: it controls what your module exports. Think of it as the module's entrance door.

1// src/features/auth/index.ts 2 3export { LoginScreen, RegisterScreen } from './screens'; 4export { useAuth } from './hooks/useAuth'; 5export { authStore } from './store/authStore'; 6export type { User, AuthState } from './types'; 7 8// Internal components are NOT exported 9// This maintains module encapsulation

2. Example: Authentication module

Let's build a complete but simple authentication module.

Store with Zustand:

1// src/features/auth/store/authStore.ts 2 3import { create } from 'zustand'; 4import type { User } from '../types'; 5 6interface AuthState { 7 user: User | null; 8 isAuthenticated: boolean; 9 isLoading: boolean; 10 login: (email: string, password: string) => Promise<void>; 11 logout: () => void; 12} 13 14export const useAuthStore = create<AuthState>((set) => ({ 15 user: null, 16 isAuthenticated: false, 17 isLoading: false, 18 19 login: async (email, password) => { 20 set({ isLoading: true }); 21 22 try { 23 // Your API call would go here 24 const response = await fetch('https://example.com/login', { 25 method: 'POST', 26 headers: { 'Content-Type': 'application/json' }, 27 body: JSON.stringify({ email, password }), 28 }); 29 30 const user = await response.json(); 31 32 set({ 33 user, 34 isAuthenticated: true, 35 isLoading: false 36 }); 37 } catch (error) { 38 set({ isLoading: false }); 39 throw error; 40 } 41 }, 42 43 logout: () => { 44 set({ 45 user: null, 46 isAuthenticated: false 47 }); 48 }, 49}));

Custom hook:

1// src/features/auth/hooks/useAuth.ts 2 3import { useAuthStore } from '../store/authStore'; 4 5/** 6 * Hook that encapsulates authentication logic. 7 * Other modules can use this hook without knowing 8 * the internal details of the store. 9 */ 10export const useAuth = () => { 11 const { user, isAuthenticated, isLoading, login, logout } = useAuthStore(); 12 13 // You could add additional logic here 14 const isAdmin = user?.role === 'admin'; 15 16 return { 17 user, 18 isAuthenticated, 19 isLoading, 20 isAdmin, 21 login, 22 logout, 23 }; 24};

Login Screen:

1// src/features/auth/screens/LoginScreen.tsx 2 3import React, { useState } from 'react'; 4import { View, StyleSheet } from 'react-native'; 5import { useAuth } from '../hooks/useAuth'; 6import { LoginForm } from '../components/LoginForm'; 7 8export const LoginScreen = () => { 9 const { login, isLoading } = useAuth(); 10 const [error, setError] = useState<string | null>(null); 11 12 const handleLogin = async (email: string, password: string) => { 13 try { 14 setError(null); 15 await login(email, password); 16 // Navigation is handled automatically when isAuthenticated changes 17 } catch (err) { 18 setError('Invalid credentials'); 19 } 20 }; 21 22 return ( 23 <View style={styles.container}> 24 <LoginForm 25 onSubmit={handleLogin} 26 isLoading={isLoading} 27 error={error} 28 /> 29 </View> 30 ); 31}; 32 33const styles = StyleSheet.create({ 34 container: { 35 flex: 1, 36 justifyContent: 'center', 37 padding: 20, 38 }, 39});

3. Communication between modules

Here's where things get interesting. How does the profile module access user information that's in auth?

The golden rule: use public exports

1// src/features/profile/screens/ProfileScreen.tsx 2 3import React from 'react'; 4import { View, Text, StyleSheet } from 'react-native'; 5// ✅ Import from the auth module's index.ts 6import { useAuth } from '../../auth'; 7 8export const ProfileScreen = () => { 9 // Use the public hook that the auth module exports 10 const { user } = useAuth(); 11 12 if (!user) { 13 return <Text>Loading...</Text>; 14 } 15 16 return ( 17 <View style={styles.container}> 18 <Text style={styles.name}>{user.name}</Text> 19 <Text style={styles.email}>{user.email}</Text> 20 </View> 21 ); 22}; 23 24const styles = StyleSheet.create({ 25 container: { 26 flex: 1, 27 padding: 20, 28 }, 29 name: { 30 fontSize: 24, 31 fontWeight: 'bold', 32 marginBottom: 8, 33 }, 34 email: { 35 fontSize: 16, 36 color: '#666', 37 }, 38});

What you should NOT do:

1// ❌ NEVER import directly from internal files 2import { useAuthStore } from '../../auth/store/authStore'; 3 4// ✅ ALWAYS use public exports 5import { useAuth } from '../../auth';

Why? Because if tomorrow you decide to change Zustand to Redux in the auth module, you only need to update the auth module and its useAuth hook. All other modules keep working without changes.

Shared components vs module components

This is a question that comes up constantly: where do I put my components? Think of LEGO. You have specific pieces (like a Stormtrooper's head that you only use in Star Wars sets) and generic pieces (like basic blocks that you use in any construction).

Module components (specific):

1// src/features/auth/components/LoginForm.tsx 2// Only used in the auth module

Shared components (generic):

1// src/components/Button.tsx 2// Used in multiple modules 3 4import React from 'react'; 5import { TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native'; 6 7interface ButtonProps { 8 title: string; 9 onPress: () => void; 10 isLoading?: boolean; 11 variant?: 'primary' | 'secondary'; 12} 13 14export const Button: React.FC<ButtonProps> = ({ 15 title, 16 onPress, 17 isLoading, 18 variant = 'primary' 19}) => { 20 return ( 21 <TouchableOpacity 22 style={[styles.button, styles[variant]]} 23 onPress={onPress} 24 disabled={isLoading} 25 > 26 {isLoading ? ( 27 <ActivityIndicator color="white" /> 28 ) : ( 29 <Text style={styles.text}>{title}</Text> 30 )} 31 </TouchableOpacity> 32 ); 33}; 34 35const styles = StyleSheet.create({ 36 button: { 37 padding: 16, 38 borderRadius: 8, 39 alignItems: 'center', 40 }, 41 primary: { 42 backgroundColor: '#007AFF', 43 }, 44 secondary: { 45 backgroundColor: '#8E8E93', 46 }, 47 text: { 48 color: 'white', 49 fontSize: 16, 50 fontWeight: '600', 51 }, 52});

Modular navigation

Each module can have its own navigator. For example, the auth module could have a stack navigator with login and registration screens:

1// src/features/auth/navigation/AuthNavigator.tsx 2 3import React from 'react'; 4import { createNativeStackNavigator } from '@react-navigation/native-stack'; 5import { LoginScreen } from '../screens/LoginScreen'; 6import { RegisterScreen } from '../screens/RegisterScreen'; 7 8const Stack = createNativeStackNavigator(); 9 10export const AuthNavigator = () => { 11 return ( 12 <Stack.Navigator screenOptions={{ headerShown: false }}> 13 <Stack.Screen name="Login" component={LoginScreen} /> 14 <Stack.Screen name="Register" component={RegisterScreen} /> 15 </Stack.Navigator> 16 ); 17};

Then, in your main navigator:

1// src/navigation/RootNavigator.tsx 2 3import React from 'react'; 4import { NavigationContainer } from '@react-navigation/native'; 5import { createNativeStackNavigator } from '@react-navigation/native-stack'; 6import { useAuth } from '../features/auth'; 7import { AuthNavigator } from '../features/auth/navigation/AuthNavigator'; 8import { MainTabNavigator } from './MainTabNavigator'; 9 10const Stack = createNativeStackNavigator(); 11 12export const RootNavigator = () => { 13 const { isAuthenticated } = useAuth(); 14 15 return ( 16 <NavigationContainer> 17 <Stack.Navigator screenOptions={{ headerShown: false }}> 18 {isAuthenticated ? ( 19 <Stack.Screen name="Main" component={MainTabNavigator} /> 20 ) : ( 21 <Stack.Screen name="Auth" component={AuthNavigator} /> 22 )} 23 </Stack.Navigator> 24 </NavigationContainer> 25 ); 26};

Global store vs module store

Here's a practical question: when to use a global store and when to use a store per module?

  • Module store: When state only matters within that module. For example: Form state within a feature, temporary screen data, or module-specific UI state.

  • Global store: When multiple modules need to access the same information. For example: Authenticated user data (multiple modules need it), app theme (dark/light mode), general configuration, or shared data cache.

Example of simple global store:

1// src/store/appStore.ts 2 3import { create } from 'zustand'; 4 5interface AppState { 6 theme: 'light' | 'dark'; 7 setTheme: (theme: 'light' | 'dark') => void; 8} 9 10export const useAppStore = create<AppState>((set) => ({ 11 theme: 'light', 12 setTheme: (theme) => set({ theme }), 13}));

TypeScript: clean and modular typing

A huge advantage of this architecture is that you can type everything very clearly:

1// src/features/auth/types/index.ts 2 3export interface User { 4 id: string; 5 name: string; 6 email: string; 7 role: 'user' | 'admin'; 8} 9 10export interface AuthState { 11 user: User | null; 12 isAuthenticated: boolean; 13 isLoading: boolean; 14} 15 16export interface LoginCredentials { 17 email: string; 18 password: string; 19}

And then export only what's necessary:

1// src/features/auth/index.ts 2 3export type { User, AuthState } from './types'; 4// Don't export LoginCredentials because it's internal to the module

This gives you encapsulation: other modules only see what they need to see.

Real benefits you'll experience

After adopting this architecture, you'll notice tangible changes:

1. Faster onboarding When someone new joins the team, you tell them: "You'll be working on the payments module, everything is in /features/payments". They don't have to search for files in 10 different folders.

2. Fewer Git conflicts If you work on auth and your colleague on dashboard, you rarely touch the same files. Merge conflicts drop dramatically.

3. Safer refactors You can completely rewrite the profile module without touching anything else. If the public exports remain the same, the rest of the app doesn't even notice.

4. Easier testing Each module can be tested in isolation. You don't need to mock half the application to test one functionality.

5. Mental clarity When you open your editor, you see a structure that makes sense. There's no "where the hell did I put this?" moment that steals 15 minutes of your day.

Conclusion

Modular architecture isn't a new or revolutionary concept. It's simply a way to organize your code that respects how your brain works: grouping related things and separating different things. At first it might seem like more work. "Why create so many folders?" But when your app grows, when you work in a team, when you have to return to a project after 6 months, this initial investment pays for itself a thousand times over.