React Native TypeScript: Production Architecture & CI/CD

Build production-ready React Native apps with TypeScript. State management, performance optimization, and automated iOS/Android deployment patterns.

0

Building a React Native app that works on a developer’s machine is one thing. Shipping it to production with type safety, reliable state management, and automated deployment pipelines is another. This guide covers the patterns and decisions that matter when you’re moving beyond prototypes.

Architecture Foundation: TypeScript and Project Structure

TypeScript in React Native isn’t optional if you’re serious about production. It catches entire categories of bugs before they reach users, makes refactoring safer, and documents your code through types rather than comments that decay. React Native defaults new projects to TypeScript, and for good reason.

A solid project structure matters equally. Here’s a practical layout:

my-app/
  src/
    screens/          # Screen components
    components/       # Reusable UI components
    hooks/            # Custom hooks
    state/            # State management (Zustand, Redux)
    services/         # API clients, native modules
    types/            # TypeScript interfaces and types
    utils/            # Helpers, formatters
    navigation/       # Navigation configuration
  ios/                # Native iOS code
  android/            # Native Android code
  app.json
  tsconfig.json
  package.json

Separate your concerns clearly. Screens handle navigation and orchestration. Components are dumb and testable. Services own the messy parts: API calls, native module bridges, platform-specific logic. This makes your codebase navigable as it grows.

State Management: Choosing Between Zustand and Redux Toolkit

Your state management choice affects how your app scales. Both Zustand and Redux Toolkit work well in React Native. The difference is philosophical and practical.

Zustand for Simplicity

Zustand is lightweight. It requires minimal boilerplate and works beautifully for apps with moderate state complexity. Here’s a working example:

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface User {
  id: string;
  email: string;
  token: string;
}

interface AuthStore {
  user: User | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>(
  persist(
    (set) => ({
      user: null,
      isLoading: false,
      login: async (email, password) => {
        set({ isLoading: true });
        try {
          const response = await fetch('https://api.example.com/login', {
            method: 'POST',
            body: JSON.stringify({ email, password }),
          });
          const data = await response.json();
          set({ user: data, isLoading: false });
        } catch (error) {
          set({ isLoading: false });
          throw error;
        }
      },
      logout: () => set({ user: null }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

Notice the persist middleware. In React Native, you almost always want to survive app restarts. Zustand’s persistence layer integrates cleanly with AsyncStorage, so your auth token or user preferences stick around.

Redux Toolkit for Complex Apps

Redux Toolkit reduces Redux boilerplate significantly. If your app has complex workflows, multiple async operations, or a large team that needs strict patterns, Redux Toolkit is worth the overhead:

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

interface User {
  id: string;
  email: string;
  token: string;
}

interface AuthState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

const initialState: AuthState = {
  user: null,
  isLoading: false,
  error: null,
};

export const loginUser = createAsyncThunk(
  'auth/loginUser',
  async ({ email, password }: { email: string; password: string }) => {
    const response = await fetch('https://api.example.com/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    return response.json();
  }
);

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    logout: (state) => {
      state.user = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(loginUser.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(loginUser.fulfilled, (state, action) => {
        state.isLoading = false;
        state.user = action.payload;
      })
      .addCase(loginUser.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.error.message || 'Login failed';
      });
  },
});

export default authSlice.reducer;

Redux Toolkit’s createAsyncThunk handles loading and error states automatically. It’s predictable and scales well as your app grows. Choose Zustand if you value simplicity; choose Redux Toolkit if you need strict patterns and middleware capabilities.

Performance Optimization in React Native

Performance issues in React Native often surface after launch. Users notice jank, slow navigation, or battery drain. Here are the patterns that matter.

Code Splitting and Lazy Loading

Not every user needs every feature. Lazy load screens that aren’t critical to the initial experience:

import React, { lazy, Suspense } from 'react';
import { ActivityIndicator, View } from 'react-native';

const ProfileScreen = lazy(() => import('./screens/ProfileScreen'));
const SettingsScreen = lazy(() => import('./screens/SettingsScreen'));

const LoadingFallback = () => (
  <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
    <ActivityIndicator size="large" />
  </View>
);

export const AppNavigator = () => (
  <Suspense fallback={<LoadingFallback />}>
    {/* Your navigation structure */}
  </Suspense>
);

This defers loading ProfileScreen and SettingsScreen until they’re actually navigated to. On slower devices or networks, this means faster initial app launch.

FlatList Optimization

Lists are a common performance bottleneck. Always use FlatList with proper configuration:

import { FlatList, Text, View } from 'react-native';

const renderItem = ({ item }: { item: any }) => (
  <View style={{ padding: 16, borderBottomWidth: 1, borderBottomColor: '#eee' }}>
    <Text>{item.title}</Text>
  </View>
);

const keyExtractor = (item: any) => item.id.toString();

export const ItemList = ({ items }: { items: any[] }) => (
  <FlatList
    data={items}
    renderItem={renderItem}
    keyExtractor={keyExtractor}
    maxToRenderPerBatch={10}
    updateCellsBatchingPeriod={50}
    initialNumToRender={20}
    removeClippedSubviews={true}
  />
);

The key optimizations: maxToRenderPerBatch limits how many items render in one batch, updateCellsBatchingPeriod spreads renders over time, and removeClippedSubviews unmounts items off-screen. These changes are invisible to users but prevent the frame drops that make scrolling feel choppy.

Native Modules for Heavy Lifting

JavaScript is fast enough for most work, but expensive operations belong in native code. Image processing, video encoding, and complex calculations should call native modules:

import { NativeModules } from 'react-native';

const { ImageProcessor } = NativeModules;

export const processImage = async (imagePath: string): Promise<string> => {
  try {
    const result = await ImageProcessor.compressImage(imagePath, {
      quality: 0.8,
      maxWidth: 1200,
      maxHeight: 1200,
    });
    return result.path;
  } catch (error) {
    console.error('Image processing failed:', error);
    throw error;
  }
};

Native modules feel like async functions from JavaScript, but they execute on native threads. This keeps your UI thread responsive even during heavy work.

Platform-Specific Code and Dependencies

iOS and Android have different capabilities, permissions models, and APIs. Managing these differences cleanly matters.

React Native supports .ios.ts and .android.ts file extensions. For a platform-specific service:

// services/notification.ios.ts
import { NativeModules } from 'react-native';

const { NotificationManager } = NativeModules;

export const requestNotificationPermission = async (): Promise<boolean> => {
  try {
    const granted = await NotificationManager.requestUserPermission();
    return granted;
  } catch (error) {
    console.error('iOS notification permission error:', error);
    return false;
  }
};
// services/notification.android.ts
import { PermissionsAndroid } from 'react-native';

export const requestNotificationPermission = async (): Promise<boolean> => {
  try {
    const granted = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
    );
    return granted === PermissionsAndroid.RESULTS.GRANTED;
  } catch (error) {
    console.error('Android notification permission error:', error);
    return false;
  }
};

Your app imports from services/notification.ts (without the platform suffix). React Native’s bundler automatically picks the right version. This keeps platform logic isolated and testable.

CI/CD: Automated Deployment to Both Stores

Manual builds are time-consuming and prone to mistakes. A solid CI/CD pipeline builds, tests, and deploys your app automatically. GitHub Actions combined with EAS Build is a practical approach for React Native.

Setting Up EAS Build

EAS (Expo Application Services) handles the heavy lifting of building for iOS and Android without needing a Mac or Android SDK locally:

npm install -g eas-cli
eas build:configure

This generates eas.json. Configure it for your needs:

{
  "build": {
    "preview": {
      "android": {
        "buildType": "apk"
      },
      "ios": {
        "buildType": "simulator"
      }
    },
    "production": {
      "android": {
        "buildType": "aab"
      },
      "ios": {
        "buildType": "archive"
      }
    }
  }
}

For Android, buildType: “aab” creates an Android App Bundle, which Google Play requires. For iOS, buildType: “archive” creates an ipa suitable for TestFlight and the App Store.

GitHub Actions Workflow

Automate builds on every commit or tag. Here’s a practical workflow:

name: Build and Deploy

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build with EAS
        env:
          EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
        run: eas build --platform all --non-interactive

      - name: Submit to stores
        env:
          EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
        run: eas submit --platform all --non-interactive

This workflow triggers on version tags. It installs dependencies, runs tests, builds for both platforms using EAS, and submits to both app stores. No manual steps, no human error.

Store credentials live in GitHub Secrets, not in your repo. Generate an EXPO_TOKEN from Expo’s dashboard and add it to your repository settings.

Environment Configuration

Your app needs different API endpoints for staging and production. Manage this through environment variables:

// config/env.ts
const ENV = {
  staging: {
    apiUrl: 'https://staging-api.example.com',
    logLevel: 'debug',
  },
  production: {
    apiUrl: 'https://api.example.com',
    logLevel: 'error',
  },
};

const currentEnv = process.env.ENVIRONMENT || 'staging';
export const config = ENV[currentEnv as keyof typeof ENV];

In your GitHub Actions workflow, pass ENVIRONMENT=production when building for release:

      - name: Build with EAS
        env:
          EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
          ENVIRONMENT: production
        run: eas build --platform all --non-interactive

Production Monitoring and Error Tracking

Your app is live. Now you need visibility into what’s happening in users’ hands. Sentry or similar services catch crashes and send them to you automatically:

import * as Sentry from '@sentry/react-native';

Sentry.init({
  dsn: 'https://your-sentry-dsn@sentry.io/project-id',
  environment: process.env.ENVIRONMENT || 'staging',
  tracesSampleRate: 1.0,
});

export const captureException = (error: Error, context?: Record<string, any>) => {
  Sentry.captureException(error, {
    contexts: { custom: context },
  });
};

Wrap your async operations and catch blocks. Unhandled errors surface immediately in your Sentry dashboard with full stack traces, device info, and user context. This turns production issues from “users report bugs” to “you fix them before users notice”.

Handling Dependencies and Native Modules

React Native’s dependency ecosystem is mature but requires care. Native modules (written in Objective-C, Swift, Java, or Kotlin) add native code to your project. When you install a package like react-native-camera, you’re adding native code.

Keep your dependency list lean. Each native module increases build time and binary size. Before adding a package, ask: Do I need this, or can I implement it with JavaScript? Does this package support the minimum OS versions I target?

Document your dependencies in a dependencies.md file at the root of your repo. Include why you’re using each one and what version constraints you require. This helps future maintainers understand your choices.

Testing in Production-Grade Apps

Unit tests are cheap insurance. Test your state management logic and utility functions:

import { renderHook, act } from '@testing-library/react-native';
import { useAuthStore } from '../state/auth';

describe('useAuthStore', () => {
  it('should logout and clear user', () => {
    const { result } = renderHook(() => useAuthStore());

    act(() => {
      result.current.logout();
    });

    expect(result.current.user).toBeNull();
  });
});

Integration tests verify that screens and components work together. E2E tests (using Detox) simulate real user flows on actual devices or simulators. Start with unit tests for your business logic, add integration tests for critical flows, and reserve E2E tests for the happy path and major features.

Putting It Together

Production-grade React Native requires discipline across multiple areas. TypeScript catches bugs early. Zustand or Redux Toolkit keeps state predictable. Performance optimization prevents jank and battery drain. Platform-specific code handles OS differences cleanly. CI/CD automation removes human error from deployments. Error tracking gives you visibility into production behavior.

None of these are novel. They’re proven patterns used by teams shipping real apps to millions of users. The combination of these practices is what separates prototypes from production.

Start with a solid TypeScript foundation and clear project structure. Choose your state management approach based on app complexity. Optimize for performance before you ship. Automate your deployment pipeline. Add monitoring. Then iterate based on real user data.

Should I use Expo or bare React Native?

Expo handles most of the complexity of native development and is excellent for getting to market quickly. Bare React Native gives you more control but requires managing native code, CocoaPods, and Gradle. For most production apps, start with Expo. Eject to bare React Native only if you need capabilities Expo doesn’t support, like custom native modules that Expo can’t provide.

How do I handle authentication securely in React Native?

Store tokens in React Native’s Keychain (iOS) or Keystore (Android) via libraries like react-native-keychain. Never store tokens in AsyncStorage; it’s not encrypted. Implement token refresh logic to handle expiration gracefully. Use HTTPS for all API calls and validate SSL certificates. For sensitive operations, require biometric authentication on supported devices.

What’s the difference between Redux Toolkit and Zustand for React Native?

Zustand is simpler and lighter. It’s ideal for apps with straightforward state needs. Redux Toolkit is more structured and opinionated. It’s better for complex apps with many async operations or large teams that benefit from strict patterns. Both work well in React Native. Choose based on your app’s complexity and team preferences, not hype.

How do I reduce my React Native app’s binary size?

Use code splitting to lazy load screens. Remove unused dependencies. Enable ProGuard on Android to shrink bytecode. For iOS, enable bitcode. Use dynamic imports for heavy libraries loaded on demand. Measure your bundle size regularly with tools like react-native-bundle-visualizer. Binary size directly affects download rates and user acquisition, so optimize it early.

Can I deploy to both iOS and Android from one CI/CD pipeline?

Yes. EAS Build handles both platforms from one command. GitHub Actions can trigger builds for both simultaneously. The workflow waits for both to complete, then submits to both stores. This ensures consistent versions across platforms and removes manual deployment steps. Set it up once and let it run automatically on every release tag.

Leave a Reply

Your email address will not be published. Required fields are marked *