Back to Blog

How I Structure React Native Projects in 2026

Emmanouil Athanasopoulos4 min read897 words
React NativeArchitectureTypeScriptBest Practices

The Motivation Behind the Project

After building over a dozen React Native apps — from simple utility tools to complex multi-module platforms with native Kotlin modules — I have settled on a project structure that scales well without becoming overly rigid. This article documents the patterns I use in every new project and explains why each decision was made. The goal is not to prescribe a universal standard, but to share what works for me and the reasoning behind it, so other developers can adapt the parts that make sense for their own workflows. Every pattern described here has been tested in production apps with real users, not just in tutorials or proof-of-concept demos.

Core Features and Design Goals

  • Feature-based folder organisation: instead of grouping files by type (all components in one folder, all hooks in another), I organise by feature. Each feature directory contains its own components, hooks, types, and utilities. Shared code lives in a top-level /shared directory. This makes it easy to find everything related to a feature in one place and makes features easy to delete or refactor without touching unrelated code.
  • Typed navigation with Expo Router: I use Expo Router's file-based routing for all navigation, with typed route parameters defined in a central types file. This eliminates the stringly-typed navigation calls that were a constant source of bugs in the React Navigation era. The file system becomes the single source of truth for available routes.
  • State management tiering: I use a three-tier approach to state. UI state (modals, form inputs, animation values) stays in local component state with useState or useReducer. Feature state (the current user's watchlist, a vehicle's fuel logs) lives in Zustand stores with MMKV persistence. Server state (API data that needs caching, pagination, and background refresh) is managed by React Query. Keeping these tiers separate prevents the common problem of stuffing everything into a single global store.
  • Native module patterns: for apps that need custom native functionality, I use the Expo Modules API with a consistent interface pattern: each module exposes a typed TypeScript API that mirrors the native Kotlin/Swift interface, with platform-safe stubs that return sensible defaults when the native module is unavailable (for example, in Expo Go during development).

Deep Dive: How It Works Under the Hood

The most important architectural decision I make in every project is separating data persistence from data synchronisation. In apps like Vehiclo and Media Tracker, the persistence layer (AsyncStorage or MMKV) handles reading and writing data locally, while the sync layer (Supabase, Google Drive) handles pushing and pulling data to the cloud. These two concerns are managed by different modules that communicate through a shared event bus. The persistence layer does not know or care whether the data will be synced — it just stores and retrieves. The sync layer watches for changes in the persistence layer and decides when and how to push them upstream. This separation has three major benefits: the app works perfectly offline, the sync logic can be tested independently, and swapping the cloud backend (from Supabase to Firebase, for example) does not require touching the persistence layer at all. I have used this pattern in Vehiclo (8 Supabase tables), Media Tracker (Zustand + MMKV + Supabase Realtime), and FetchIt (local-only storage), and it has held up well in all three contexts.

Technical Implementation

  • Environment configuration: I use a .env file with expo-constants for environment-specific values (API URLs, feature flags), with a typed wrapper module that validates all required variables at app startup and throws a clear error if any are missing. This catches misconfiguration immediately instead of at runtime when the missing value is first accessed.
  • Error boundary strategy: I wrap each feature's root component in an error boundary that catches rendering errors and displays a feature-specific fallback UI. This prevents a crash in one feature (say, the map view) from taking down the entire app. The error boundary also reports the error to a logging service with the feature name as context.
  • Testing approach: I write unit tests for business logic (data transformers, validators, algorithms) and integration tests for critical user flows (login, data sync, form submission). I deliberately skip unit tests for UI components because the cost-benefit ratio is poor — component tests break on every visual change and rarely catch real bugs. Instead, I rely on manual testing and occasional snapshot tests for complex layouts.
  • Performance monitoring: I instrument key user flows with timing markers using performance.now(), logging how long critical operations (app boot, data sync, screen transitions) take in production. This data surfaces performance regressions early, before users start complaining about slowness.

The Technology Stack

The patterns described here are used across apps built with Expo SDK 54–56, React Native, TypeScript, Expo Router, Zustand, React Query, MMKV, and various native modules. The architecture is framework-agnostic in principle — the same separation of concerns would apply if the UI layer were SwiftUI or Jetpack Compose instead of React Native.

Final Reflections

Project structure is not about following rules — it is about reducing the friction of daily development. The best architecture is the one where adding a new feature does not require modifying files in ten different directories, and where deleting a feature does not leave orphaned code scattered across the codebase. Every pattern in this article exists because it solved a specific problem I encountered while shipping real apps to real users.