All lessons
Mobile appsIntermediate16 min readIn my stack

React Native

One React codebase, two truly native apps — and how to build one from zero.

  • React Native 0.86
  • New Architecture
  • Navigation
  • Native modules
  • Reanimated
Step 1/5
One TypeScript codebase: React components, state and business logic, written once.

In 30 seconds

React Native lets you write a mobile app in React and TypeScript, while the screens are drawn with real iOS and Android components. Your JavaScript runs in the Hermes engine and talks to native code directly through the New Architecture. With a good structure — navigation, stores, an API layer and design tokens — one team can ship and maintain both apps from a single codebase.

Why I use it

  • I use React Native because one TypeScript codebase gives a client both iOS and Android apps that share business logic with their React web frontend.
  • I build apps on a shared internal template, so auth, API access, theming, push and force-update are solved once and every new app starts from a working base.
  • The New Architecture and Hermes make performance good enough that users cannot tell the difference for commerce, loyalty and booking apps.
  • When a feature needs the platform — a payment SDK, camera scanning, maps — I can drop into Kotlin or Swift for that one module and keep the rest in React.

How it works

01How React Native works

Your app has two worlds. The JavaScript world runs your React components, state and business logic inside Hermes, a JavaScript engine built for mobile that compiles your code to bytecode at build time so apps start faster and use less memory. The native world owns the real UIView and Android View objects, the camera, the keychain and so on. React describes what the screen should look like; React Native turns that description into native views. A <View> becomes a UIView on iOS and a ViewGroup on Android; <Text> becomes native text. There is no WebView: scrolling, gestures and text input are native, which is why RN apps feel like real apps. Layout uses Yoga, an engine that implements Flexbox, so the same style works on both platforms.

02The New Architecture: JSI, Fabric, TurboModules

The old architecture sent every message between JavaScript and native as serialized JSON over an asynchronous 'bridge' — a queue that became a bottleneck for animations and big lists. The New Architecture, the default since 0.76 and the only option since 0.82, replaces it. JSI (JavaScript Interface) lets JavaScript hold direct references to C++ objects and call them synchronously, without serializing. Fabric is the new renderer: it builds the UI tree in C++, supports React 18+ concurrent features and can measure and update layout synchronously. TurboModules are native modules loaded lazily on first use and typed through Codegen, which generates the C++ glue from your TypeScript spec so mistakes show up at build time. Practical impact: smoother gestures and animations (Reanimated 4 needs it), faster startup, and libraries must be New-Architecture compatible — check before you install.

  • JSI: direct, synchronous JS ↔ C++ calls, no JSON bridge
  • Fabric: C++ renderer, concurrent React, synchronous layout
  • TurboModules + Codegen: lazy, type-safe native modules
  • Hermes: bytecode-compiled engine, default on both platforms

03Bare React Native or Expo?

The React Native team now recommends starting with a framework, and Expo is the main one. Expo gives you a large set of maintained native modules, file-based routing (Expo Router), config plugins that edit native projects for you, development builds, and EAS for cloud builds, submission and OTA updates. 'Bare' means you own the ios/ and android/ folders directly (started with the React Native Community CLI). Bare gives full control and fits teams with native experience or heavy custom native SDKs. The line has blurred: Expo apps can include any native code through config plugins or prebuild, and bare apps can use Expo modules. My rule: for a new product with standard needs, start with Expo; for an app that must integrate unusual native SDKs or follows an existing native template, bare is fine — just upgrade regularly.

bash
# Expo (recommended start)
npx create-expo-app@latest my-app --template default
cd my-app && npx expo start
 
# Bare React Native (Community CLI)
npx @react-native-community/cli@latest init MyApp
cd MyApp && npx react-native run-ios

04Building a real app from zero: structure, env, API, state

Structure by feature, not by file type, so a screen, its hooks and its components live together. Keep three layers separate. The API layer is one HTTP client with base URL, auth header, token refresh and timeouts; adapters turn backend responses into app types, so a renamed backend field changes one file, not twenty screens. Stores (Zustand is light and works well) hold app state like session, cart and settings; screens read stores and call actions, never fetch directly. For server data, TanStack Query adds caching, retries and refetch on focus. Environment: keep the API URL and keys per build (dev, staging, prod) through build-time config, never hard-coded, and never put real secrets in the app — anything in the bundle can be extracted. Retry only idempotent requests (GET, PUT with keys) automatically; a retried POST can create a duplicate order.

bash
src/
  app/          # navigation, providers, deep links
  features/
    auth/       # screens, hooks, components for sign-in
    cart/
    orders/
  shared/
    api/        # http client, adapters, endpoints
    stores/     # zustand stores (session, cart, settings)
    ui/         # Button, Text, Card — built on tokens
    theme/      # colors, spacing, typography tokens
    i18n/       # en / hy / ru dictionaries
  native/       # bridges to custom native modules

05Navigation, auth flow and push

React Navigation (stack, tabs, drawers on top of native screens) and Expo Router (file-based, built on React Navigation) are the standard choices. Model auth as navigation state: a root navigator shows the auth stack or the main tabs depending on whether a session exists, so a signed-out user cannot reach private screens by a deep link. Store tokens in the Keychain/Keystore (react-native-keychain or expo-secure-store), never in AsyncStorage. Let guests browse and gate only actions that need an account, returning them to the same place after sign-in. Configure deep links and Universal/App Links early; push notifications, emails and QR codes all depend on them. For push, register the device token after sign-in and send it to your backend, ask for permission at a meaningful moment rather than on first launch, and handle notification taps by navigating with the same deep-link routes.

tsx
const Stack = createNativeStackNavigator<RootParams>();
 
export function RootNavigator() {
  const session = useSession((s) => s.session);
  return (
    <Stack.Navigator screenOptions={{ headerShown: false }}>
      {session ? (
        <Stack.Screen name="Main" component={MainTabs} />
      ) : (
        <Stack.Screen name="Auth" component={AuthStack} />
      )}
      <Stack.Screen name="ForceUpdate" component={ForceUpdateScreen} />
    </Stack.Navigator>
  );
}

06Styling, design tokens and lists performance

There is no CSS cascade: each component gets a style object, and StyleSheet.create keeps them static. Build a small theme of tokens — colors (light and dark), spacing scale, radii, typography — and a handful of base components (Text, Button, Card, Screen) that only use tokens. Screens then never contain raw hex values or magic numbers, and dark mode or a rebrand is one file. Respect safe areas (react-native-safe-area-context), Dynamic Type/font scaling and Android edge-to-edge layouts. For long lists never render with map inside a ScrollView; use FlatList or, better, FlashList, which recycles cells. Give items stable keys, memoize row components, avoid inline heavy work in renderItem, and size images properly (expo-image or a cached image library). Animate with Reanimated, which runs animations on the UI thread so they stay smooth even when JavaScript is busy.

tsx
export const theme = {
  color: { bg: "#FFFFFF", text: "#111111", primary: "#0A6CFF", muted: "#6B7280" },
  space: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
  radius: { sm: 6, md: 12, pill: 999 },
  font: { body: 16, title: 22, caption: 13 },
} as const;
 
<FlashList
  data={products}
  keyExtractor={(p) => p.id}
  renderItem={({ item }) => <ProductRow product={item} />}
/>

07Native modules, platform differences and debugging

When no library exists — a regional payment SDK, a printer, a custom scanner — write a TurboModule: a TypeScript spec, Codegen, then Kotlin/Swift implementations (Expo Modules API is a friendlier alternative). Keep the surface small and promise-based. Expect platform differences: permissions flows, back button on Android, keyboard behaviour, shadows (iOS shadow props vs Android elevation), date pickers, fonts, and stricter background limits on iOS. Use Platform.select or file suffixes (Button.ios.tsx) only where behaviour truly differs. Debugging: React Native DevTools (press j in Metro) gives a Chrome-style debugger, console, React components and profiler for Hermes. Use Xcode and Android Studio logs (Logcat) for native crashes, Crashlytics or Sentry for production, and the performance monitor to watch JS and UI frame rates. Test on a real low-end Android phone — the simulator lies about performance.

Use it when

  • You need iOS and Android apps with mostly shared UI and business logic and a limited budget.
  • Your team already knows React and TypeScript, or shares code and types with a React web app.
  • Commerce, booking, loyalty, CRM and content apps where native look and feel matters but the UI is standard.
  • You want OTA updates for JavaScript fixes and fast iteration with a single release process.

Skip it when

  • Graphics-heavy games or AR — use a game engine (Unity, Godot) or native.
  • Apps that are mostly deep platform features (watch faces, complex widgets, background audio processing) with little shared UI.
  • A team with strong native iOS and Android engineers and no React experience.

Alternatives

  • Flutter

    Draws its own UI with its own engine and Dart; very consistent pixels, less native feel, separate language.

  • Native (Swift / Kotlin)

    Best platform integration and performance, but two codebases and two teams.

  • Kotlin Multiplatform

    Share business logic in Kotlin while keeping native UI (or Compose Multiplatform).

  • PWA / Capacitor

    Web app in a shell; cheapest for content apps, weakest native feel and store presence.

Best practices

  1. Start from a template with auth, API client, theme, i18n, push and force-update already wired.
  2. Keep screens thin: data through an API layer with adapters, state in stores, UI from tokens.
  3. Store tokens in Keychain/Keystore; never ship secrets inside the bundle.
  4. Check every library for New Architecture support and maintenance before adding it.
  5. Use FlashList/FlatList for lists and Reanimated for animations; profile on a low-end Android device.
  6. Upgrade React Native regularly (Upgrade Helper) instead of jumping many versions at once.
  7. Set up Crashlytics or Sentry, deep links and a force-update switch before the first release.

Common mistakes

  • Fetching inside screens and spreading backend field names across the UI, so every API change breaks many files.
  • Rendering long lists with ScrollView + map, causing memory spikes and dropped frames.
  • Storing tokens in AsyncStorage or putting private API keys in environment files bundled into the app.
  • Asking for every permission on first launch, so users deny push and location forever.
  • Testing only on the iOS simulator and discovering Android keyboard, back button and performance issues after release.

Deep dive

08Threads and the render pipeline

A React Native app runs on several threads. The JavaScript thread runs React, your state updates and business logic in Hermes. The main (UI) thread owns native views, touches and the platform's frame loop. Fabric renders in three phases. Render: React produces elements and Fabric builds a shadow tree of C++ nodes. Commit: Yoga calculates layout for that tree, often on a background thread, and the new tree is promoted as the next one to show. Mount: the differences are applied to real native views on the UI thread. At 60 Hz each frame has about 16.7 ms; at 120 Hz about 8 ms. If the JavaScript thread is busy parsing a big JSON response or re-rendering a large tree, touches still arrive but React cannot respond, and the app feels frozen. That is why Reanimated worklets run animations on the UI thread, and why startTransition can mark non-urgent updates as interruptible.

09Writing a TurboModule with Codegen

A TurboModule starts as a typed JavaScript spec. The file name must start with Native, and package.json declares a codegenConfig that tells Codegen where specs live. At build time Codegen reads the spec and generates C++ and platform interfaces; your Kotlin and Objective-C++ or Swift classes implement them, and the compiler complains if a method signature does not match. Keep the surface small: plain data types, promises for anything slow, and events for streams of updates such as payment status. Never block the JavaScript thread with synchronous native calls that touch the network or disk; synchronous methods are for cheap reads only. If the team is not native-heavy, the Expo Modules API offers a simpler Swift and Kotlin DSL that works in bare apps too.

ts
// specs/NativePayments.ts
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
 
export interface Spec extends TurboModule {
  isAvailable(): boolean; // cheap, synchronous
  startPayment(amountMinor: number, currency: string): Promise<string>;
}
 
export default TurboModuleRegistry.getEnforcing<Spec>("NativePayments");
 
// package.json
// "codegenConfig": { "name": "PaymentsSpec", "type": "modules",
//   "jsSrcsDir": "specs", "android": { "javaPackageName": "com.example.payments" } }

10Startup time and bundle size

Cold start is the first impression, and most of it is under your control. Hermes already ships precompiled bytecode, so the remaining cost is mostly what your code does at module load: importing every screen and heavy library up front, reading large data from storage synchronously, or initialising many SDKs before the first frame. Load screens lazily, defer analytics and non-critical SDKs until after the first render, and keep the splash screen up only until the first meaningful data is ready. Measure on a release build on a mid-range Android device, never in development mode, which is many times slower. On Android, enable R8 minification for release and keep its mapping files for Crashlytics. Watch the JavaScript bundle size over time; a single date or chart library can add hundreds of kilobytes that every user downloads and parses.

11Security and offline robustness

Assume the bundle will be read: anyone can unpack an APK or IPA and inspect the Hermes bytecode and assets. Secrets stay on the server; the app holds only user tokens in Keychain or Keystore. To make API abuse harder, add device attestation (Play Integrity on Android, App Attest on iOS) and verify it on the server. Certificate pinning blocks some interception attacks but can lock every user out if a certificate rotates unexpectedly, so pin to public keys with a backup key and a remote kill-switch. Offline behaviour needs explicit design: detect connectivity, show cached data with a clear offline banner, queue user actions locally and replay them with idempotency keys so a retried order is never duplicated. Clear caches containing personal data on sign-out, and exclude sensitive screens from screenshots and the app switcher where the domain demands it.

Real-world scenarios

01

An upgrade breaks because a library is not New-Architecture ready

Problem

Moving from an older React Native version to 0.82 or later, the build fails or a screen crashes in a third-party module that was written for the old bridge and has not been updated in a year.

How to solve it

Since 0.82 the old architecture cannot be switched back on, so the module must work with the New Architecture. Check React Native Directory and the library's issues for a compatible release or fork. Many legacy modules still run through the interop layer, so test them on a real device first. If the library is abandoned, choose between a maintained alternative, a temporary patch with patch-package, or rewriting the small part you use as a TurboModule or Expo module. Do the upgrade on a branch with the Upgrade Helper diff, one version step at a time, and run the full release checklist before merging.

02

The product list stutters on cheap Android phones

Problem

On a flagship iPhone the catalogue scrolls smoothly, but on a budget Android device it drops frames, shows blank cells while scrolling and sometimes runs out of memory.

How to solve it

Profile a release build on that device with React Native DevTools and the performance monitor to see whether the JavaScript or UI thread is the bottleneck. Move the list to FlashList, memoize the row, keep stable keys and pass only primitive props, so a cart update does not re-render every row. Request thumbnails sized for the cell from the server instead of full images, and use a caching image component. Move price formatting and sorting out of renderItem into a selector. Paginate the API so the app never holds thousands of products. Measure again and record the result as a budget for future changes.

03

The app crashes only in the Android release build

Problem

Everything works in debug, but the signed release build crashes on launch for testers. There is no red error screen and the Crashlytics stack trace shows obfuscated class names.

How to solve it

Reproduce locally with npx react-native run-android --mode release and read adb logcat for the native exception. The usual causes are R8 removing classes a native SDK loads by reflection, a missing build-time environment value, or a native library missing for one CPU architecture. Add the SDK's documented keep rules to proguard-rules.pro, confirm the release build receives the right environment config, and make sure mapping files upload to Crashlytics so traces become readable. Then add an automated smoke test of the release variant to CI, so this class of bug is caught before testers ever see it.

Cheat sheet

Run an Android release build locally
npx react-native run-android --mode release
Reset the Metro cache
npx react-native start --reset-cache (Expo: npx expo start -c)
Open React Native DevTools
press j in the Metro terminal
Check the local toolchain
npx react-native doctor
Align Expo package versions
npx expo install --check
Regenerate native folders (Expo)
npx expo prebuild --clean
Install iOS pods
cd ios && bundle exec pod install
Clean Android build outputs
cd android && ./gradlew clean
Show only Android errors
adb logcat *:E
Plan an upgrade diff
react-native-community.github.io/upgrade-helper

In my real projects

  • I have built 5 React Native apps (restaurant ordering, pet shop, gym, coffee loyalty, investment) on a shared internal template, moving from RN 0.81 to 0.86.
  • The newest coffee-ordering app runs RN 0.86 with the New Architecture and Hermes, Reanimated 4.5 and Zustand; an adapter layer normalises backend responses so a rename never touches a screen.
  • The restaurant app I lead includes a custom native Android module for a regional payment provider, plus Remote Config force update, a switchable API base URL and a retry policy that only retries idempotent requests.
  • A gym member app uses VisionCamera for QR check-in and scheduled push campaigns, with a documented release process.

Questions & answers

Q1How does React Native differ from a hybrid WebView app?

A hybrid app like Cordova or Capacitor renders HTML and CSS inside a WebView, so the whole UI is a web page. React Native renders real native components: a View becomes a UIView or Android ViewGroup, a TextInput is the platform text field, scrolling and gestures are native. JavaScript runs in Hermes and describes the UI; the native side draws it. That is why RN apps feel native and integrate with accessibility, keyboards and system behaviour. The trade-off is that you cannot reuse web DOM libraries directly, and some features need native modules.

Q2Explain the New Architecture in simple terms.

Previously JavaScript and native code talked through an asynchronous bridge that serialized every message to JSON, which was slow for animations and large lists. The New Architecture removes that bridge. JSI lets JavaScript call C++ objects directly and synchronously. Fabric is a new C++ renderer that supports concurrent React and synchronous layout measurement. TurboModules load native modules lazily, with Codegen generating type-safe glue from TypeScript specs. It has been the default since 0.76 and the only architecture since 0.82, so in practice the main task is choosing libraries that support it.

Q3How do you structure a React Native project for a long-lived product?

By feature, with clear shared layers. Each feature folder holds its screens, hooks and components. A shared API layer owns the HTTP client, auth headers, token refresh, timeouts and adapters that convert backend payloads into app types. Stores hold session, cart and settings; screens only read stores and call actions. UI is built from a few base components that use design tokens, so dark mode and rebrands are cheap. Navigation, deep links and push handling live in one app folder. This keeps changes local and lets several developers, or AI agents, work without stepping on each other.

Q4How do you improve performance of a slow list?

First measure with the React Native DevTools profiler on a real mid-range Android phone. Then switch to FlashList or a tuned FlatList, never ScrollView with map. Give items stable keys, memoize the row component and keep renderItem cheap — no inline sorting or formatting of the whole dataset. Pass only the props a row needs, so unrelated state changes do not re-render every row. Resize images on the server and use a caching image component. Paginate from the API, and move heavy animations to Reanimated so they run on the UI thread.

Q5Expo or bare React Native — which do you recommend?

For most new apps I recommend Expo, and so does the React Native team. You get maintained modules, development builds, config plugins for native changes, EAS Build and Submit, and OTA updates, which removes a lot of setup and upgrade pain. Expo does not lock you out of native code: you can add custom modules and config plugins. Bare makes sense when you already have a native-heavy template, need unusual SDKs that are painful to wrap in plugins, or have a team that wants direct control of Xcode and Gradle projects. Either way, keep dependencies current.

Q6How do you handle authentication securely in a mobile app?

Use short-lived access tokens with a refresh token, issued by the backend after password, OTP or social sign-in. Store tokens in the iOS Keychain or Android Keystore, not AsyncStorage. The API client attaches the access token, refreshes once on a 401 and queues parallel requests during refresh, and logs out cleanly if refresh fails. The root navigator switches between auth and main stacks based on session state. Never embed private keys or secrets in the app, rate-limit OTP on the server, and support account deletion inside the app, which Apple requires.

Senior-level questions

S1How would you share code between a React Native app and a React web app?

Share what has no pixels first. In a monorepo I put API types, the HTTP client, adapters, validation schemas, domain logic and stores into packages that both apps import; those are pure TypeScript and test easily. UI is a separate decision. React Native Web can render the same components on the web, which pays off for internal tools or simple screens, but marketing sites and SEO pages usually want native web components and server rendering. Design tokens are a good middle ground: one token source feeding both CSS variables and the native theme object. The trade-offs are tooling complexity — Metro, Next.js and TypeScript project references must all resolve the packages — and coupling: a change in a shared package now needs both apps tested, so the shared layer must have its own tests and clear versioning.

S2Design an offline-first ordering flow for a mobile app.

Separate reads from writes. Reads come from a local cache — TanStack Query persisted to storage, or SQLite for larger data — shown immediately with a timestamp and refreshed when the network returns. Writes become commands in a local outbox: each order gets a client-generated idempotency key, is stored locally, displayed as pending and sent when connectivity allows. The server must treat the key as unique, so a retry after a timeout returns the original order instead of creating a second one. Conflicts need business rules, not technical magic: if a product sold out or a price changed, the server rejects with a clear reason and the app asks the user. Trade-offs: more client complexity, migrations for the local schema, and careful UX so users understand what is confirmed versus pending.

S3How do you keep several apps on a shared template up to date?

Treat the template as a product with its own versioning. Shared code — API client, auth, theme system, push, force update — moves into packages or a clearly marked core folder that apps do not edit directly; app-specific code lives elsewhere. React Native upgrades happen in the template first, one minor version at a time, using the Upgrade Helper and a checklist that covers New Architecture compatibility of every dependency. Then each app takes the change on a branch, runs its release build and smoke tests and ships through TestFlight and an internal track. The trade-off is discipline versus freedom: the more an app forks the template, the more expensive each upgrade becomes, so I push teams to contribute fixes back into the core instead of patching locally.

S4When would you choose native development over React Native for a new project?

When the product's value lives in the platform. Examples: heavy real-time graphics, AR, complex background audio or Bluetooth work, deep widget and watch integrations, or apps where most screens are custom native experiences with little shared logic. Also when the organisation already has strong separate iOS and Android teams; forcing them onto React adds cost without benefit. React Native wins when most of the app is forms, lists, commerce and content, when the team knows React, and when iOS and Android must ship the same features at the same time. There is also a hybrid path: a native app that embeds React Native for some screens, or a React Native app with a few native screens. The honest answer is a cost model: team skills, feature parity needs, performance risks and long-term maintenance.

Glossary

Hermes
The JavaScript engine React Native uses by default, optimised for mobile with bytecode compiled at build time.
JSI
JavaScript Interface: a C++ API letting JavaScript call native objects directly and synchronously, without a JSON bridge.
Fabric
The New Architecture renderer that builds the UI tree in C++ and supports concurrent React.
TurboModule
A New Architecture native module, loaded lazily and typed through a Codegen spec.
Codegen
A build-time tool that turns typed JavaScript specs into C++ and platform interfaces for native code.
Shadow tree
Fabric's C++ copy of the UI tree, used to calculate layout before changes reach real native views.
Yoga
The cross-platform layout engine that implements Flexbox for React Native components.
Metro
The JavaScript bundler and development server that serves your code to the app and powers Fast Refresh.
Worklet
A small JavaScript function Reanimated runs on the UI thread, so animations stay smooth while JavaScript is busy.
Config plugin
An Expo function that edits native project files during prebuild, so native setup stays reproducible.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.