React Native App Architecture
A practical guide to structuring production React Native apps for growth.
Architecture decisions that look fine at 10 screens start to hurt at 50. Here's the structure I actually use on production apps, and — more importantly — why each piece exists.
Feature-First, Not Type-First
The most common mistake I see in growing codebases is organizing by technical type — one giant `components/` folder, one giant `hooks/` folder, one giant `screens/` folder — instead of by feature. That works fine at small scale and becomes genuinely painful once a team has more than a couple of engineers working in parallel, because every feature change touches five unrelated top-level folders.
I structure by feature: each feature owns its screens, its components, its hooks, and its local state, with a small set of genuinely shared primitives (design system components, common hooks, API client) living at the app level. This keeps a feature's blast radius contained — most changes only touch one folder — and makes it obvious where new code belongs.
State: Local First, Then Reach for a Store
Not everything needs Redux. My default is: component state for anything local to a screen, React Context for state a handful of related screens need without prop-drilling, and a proper store (Redux Toolkit or Zustand depending on the team's preference) only for state that's genuinely global — auth session, user profile, feature flags, things multiple unrelated parts of the app need to read and write.
The mistake I actively avoid is putting everything in a global store 'to be safe.' That makes the store harder to reason about and makes components harder to test in isolation, for no actual benefit if the state was never going to be needed outside its own screen.
Navigation as a Contract, Not an Afterthought
I treat the navigation structure as something to design deliberately early, not something that accretes screen by screen. Typed navigation params (React Navigation with TypeScript, or Expo Router's file-based typed routes) catch an entire category of runtime bugs at compile time — a screen expecting a param that was never passed is a crash in production and a red squiggle in development.
Deep linking gets designed alongside the navigation structure, not bolted on later, because retrofitting deep links onto a navigator that wasn't built with them in mind usually means restructuring the whole navigation tree.
The API Layer Is One Thing, Not Scattered Fetch Calls
Every screen calling `fetch` directly, with its own error handling and loading state logic copy-pasted around, is a maintenance problem waiting to surface. I centralize API calls behind a typed client layer — one place that handles auth token attachment, retries, and error shape normalization — and pair it with React Query or RTK Query for caching, request deduplication, and loading/error state that components don't have to reinvent per-screen.
Native Modules Need a Release Plan From Day One
Any native module — whether third-party or custom — needs someone to own its update path before it goes in. I've seen projects get stuck because a native dependency pinned to an old React Native version blocked an upgrade for the whole app. I evaluate a native dependency's maintenance activity and New Architecture (Fabric/TurboModules) compatibility before adopting it, and I document the upgrade path for any custom native module the team writes, so it isn't tribal knowledge sitting in one person's head.
What This Buys You
None of this is architecture for its own sake. Feature-first structure means a new engineer can find and safely change one feature without reading the whole app. Deliberate state boundaries mean components stay testable. Typed navigation and a centralized API layer catch bugs before they reach a device. And a native-module release plan means a version upgrade doesn't turn into a two-week fire drill. The goal is always the same: make the next change easier than the last one, not harder.
