I've built several React Native apps over the past few years—some successful, some that needed complete rewrites. Each one taught me something new about mobile app architecture. Let me share what I've learned the hard way.
My First React Native App: What Went Wrong
My first React Native app was a disaster. I treated it like a web app. Everything was in one massive component file, state was managed with useState everywhere, and API calls were scattered throughout components.
The app worked, but it was impossible to maintain. Adding a new feature meant touching 10 different files. Debugging was a nightmare. After 6 months, I had to rewrite it.
The Architecture That Works
After that painful experience, I developed a structure that scales:
src/
screens/ # Full screen components
components/ # Reusable UI components
common/ # Buttons, inputs, etc.
features/ # Feature-specific components
services/ # API calls, business logic
hooks/ # Custom hooks for shared logic
utils/ # Helper functions
navigation/ # Navigation setup
context/ # Global state (if needed)This structure separates concerns clearly. Screens compose components, components use services, and services handle data. Simple, but it works.
State Management: Keep It Simple
I've seen developers add Redux to every React Native app. Don't. Most apps don't need it.
For small apps, React Context + useReducer is perfect. For medium apps, I use Zustand—it's simpler than Redux but more powerful than Context.
Here's my rule: If you can't explain your state management to a junior developer in 5 minutes, it's too complex.
Example from a recent project:
// services/authService.js
export const authService = {
async login(email, password) {
const response = await api.post('/auth/login', { email, password });
return response.data;
},
async logout() {
await api.post('/auth/logout');
}
};
// hooks/useAuth.js
export const useAuth = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const login = async (email, password) => {
setLoading(true);
try {
const userData = await authService.login(email, password);
setUser(userData);
} catch (error) {
throw error;
} finally {
setLoading(false);
}
};
return { user, login, loading };
};This pattern keeps business logic separate from UI. Components stay clean and testable.
Navigation: Don't Overthink It
React Navigation is powerful, but it's easy to overcomplicate. I've seen navigation files with 500+ lines of code. That's too much.
Keep navigation simple. Define your screens, set up your navigators, and move on. Don't try to handle every edge case upfront—you'll add complexity you don't need.
My navigation setup:
const Stack = createNativeStackNavigator();
function AppNavigator() {
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}That's it. No complex logic, no nested navigators unless you need them. Simple.
API Layer: The Foundation
A clean API layer makes everything easier. I create a base API service that handles authentication, error handling, and request/response transformation.
// services/api.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle errors globally
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
logout();
}
return Promise.reject(error);
}
);
export default api;Then each service uses this base API:
// services/userService.js
import api from './api';
export const userService = {
getProfile: () => api.get('/user/profile'),
updateProfile: (data) => api.put('/user/profile', data),
};This pattern makes API calls consistent and easy to test.
Performance: The Mobile Reality
Mobile devices are slower than you think. What works smoothly on your iPhone might lag on a mid-range Android.
Key optimizations:
1. Use FlatList, always. Never use ScrollView with a map. FlatList only renders visible items.
2. Optimize images. Use appropriate sizes, cache them, and lazy load below the fold.
3. Memoize expensive operations. But don't overdo it—premature optimization is still a thing.
// Good: Memoize expensive calculations
const expensiveValue = useMemo(() => {
return heavyCalculation(data);
}, [data]);
// Bad: Memoizing everything
const simpleValue = useMemo(() => data.name, [data]);Testing: Start Simple
I used to skip testing because setting it up felt overwhelming. That was a mistake.
Start with simple unit tests for your services and utilities. Don't try to test everything—test the critical paths. A few good tests are better than no tests.
What I'd Do Differently
If I could rebuild my first React Native app, I'd:
1. Start with a clear architecture from day one
2. Keep state management simple
3. Build the API layer first
4. Test as I go, not at the end
5. Profile performance early, not after users complain
The Bottom Line
React Native architecture doesn't need to be complicated. Keep it simple, separate concerns, and don't add complexity until you need it. The best architecture is the one that's easy to understand and maintain.
Focus on building features, not perfect architecture. You can always refactor later, but you can't ship features you never built.