React Native projects often start clean but collapse under their own weight around month four. A simple screen grows to 400 lines as developers stuff it with API calls, state management, and native module interactions. When push notifications, navigation, and background processes start interfering, bugs emerge that no one can reproduce consistently.
Clean architecture in React Native isn’t about folder structures or rigid layering—it’s about preserving clarity when asynchronous operations, navigation, and native modules collide. The real question isn’t where your code lives, but whether you can still reason about your application when complexity spirals.
The silent architecture killer: implicit coupling
Most React Native projects begin with a straightforward pattern: code lives where it’s first needed. API calls go in the screen handler that triggers them. State is managed in the component that displays it. Native module interactions are embedded directly in the button callbacks. This approach works during the honeymoon phase of development.
But it fails spectacularly when:
- Asynchronous operations outlive their context. A user starts a network request, navigates to another screen via a notification, and suddenly the promise resolves into a state setter that no longer exists.
- Native modules leak into UI logic. A screen directly invokes
NativeModules.Audio.start(). When iOS 17 changes audio session semantics, three unrelated screens break—not because they’re broken, but because they relied on implicit platform behavior.
- Business rules fragment across screens. Two screens implement "send a message" differently. One gets a new validation rule; the other doesn’t. The logic drifts until neither behaves correctly.
The insidious pattern looks harmless at first:
const handleSend = async () => {
const res = await api.post('/messages', input)
setMessages(prev => [...prev, res.data])
}Until this exact pattern gets rewritten slightly differently in another screen. Then another. Then another. The codebase becomes a minefield of duplicated logic waiting to explode.
Three layers, one hard boundary
Forget the textbook diagrams. The minimal sustainable structure has three components, enforced by a single rule:
The UI layer never talks directly to the data layer.
- Presentation layer: Components, screens, and hooks. Handles rendering and user interaction orchestration. Knows nothing about APIs or native modules.
- Domain layer: Pure business logic encapsulated in use cases. No React, no HTTP clients, no platform-specific code. Only algorithms and rules.
- Data layer: API clients, storage mechanisms, and native bridges. Knows about the external world and nothing else.
This isn’t academic theory. The rule creates a firewall between what changes often (the UI) and what should change rarely (the business logic). When platform behavior shifts, the UI adapts. When business rules evolve, the domain layer updates without cascading changes through screens and components.
Use cases: the boundary that matters
The transformation starts with a simple delegation shift. Instead of the handler managing the entire flow:
// Before: handler owns the entire workflow
const handleSend = async () => {
const res = await api.post('/messages', input)
setMessages(prev => [...prev, res.data])
}The screen defers to a dedicated use case:
// After: handler delegates responsibility
const handleSend = async () => {
await sendMessage.execute(input)
}The actual logic lives in the domain layer:
class SendMessage {
constructor(private repo: MessageRepository) {}
async execute(input: SendMessageInput) {
// Business validation, orchestration, error handling
return this.repo.send(input)
}
}This isn’t ceremony—it’s control. The SendMessage use case becomes the single source of truth for how messages are sent. Two screens calling it can’t drift apart because there’s only one implementation. The repository interface lives in the data layer:
interface MessageRepository {
send(input: SendMessageInput): Promise<Message>
}The UI imports the use case abstraction and calls execute(). It never touches the repository, never sees the API client, and remains blissfully unaware of platform quirks.
Where React Native exacts its hidden costs
Most clean architecture guides ignore the unique pain points of React Native development. On the web, you have UI and API. On mobile, you juggle UI, API, navigation lifecycle, native modules, background/foreground transitions, OS interruptions, and platform-specific behaviors. Mixing layers compounds these costs exponentially.
Async operations that outlive their screens create a classic React Native trap. A request starts on screen A, the user navigates to screen C via notification, and suddenly the response tries to update state that no longer exists. A use case can centralize cancellation logic, idempotency checks, and "is this caller still valid?" guards. The screen doesn’t need to know.
Native modules embedded in UI handlers create platform fragility. Calling NativeModules.Audio.start() directly ties your screen to iOS-specific audio session semantics. Wrap the module in a repository, expose a use case (StartRecording), and suddenly your UI is platform-agnostic. Platform-specific logic has one home—right where you need it when iOS changes behavior.
Authentication races become unmanageable when logic sprawls across axios interceptors, context providers, and screens. A dedicated RefreshSession use case that owns the token refresh queue transforms an impossible race condition into a manageable workflow.
Tests that stop pretending to be integration tests
The most valuable outcome isn’t code reuse—it’s testability. A clean architecture lets you verify business logic without spinning up the entire framework:
it('sends a message via the repository', async () => {
const repo = new FakeMessageRepo()
const useCase = new SendMessage(repo)
await useCase.execute({ body: 'hi' })
expect(repo.sent).toHaveLength(1)
})No render trees. No mocked native modules. No Detox flakiness. The use case runs in pure Node and completes in milliseconds. Most architecture value comes from what becomes testable—not what becomes "clean."
The architecture trap: when good patterns go bad
Even the best architecture fails when misapplied:
- Optional architecture isn’t architecture. "I’ll just call the API directly this once" is how drift begins. Either enforce boundaries everywhere or accept the consequences.
- Three layers for a two-screen app wastes effort. If your application has login and list screens only, skip the use case layer. Apply this pattern when complexity demands it—typically between the third real feature and the second engineer joining the team.
- Folders aren’t boundaries. You can have a
domain/directory but still importfetchin a screen. Directory structure documents intent. ESLint rules and code review enforce boundaries.
The upfront friction feels real. A new feature now requires touching multiple layers. But when platform changes hit or business rules evolve, the cost of maintenance drops dramatically. The architecture doesn’t clean your code—it keeps your code clean as it grows.
AI summary
React Native'de temiz mimari, uygulamalarınızı daha iyi yönetmenize ve bakımını yapmanıza yardımcı olur