Building a Robust React Native Foundation for Complex Apps (Part 1: Architecture & Developer Workflow)

Thomas EjembiThomas Ejembi
10 min read

There's a unique thrill in bringing a mobile app idea to life, from initial development to deployment. But anyone who's navigated the real-world complexities of app stores (and their review teams!) knows that the road to a production-ready application is rarely straightforward. That initial joy can quickly fade if your app isn't built on solid ground.

Many developers, myself included, have learned the hard way that a rushed or poorly planned initial setup is a major source of future headaches—leading to brittle code, difficult debugging, and frustrating user experiences.

This article shares my approach to building a robust foundation for a new React Native pet management app called Pawfect. I'll explain the crucial decisions I made regarding architecture, testing, development workflow and CI/CD, demonstrating how a strategic upfront setup can save you countless hours, prevent future regressions, and ultimately lead to a more successful and maintainable app in the long run.

Architecture

My vision for "Pawfect" is an app packed with distinct and powerful features, each designed to empower pet owners. Given this complexity, selecting the right architectural foundation was paramount.

Why This Approach?

When embarking on a project with so many varied functionalities, my primary drivers for architectural choice were:

  • Loose Decoupling: Each part of the application should be as independent as possible. This minimizes ripple effects when changes are made, making debugging easier and reducing the risk of introducing new bugs in unrelated features.

  • Seamless Team Onboarding: A clear, predictable structure allows new developers to quickly understand where everything lives and how different parts interact, significantly reducing ramp-up time.

  • Standalone Features & Separation of Concerns: I wanted each feature to be truly self-contained, owning all it needs to run without tight dependencies on other features. This enforces a strong separation of concerns, meaning, for instance, the chat logic doesn't interfere with the pet profile management.

Modularity is Key: The ultimate goal was to build an app where I could theoretically remove an entire feature (e.g., the shopping module) without it breaking the core application. This extreme flexibility is crucial for long-term scalability and adaptability.

Given these requirements, a modular architecture, specifically a feature-based approach, naturally emerged as the optimal choice.

How It's Structured: Clean Architecture Principles in Practice (with State Management)

To bring this modular vision to life, I adopted principles from Clean Architecture, adapting them to fit the React Native context. This involved enforcing a strict separation of concerns into distinct layers: Presentation, Domain, and Data. My focus was on achieving strong feature isolation for supreme scalability and maintainability.

Given that state management is crucial for any React Native app, I also integrate a Store (e.g., using Redux, Zustand, Context API, etc.) within this structure. This store acts as a centralized place for managing application state, often with feature-specific slices for better organization.

Here's how that structure breaks down within the project's directory:

├── features/                 # Feature-based modules - the heart of modularity
│   ├── authentication/       # User Authentication
│   │   ├── api/              # Data Layer: Handles actual network requests for authentication (e.g., login, signup).
│   │   ├── components/       # Presentation Layer: UI elements specific to auth (e.g., login form fields, auth buttons).
│   │   ├── hooks/            # Presentation/Domain Bridge: Custom hooks that manage UI state, interact with API calls (from 'api/'), or orchestrate domain logic, often reading from or dispatching to the local feature store.
│   │   ├── screens/          # Presentation Layer: The actual UI screens users see (LoginScreen, RegistrationScreen). They orchestrate components and interact with hooks/domain, consuming state from the store.
│   │   └── store/            # Store Layer: Manages authentication-related state (e.g., user token, login status, user profile data). Actions here might trigger API calls.
│   │
│   ├── pets/                 # Pet Profile & Management
│   │   ├── api/              # Data Layer: Handles API calls for pet profiles, medical records, etc.
│   │   ├── components/       # Presentation Layer: UI elements for pet profiles, activity cards, health graphs.
│   │   ├── services/         # Domain Layer: This is where your core business logic for pets lives. Functions like 'calculatePetAge(dob)', 'determineVaccinationSchedule(petType)'. These often interact with the store to update state or trigger data fetching.
│   │   ├── screens/          # Presentation Layer: Screens displaying pet details, activity tracking UI, consuming pet-related state from the store.
│   │   └── store/            # Store Layer: Manages pet-specific state (e.g., list of pets, selected pet's details, activity logs).
│   │
│   ├── cart/                 # Shopping for Pet Items
│   │   ├── api/              # Data Layer: API calls for shopping, adding to cart, checkout.
│   │   ├── components/       # Presentation Layer: UI elements like product cards, cart item display.
│   │   ├── screens/          # Presentation Layer: Product listing, cart view, checkout flow, consuming cart state.
│   │   └── store/            # Store Layer: Manages cart state (e.g., items in cart, total price, checkout status).
│   │
│   └── chat/                 # AI & Veterinarian Chat
│       ├── api/              # Data Layer: Handles real-time chat API communication.
│       ├── components/       # Presentation Layer: UI elements like message bubbles, chat input field.
│       ├── screens/          # Presentation Layer: The chat interface screens, consuming chat history and status.
│       └── store/            # Store Layer: Manages chat-related state (e.g., message history, unread counts, chat status).
│
└── shared/                   # Cross-feature resources (generally Presentation or Utility)
    ├── components/           # Presentation Layer: Truly global, reusable UI components that have no business logic and can be used anywhere (e.g., `PrimaryButton`, `LoadingSpinner`, `CustomModal`). These might sometimes consume shared global state.
    │   ├── buttons/
    │   ├── modals/
    │   └── icons/
    │
    ├── hooks/                # Presentation/Domain/Utility: Shared hooks can fall into different categories.
    │   ├── useLocation.ts    # Often Presentation Layer (for UI effects based on location) or can bridge to Domain (if location data triggers business logic).
    │   └── useTimer.ts       # Often Presentation Layer (for UI countdowns) or Utility.
    │
    ├── store/                # Shared Store Layer (Optional, for Global State): Manages application-wide state not tied to a single feature (e.g., user session, app theme, global loading indicators).
    └── utils/                # Utility Layer (Cross-Cutting): These are pure helper functions that don't belong to a specific feature or layer but are used across your application.
        ├── formatters/       # Used by Presentation or Domain for data display/manipulation.
        └── validators/       # Used by Presentation (form validation) or Domain (business rule validation).

Deconstructing the Architecture: Layers in Action

In Clean Architecture, the core idea is to separate your application into distinct, concentric layers, with dependencies flowing inwards. This means inner layers have no knowledge of outer layers.

  • Domain Layer (Core Business Logic): This is the innermost layer. It contains your core business rules, entities (like Pet, User), use cases (what your app does, like "Register a Pet," "Track Activity"), and interfaces (contracts) for interacting with outer layers. It's the most stable part of your application, independent of any UI or database.

  • Data Layer (External Interfaces): This layer handles how data is retrieved, stored, and managed. It includes implementations of the interfaces defined in the Domain layer. Think of API calls, database interactions, local storage, etc.

  • Presentation Layer (UI & User Interaction): This is the outermost layer, responsible for everything the user sees and interacts with. It interprets user input, displays data, and orchestrates calls to the Domain layer. It has no knowledge of how data is fetched or business rules are implemented, only that it needs data or wants to trigger an action.

The Developer Workflow: Keeping Things Squeaky Clean

A well-architected app is only truly maintainable if the development workflow supports it. My philosophy is simple: keep the codebase as clean and consistent as possible. This means catching issues early, automating tedious tasks, and ensuring everyone on the team follows the same coding standards. To achieve this, I've set up a robust system involving ESLint, Prettier, Husky, and a custom remove_console.js script, all enforced at the pre-commit stage.

Here's how these pieces fit together to ensure "Pawfect's" code quality:

1. Enforcing Code Standards with ESLint and Prettier

Why: Let's be honest, few things are as frustrating as inconsistent code styles or subtle bugs introduced by forgotten console.log statements. ESLint helps us catch potential errors and enforce coding best practices, while Prettier takes care of consistent code formatting. Together, they eliminate style wars and allow us to focus on building features.

How: My .eslintrc.js is configured to leverage React Native, TypeScript, and Jest-specific rules, ensuring comprehensive coverage. Key rules like react-native/no-inline-styles (to promote proper styling) and @typescript-eslint/no-explicit-any (to encourage type safety) are set as errors, forcing us to address them. We also automatically remove console statements for production builds, keeping our production bundles clean.

// .eslintrc.js
module.exports = {
  root: true,
  extends: [
    'eslint:recommended',
    'plugin:react/recommended',
    'plugin:@typescript-eslint/recommended',
    '@react-native',
  ],
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint', 'react', 'react-native', 'jest'],
  env: {
    node: true, // Important for Node.js specific globals
  },
  rules: {
    'react/react-in-jsx-scope': 'off', // Not needed with new JSX transform
    '@typescript-eslint/no-unused-vars': 'warn', // Warns about unused variables
    'react/prop-types': 'off', // PropTypes often replaced by TypeScript
    'react-native/no-inline-styles': 'error', // Enforce StyleSheet usage
    '@typescript-eslint/no-explicit-any': 'error', // Strongly discourage 'any' type
    '@typescript-eslint/func-call-spacing': 'off', // Let Prettier handle spacing
    '@typescript-eslint/no-require-imports': 'off', // Allow require for specific cases
    '@typescript-eslint/consistent-type-imports': [ // Prefer type imports for clarity
      'error',
      {
        prefer: 'type-imports',
        fixStyle: 'inline-type-imports',
      },
    ],
  },
  overrides: [
    {
      // Apply Testing Library rules only to test files
      files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
      extends: ['plugin:testing-library/react'],
    },
  ],
};

2. Automating Cleanup with a Pre-Commit Hook

Why: Even with the best intentions, a console.log can slip into a committed file. To prevent this, and to ensure code is formatted and linted before it even hits the repository, we use Husky and lint-staged. This creates a powerful guardrail, automatically applying fixes and stripping out debug statements.

How: Husky sets up Git hooks (specifically, a pre-commit hook). lint-staged then runs our specified commands only on the files that are staged for commit. This is incredibly efficient as it doesn't process the entire codebase every time.

My package.json defines the lint-staged commands and the prepare script for Husky:

// package.json (Relevant snippets)
{
  "name": "Pawsfect",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
    "prepare": "husky install" // Important: Don't forget 'husky install' if using Husky v7+
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "node remove_console.js", // Our custom script to strip console logs
      "prettier --write",       // Format code
      "eslint --fix"            // Apply ESLint auto-fixes
    ]
  },
  "engines": {
    "node": ">=18" // Ensures consistency with Node.js version across environments
  }
}

The remove_console.js script is a simple yet effective tool that does exactly what it says:

// remove_console.js
const fs = require('fs');

const files = process.argv.slice(2); // Get file paths from arguments

files.forEach(file => {
  const appContent = fs.readFileSync(file, 'utf-8');
  // Replaces all common console methods
  const updateAppContent = appContent.replace(
    /console\.(log|warn|error|info|debug|trace)\(.*\);?/g,
    '',
  );
  fs.writeFileSync(file, updateAppContent, 'utf-8');
});

3. Streamlining Imports with Module Resolver

Why: As a project grows, navigating deep folder structures with endless ../../../ paths becomes a nightmare. Module Resolver (for Babel) and paths aliases (for TypeScript) allow us to create clean, absolute imports. This significantly improves readability, reduces refactoring headaches, and makes the developer experience much more pleasant.

How: By configuring babel.config.js and tsconfig.json with aliases, we can import modules elegantly. For instance, instead of import { Button } from '../../shared/components/buttons/Button';, we can write import { Button } from '@shared/components/buttons/Button';.

// babel.config.js
module.exports = {
  presets: ['module:@react-native/babel-preset'],
  plugins: [
    [
      'module-resolver',
      {
        root: ['./src'], // Base directory for aliases
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
        alias: {
          '@assets': './src/assets',
          '@features': './src/features',
          '@hooks': './src/shared/hooks',
          '@navigator': './src/navigator', // Assuming you have a top-level navigator
          '@shared': './src/shared',
          '@store': './src/store', // If you have a global store not within features
          '@utils': './src/shared/utils',
          '@types': './src/types/index.ts', // Centralized type definitions
          test: './test', // For testing utilities
          underscores: 'lodash', // Example alias for external library
        },
      },
    ],
  ],
};

And to make sure TypeScript understands these aliases for type checking and IntelliSense:

// tsconfig.json
{
  "extends": "@react-native/typescript-config/tsconfig.json",
  "compilerOptions": {
    "jsx": "react",
    "strict": true,
    "moduleResolution": "bundler",
    "types": ["react-native", "react", "node"],
    "baseUrl": "./src", // Base URL for path aliases
    "paths": {
      "@assets/*": ["./assets/*"], // Path relative to baseUrl
      "@hooks/*": ["./shared/hooks/*"],
      "@navigator/*": ["./navigator/*"],
      "@shared/*": ["./shared/*"],
      "@features/*": ["./features/*"],
      "@store/*": ["./store/*"],
      "@utils/*": ["./shared/utils/*"],
      "@types": ["./types/index.ts"] // Specific file alias
    },
    "isolatedModules": true // Important for faster builds and type safety
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "babel.config.js",
    "declaration.d.ts" // If you have one for global types
  ]
}

Conclusion to Part 1: Building a Solid Bedrock

We've established 'Pawfect's' solid foundation. Prioritizing modularity and clean architecture ensured a scalable, maintainable app. Our development workflow with ESLint, Prettier, and Husky guarantees a clean, high-standard codebase, fostering an efficient and less frustrating development environment. This disciplined setup is the bedrock for confidently building complex features.

What's Next in Part 2: Testing for Quality and Automated Integration

In Part 2 of this series, we'll dive into ensuring 'Pawfect's' quality and automating its integration. I'll share my thought process and decisions behind our comprehensive testing strategies (unit, integration, E2E) and our Continuous Integration (CI) pipeline. Discover how CircleCI, Snyk, and SonarQube automate builds, security, and quality checks. Stay tuned – the journey from clean code to a rigorously tested and continuously integrated application continues!

10
Subscribe to my newsletter

Read articles from Thomas Ejembi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thomas Ejembi
Thomas Ejembi

I'm a dedicated software engineer with a passion for solving business challenges through innovative software solutions. My technical stack spans React, React Native (for both Android and iOS), Kotlin, and Android development—tools I leverage to build dynamic user interfaces and architect efficient, scalable mobile applications that drive business success and elevate user experiences. In my previous role, I led a team of developers to create a high-performance delivery app for a major client in East Asia, as well as a cutting-edge meme coin launchpad. Currently, as a co-founder and mobile engineer at BlinkCore, I help build digital and contactless payment apps that empower users to make blink-fast, secure payments while enabling businesses to receive them seamlessly. Our technology integrates NFC, HCE, QR code scanning, and geolocation to deliver a next-generation payment experience. I thrive on tackling complex problems and consistently deliver scalable, innovative solutions that align with business needs. Let's connect and explore how we can turn challenging ideas into transformative digital experiences.