Home / Articles / Bare React Native, Expo or Flutter: Matching the Mobile Stack to Your Team

This article is published in English.

Bare React Native, Expo or Flutter: Matching the Mobile Stack to Your Team

A practical comparison of bare React Native, Expo and Flutter: what each looks like in code, where each hurts, and a five-question framework for choosing between them.

2627 words

Choosing a cross-platform mobile stack used to be a question of which compromise you could live with. Today React Native, Expo and Flutter can all ship apps that feel native, run smoothly and scale to very large audiences, so raw speed rarely decides the matter. What decides it is fit: the skills your team already has, the code you already own, and how quickly you need to get to the stores. This article treats Expo as a choice in its own right rather than a footnote to React Native, walks through what each option looks like in practice, and ends with a short set of questions you can use to pick one.

What has changed in all three stacks

Several structural weaknesses that shaped older comparisons have been engineered away:

  • React Native's New Architecture is the default. It moves away from the legacy asynchronous bridge, so JavaScript and native code communicate more directly. Calls into native modules are considerably cheaper as a result.
  • Flutter's Impeller renderer replaced Skia as the default on mobile. Impeller compiles its shaders ahead of time, which removes the well-known jank caused by shader compilation on first run and keeps frame rates more consistent.
  • Expo has become the standard way to build React Native apps. It is no longer a beginner's sandbox but a production toolchain used by large companies.

Published benchmarks tend to show Flutter slightly ahead on rendering throughput in animation-heavy interfaces, and React Native ahead on startup time, memory footprint and native I/O. Treat any specific numbers with caution, because they vary widely with the app and the device. For most products the gap is simply no longer what determines the outcome. If you want the view from teams that have lived with these choices for a while, our article on the Flutter versus React Native decisions that only surface in production covers the long-term side.

Bare React Native: maximum control, maximum responsibility

React Native lets you write the interface in JavaScript or TypeScript with React, while the framework renders genuine platform components on iOS and Android. There is no WebView and no custom canvas: a View becomes a native view and a Text becomes a native text component.

The counter below shows the basic shape of an app on the New Architecture with the Fabric renderer. State lives in a useState hook, the button is a Pressable, and styles are declared once with StyleSheet.create so they can be validated and reused.

// App.tsx — React Native (New Architecture, Fabric)
import React, { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <View style={styles.container}>
      <Text style={styles.counter}>{count}</Text>
      <Pressable style={styles.button} onPress={() => setCount(c => c + 1)}>
        <Text style={styles.buttonText}>Tap me</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
  counter: { fontSize: 48, fontWeight: '700', marginBottom: 24 },
  button: { backgroundColor: '#2563eb', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 },
  buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});

Nothing in this snippet is specific to the New Architecture; the same component code runs on both. The architecture change happens underneath, in how the renderer and native modules talk to JavaScript.

Strengths

  • Real native widgets. You get the platform's own controls, accessibility behavior and text rendering rather than an approximation.
  • The largest talent pool. JavaScript, TypeScript and React skills are far more common than those for the other options.
  • Full native access. You own the ios/ and android/ directories, so you can drop into Swift, Kotlin, Objective-C or Java whenever you need to.
  • A huge ecosystem. npm offers more third-party packages than pub.dev, and most mobile SDKs for payments, maps and analytics ship official React Native wrappers.
  • Faster native calls. Fabric, TurboModules and JSI remove the old asynchronous bridge bottleneck, so calls into native code are close to immediate.
  • Code sharing with the web. Through React Native Web, a team with an existing React web app can reuse much of its logic and even some components.

Weaknesses

  • You own the native build tooling. Xcode, Gradle and CocoaPods version mismatches and native dependency conflicts remain a frequent source of pain.
  • Cross-platform consistency takes effort. Because the framework maps to native widgets on purpose, iOS and Android will look different unless you design for consistency.
  • Delivery infrastructure is on you. CI/CD, code signing and over-the-air updates are non-trivial to build from scratch, which is exactly the gap Expo fills.
  • Uneven third-party modules. Maintenance quality among native modules ranges from excellent to abandoned.

Practical advice

  • Avoid starting a new app in bare React Native unless you have a concrete reason, such as a very particular native SDK or an existing native app you are migrating piece by piece. Start with Expo and generate native projects only when you need them.
  • Use react-native-reanimated and react-native-gesture-handler for performance-sensitive interactions. They run animations and gestures on the UI thread, so a busy JavaScript thread does not cause dropped frames.
  • Keep Hermes enabled; it is the default engine. It compiles JavaScript to bytecode ahead of time, which reduces startup time and memory use noticeably compared with JavaScriptCore.

Expo: React Native with the infrastructure included

Expo is a framework and a set of services built on top of React Native. Its old reputation was a restricted environment with no access to native code. That no longer holds: through prebuild, also called continuous native generation (CNG), Expo works with the whole native module ecosystem.

The screen below uses Expo Router, which maps files in the app/ directory to routes in the same way Next.js does for the web. app/index.tsx is the home route, and router.push('/profile') navigates to the file that defines /profile. Deep links and web URLs come from the same structure.

// app/index.tsx — Expo Router (file-based routing)
import { View, Text, Pressable } from 'react-native';
import { useRouter } from 'expo-router';

export default function HomeScreen() {
  const router = useRouter();

  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
      <Text style={{ fontSize: 24, fontWeight: '600' }}>Welcome</Text>
      <Pressable onPress={() => router.push('/profile')}>
        <Text style={{ color: '#2563eb' }}>Go to Profile</Text>
      </Pressable>
    </View>
  );
}

Creating and running a project takes three commands. The first scaffolds the app, and npx expo start launches the development server so you can open the app on a device or simulator.

# Spin up a new project in under a minute
npx create-expo-app@latest my-app
cd my-app
npx expo start

Strengths

  • No native tooling to get started. For most of development you do not need Xcode or Android Studio; you can test on a physical device with Expo Go or a custom development client.
  • EAS Build. iOS and Android builds run in the cloud from any operating system, so you can produce an iOS build from Windows or Linux.
  • EAS Update. Over-the-air updates deliver JavaScript and asset changes to users immediately, without waiting for store review, as long as no native code changes.
  • Expo Router. File-based routing with deep linking and universal navigation for web and native is available out of the box.
  • A large curated SDK. Packages such as expo-camera, expo-notifications, expo-location and expo-image are integrated, documented and version-matched so they work together.
  • Config plugins. You can change native project files such as Info.plist and AndroidManifest.xml declaratively from app.json instead of editing them by hand, which keeps CI builds reproducible.
  • No lock-in. It is still React Native underneath. Running npx expo prebuild generates the native folders whenever you need full native control.

Weaknesses

  • Niche SDKs need extra work. A few specialized native SDKs still call for writing your own config plugin or native module, slightly more work than in bare React Native, though the gap keeps shrinking.
  • The Expo Go trap. Relying heavily on Expo Go can hide the fact that a custom native module will not run until you create a development build. This regularly catches newcomers.
  • Service costs. EAS Build and Update have free tiers, but serious production teams usually move to paid plans, a cost that bare React Native with self-hosted CI avoids.
  • Inherited limits. As a layer over React Native, Expo keeps its drawbacks: platform UI divergence and a JavaScript thread that can become a bottleneck under heavy computation.

Practical advice

  • Run npx expo prebuild when you need a native module that Expo does not cover. It creates the ios/ and android/ folders on demand, so native code is always within reach. Our article on treating native folders as build output with Expo prebuild and CNG explains the workflow in depth.
  • Use EAS Update for hotfixes, not for features that change native behavior. App store rules restrict what downloaded code may change, and shipping new functionality disguised as an OTA update risks rejection; read the current Apple and Google policies before relying on it.
  • Adopt Expo Router at the start of a new project. Retrofitting file-based routing onto an existing navigation setup is painful.
  • Run expo doctor before each release build. It detects dependency and version mismatches that would otherwise show up as cryptic native build failures.

Flutter: owning every pixel

Flutter takes a fundamentally different route. Instead of mapping to platform widgets, it draws the whole interface itself with its Impeller engine, and Dart code is compiled ahead of time to native ARM or x86 machine code.

The Dart counter below mirrors the React Native example. MyApp wraps the app in MaterialApp, CounterScreen is a StatefulWidget whose state class holds _count, and pressing the button calls setState, which tells Flutter to rebuild that subtree with the new value.

// main.dart
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: CounterScreen());
  }
}

class CounterScreen extends StatefulWidget {
  const CounterScreen({super.key});

  @override
  State<CounterScreen> createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('$_count', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold)),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () => setState(() => _count++),
              child: const Text('Tap me'),
            ),
          ],
        ),
      ),
    );
  }
}

The command-line workflow covers the whole lifecycle: create the project, run it with hot reload during development, and produce release artifacts for Google Play (an app bundle) and the App Store (an IPA).

flutter create my_app
cd my_app
flutter run          # hot reload in under a second
flutter build appbundle --release   # Android
flutter build ipa --release         # iOS

Strengths

  • Identical UI everywhere. Because Flutter renders every widget itself, behavior and appearance match across iOS, Android, web and desktop without platform quirks.
  • Strong animation performance. With shaders compiled ahead of time and direct GPU access through Impeller, Flutter tends to lead frame-rate comparisons for complex, animation-heavy interfaces.
  • Six targets from one codebase. iOS, Android, web, Windows, macOS and Linux all build from the same Dart code.
  • Excellent tooling. DevTools, hot reload and a consistent, well-documented widget catalog make the daily development loop pleasant.
  • No interpreter in the runtime path. Ahead-of-time compilation to machine code means there is no JavaScript engine or bridge at runtime.
  • Proven at scale. Google backs it, and large production apps such as Google Pay, BMW's apps and Alibaba's Xianyu use it.

Weaknesses

  • Dart is a smaller ecosystem. Hiring is harder than for JavaScript or TypeScript, and even experienced developers typically need a few weeks to become productive.
  • It can feel slightly non-native. Since it does not use platform widgets, attentive users may notice small differences in behavior, although this has narrowed considerably.
  • Larger binaries. The Flutter engine ships inside every app, so bundles usually come out bigger than an equivalent React Native app.
  • Fewer niche packages. pub.dev is good but shallower than npm, particularly for wrappers around specialized native SDKs.
  • No reuse of web React code. An organization with an established React web codebase gets nothing to reuse on the web.

Practical advice

  • Turn on flutter analyze with strict lint rules from the first day. Dart's null safety is a real advantage, but only if you avoid undermining it with dynamic types everywhere.
  • Choose a state management approach such as Riverpod or Bloc for anything beyond a prototype; setState alone does not scale to a real app.
  • Profile with the DevTools Performance view before concluding you have a jank problem. Impeller already eliminated most historical shader-compilation stutter.
  • If the web matters, test Flutter Web early. Its renderers behave differently from mobile, and bundle size can be surprising. At the time of writing, Flutter has been consolidating on its CanvasKit-based renderers, so check the current documentation for which options are still supported.

Five questions that settle the choice

Work through these in order. The first question with a clear answer usually decides.

  • Does your team already work in React and JavaScript? If yes, stay in the React Native ecosystem and use Expo by default. If not, and you are free to choose, Flutter and Expo are both sound; pick the language your team would rather learn.
  • Do you need pixel-identical design or heavy custom animation, as in games, creative tools or visualization-heavy apps? Flutter is the stronger default.
  • Do you want to share code or components with an existing React web app? React Native, through React Native Web, has a real advantage; Flutter starts over on the web.
  • Do you need to ship JavaScript-only fixes without store review, or build iOS apps without a Mac? Expo's EAS Update and EAS Build address both directly.
  • Are you embedding cross-platform screens in a large existing native app? Bare React Native, or Flutter's add-to-app support, suits that better than a fresh Expo project.

Recommendations

  • For a React or JavaScript team starting a new app, choose Expo. It removes most historical React Native pain around native tooling, CI/CD and OTA updates while keeping full native power available when you need it.
  • Choose Flutter when interface polish, animation performance and reach across mobile, desktop and web matter more than reusing the JavaScript ecosystem, or when your team has no strong language preference and is starting fresh.
  • Reserve bare React Native for situations where directly owning the native projects is a requirement, typically a large existing native codebase or unusual native integration requirements.

Wrapping up

The three stacks have converged enough in performance and maturity that framework capability is rarely the bottleneck. The deciding factors are the people on your team, the code you already have and how fast you need to ship. Think of Expo as the default way to use React Native, reserve bare React Native for cases where owning the native projects is the point, and pick Flutter when rendering control and multi-platform reach outweigh the value of staying in JavaScript. Whichever you choose, validate the riskiest part of your app, whether a native SDK, a complex animation or the web build, in the first weeks rather than at release time.