Every React Native codebase I've worked on hits the same wall around month four. A screen that started at 80 lines is now 400. Half of it is useEffect chains coordinating API calls. A push notification mid-flow leaves the app in a state nobody can reproduce.
Clean architecture in React Native isn't about folders or layers. It's about whether you can still reason about your app when async, navigation, and native modules collide.
The shape that fails
The default React Native architecture is "everything lives where it's first needed." API calls land in the handler that triggers them. State sits in the screen that displays it. Native modules get called from the button that activates them. It works for the first month.
What it doesn't survive:
- Async outliving its caller. A user kicks off a request, taps a notification, lands on a different screen. The original promise resolves into a setter that no longer makes sense.
-
Native modules in the UI. A screen calls
NativeModules.Audio.start()directly. iOS 17 changes the audio session semantics. Three screens break, not one. - Auth races. A token refresh fires while three other requests are in flight. Two retry, one logs the user out, one leaks the old token.
- Drift. The same "send a message" logic lives in two screens. One gets a validation rule added. The other doesn't.
The pattern that produces all of this:
const handleSend = async () => {
const res = await api.post('/messages', input)
setMessages(prev => [...prev, res.data])
}
Nothing wrong with this code on its own. The problem is the second time it gets written, slightly differently, in another screen.
Three layers, one rule
Skip the diagram. The minimum viable framing:
- Presentation. Screens, components, hooks. Renders. Orchestrates.
-
Domain. Use cases. Pure logic. No
react, nofetch, noNativeModules. - Data. API clients, storage, native bridges. Knows about the outside world.
One rule: the UI talks to the domain, never to data directly.
That's the post. The rest is what enforcing that rule does to the bugs above.
Use cases are the boundary
The shift in code is small:
// Before: handler decides how things work
const handleSend = async () => {
const res = await api.post('/messages', input)
setMessages(prev => [...prev, res.data])
}
// After: handler delegates
const handleSend = async () => {
await sendMessage.execute(input)
}
The use case is where the logic actually lives:
class SendMessage {
constructor(private repo: MessageRepository) {}
async execute(input: SendMessageInput) {
// validation, business rules, orchestration
return this.repo.send(input)
}
}
This isn't ceremony. The point is that SendMessage is the only place anyone outside the domain learns how a message gets sent. Two screens calling it can't drift apart, because there's only one of it.
The repository is the other half:
interface MessageRepository {
send(input: SendMessageInput): Promise<Message>
}
The use case depends on the interface. The implementation lives in the data layer. The UI imports neither. It imports the use case, calls execute, and stops thinking.
Where React Native makes you pay for shortcuts
This is the part most "clean architecture" posts are silent on. Web apps have UI and API. React Native has UI, API, navigation lifecycle, native modules, background/foreground transitions, and OS-level interruptions. The cost of mixing layers compounds with each one.
Async outliving the screen. A request starts on screen A and resolves after the user is on screen C. If the resolution reaches for component-local setters, navigation refs, or context that no longer exists, you get a bug that only reproduces when someone moves fast. A use case gives you one place to attach cancellation, idempotency, or "is this caller still listening?" guards. The screen doesn't need to know.
Native modules don't belong in handlers. NativeModules.Audio.start() in a button handler ties the UI to platform behavior. Platform behavior is the part most likely to diverge between iOS and Android, between OS versions, between simulator and device. Wrap the module in a repository, expose a use case (StartRecording), and the UI is platform-agnostic. The platform-specific logic has one home, and you know where to look when iOS changes.
Auth and rehydration races. Token refresh overlapping with three in-flight requests is the canonical React Native bug. If your auth logic is split across an axios interceptor, a context provider, and a screen, the race is unfixable. There's no single thing to serialize. A RefreshSession use case that owns the queue makes it tractable. Boring, but tractable.
Tests stop pretending
The biggest practical payoff isn't reuse. It's that tests stop needing the framework.
it('sends a message via the repo', async () => {
const repo = new FakeMessageRepo()
const useCase = new SendMessage(repo)
await useCase.execute({ body: 'hi' })
expect(repo.sent).toHaveLength(1)
})
No render tree. No react-test-renderer. No mocked NativeModules. No Detox. The use case runs in pure Node and exits in milliseconds.
Most of the value of architecture is what becomes testable, not what becomes "clean."
The trap
A few ways this goes wrong:
- Optional architecture isn't architecture. "I'll just call the API directly this once" is how you end up with three places that do the same thing badly. Either the boundary is enforced or it isn't.
- Three layers for a two-screen app is waste. If your app is a login and a list, you don't need a use case layer. Apply this when the complexity earns it, usually somewhere between the third real feature and the second engineer.
-
Folders aren't boundaries. You can have a
domain/directory and still callfetchfrom a screen. The directory structure is documentation. ESLint rules and code review are enforcement.
The other cost is upfront friction. A new feature now touches three files instead of one. For a few weeks that feels worse, not better. It pays off the first time a bug reproduces only on Android, only after a notification, only when offline. You find the cause in one place instead of grepping six.
What you actually get
Clean architecture in React Native isn't a goal, and it isn't about being clean. Something will go wrong at month twelve, in a way you didn't predict. It's the bill you pay so the code you're staring at is still one you can reason about.
Top comments (0)