Originally published on devanddeliver.com.
Introduction
React Native's New Architecture has closed most of the performance gap that made Flutter the go-to for animation-heavy apps - and if your team already writes TypeScript and React, the case for switching languages has never been weaker. We cover the architecture shift, real pain points on both sides, and side-by-side code for the patterns you will actually build.
Versions covered: React Native 0.85.3 · Expo SDK 56 · Flutter 3.44.0
Last verified: May 2026
The short version
If your team writes JavaScript or TypeScript and already knows React: React Native. The knowledge transfer is direct, the ecosystem is enormous, and the New Architecture has erased most of the performance arguments that used to favour Flutter. You are building mobile apps with the same language, the same mental model, the same tooling, and - increasingly - the same people who build your web product.
If you are starting from zero with no existing React knowledge, care deeply about animation consistency at 120fps, or are building an app where pixel-perfect parity across iOS and Android is a hard requirement: Flutter. Dart is a faster learning curve than many engineers expect, and Flutter's rendering model removes an entire class of "why does this look different on Android" problems.
This is not a close race decided by benchmarks. It is a question of team fit and project requirements. For most product companies and agencies building TypeScript web stacks, React Native is the natural extension. Flutter is the right call when you are specifically optimising for what it is best at.
The fundamental difference
This is the one thing to understand before anything else.
React Native runs your JavaScript code in a JS engine (Hermes, by default since RN 0.70) and renders actual native UI components - UIButton on iOS, android.widget.Button on Android. Your code is JavaScript. Your UI is native. The New Architecture (mandatory as of RN 0.82) replaced the old async JSON bridge with JSI - direct C++ bindings that allow JS to call native code synchronously and hold references to native objects.
Flutter compiles Dart code to native ARM and x86 binaries and renders every pixel itself using the Skia/Impeller graphics engine. There are no native platform widgets involved. A Flutter Button is drawn on a canvas, not an iOS UIButton. This is why Flutter has perfect cross-platform pixel parity - and why it has to work harder to look native on each platform.
Neither approach is wrong. They have different trade-offs:
| React Native | Flutter | |
|---|---|---|
| UI layer | Native platform widgets | Custom-rendered canvas |
| Cross-platform parity | Close, with platform quirks | Pixel-perfect |
| "Looks native" | Yes -uses actual native components | Approximated (very well) |
| Native component access | Direct | Via PlatformView (has overhead) |
| Language | TypeScript / JavaScript | Dart |
| Compilation | JS + native modules | Fully compiled to native |
React Native in 2026: the New Architecture story
For several years, the main argument for Flutter over React Native was performance. The async bridge at the heart of the old architecture caused frame drops during complex animations and made synchronous native access impossible. That argument is no longer valid in 2026.
The timeline:
- RN 0.76 (October 2024): New Architecture became the default for new projects
- RN 0.82 (October 2025): Legacy Paper renderer fully removed - New Architecture is the only option
- RN 0.84 (February 2026): Hermes V1 becomes default; precompiled binaries on iOS; legacy architecture code fully deleted
- RN 0.85 (April 2026): Improved animation backend, DevTools improvements, Metro TLS support
- Expo SDK 55 (February 2026): Legacy architecture support removed from Expo
The New Architecture ships four interconnected pieces:
- JSI (JavaScript Interface) - replaces the JSON bridge with direct C++ bindings. JS can now call native methods synchronously and hold native object references, enabling patterns that were impossible before (like react-native-reanimated's UI-thread animations and react-native-vision-camera's frame processors).
- Fabric - new concurrent renderer. Supports React 18/19 concurrent features: Suspense, transitions, startTransition. Layout calculations run on multiple threads instead of blocking the JS thread.
- TurboModules - lazy-loaded native modules. The old architecture loaded every registered native module at startup regardless of whether you used it. TurboModules load on first access, improving startup time meaningfully.
- Codegen - auto-generates type-safe C++ binding code from TypeScript specs. Write your native module interface in TypeScript, Codegen produces the platform glue code.
The practical result: React Native's animation and interaction performance on New Architecture is genuinely close to Flutter for the vast majority of application patterns. The gap remains at the extremes - very heavy custom animations, games, frame-by-frame camera processing - but for business apps, the New Architecture is good enough to make performance a non-issue in the RN vs Flutter decision.
Start with Expo
In 2026, the right way to start a new React Native project is Expo. The old distinction between "Expo managed" and "bare workflow" has largely dissolved. You can now:
- Use Expo's managed workflow with full native code access via config plugins - no ejecting required for most customisations
- Run EAS Build (Expo Application Services) for CI/CD, without maintaining your own macOS build agent for iOS
- Ship over-the-air updates via EAS Update without app store review for JS-layer changes
- Use Expo Router for file-based routing that mirrors Next.js conventions - if your team already knows Next.js, the mental model is identical
Expo SDK 56 targets React Native 0.85 and React 19.2.3. Legacy architecture support was removed in SDK 55 - there is no managed path back to the old bridge.
The only reasons to skip Expo in 2026: you have a highly custom native module that Config Plugins cannot accommodate, or you are integrating into an existing native app that already has its own build infrastructure.
Code comparison
VIRTUALISED LIST WITH PULL-TO-REFRESH
React Native:
import { useState } from 'react'; import { FlatList, RefreshControl, Text, View, StyleSheet } from 'react-native'; type Item = { id: string; title: string }; export default function ItemList({ items }: { items: Item[] }) { const [refreshing, setRefreshing] = useState(false); const handleRefresh = async () => { setRefreshing(true); await fetchItems(); // your data fetch setRefreshing(false); }; return ( <FlatList data={items} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <View style={styles.row}> <Text>{item.title}</Text> </View> )} refreshControl={ <RefreshControl refreshing={refreshing} onRefresh={handleRefresh} /> } /> ); } const styles = StyleSheet.create({ row: { padding: 16, borderBottomWidth: 1, borderBottomColor: '#eee' }, }); Flutter:
class ItemList extends StatefulWidget { final List<Item> items; const ItemList({required this.items, super.key}); @override State<ItemList> createState() => _ItemListState(); } class _ItemListState extends State<ItemList> { Future<void> _handleRefresh() async { await fetchItems(); // your data fetch } @override Widget build(BuildContext context) { return RefreshIndicator( onRefresh: _handleRefresh, child: ListView.builder( itemCount: widget.items.length, itemBuilder: (context, index) { final item = widget.items[index]; return ListTile(title: Text(item.title)); }, ), ); } } Note: Flutter's ListView is not virtualised by default - ListView.builder is the lazy equivalent of RN's FlatList. For short, static lists ListView is fine; for long or dynamic lists always use ListView.builder.
FORM WITH VALIDATION
React Native - no built-in form handling; react-hook-form + zod is the community standard:
import { useForm, Controller } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { TextInput, Text, Pressable, View } from 'react-native'; const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Enter a valid email'), }); type FormData = z.infer<typeof schema>; export default function SignUpForm() { const { control, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(schema), }); const onSubmit = (data: FormData) => console.log(data); return ( <View> <Controller name="name" control={control} render={({ field: { onChange, value } }) => ( <TextInput placeholder="Name" onChangeText={onChange} value={value} /> )} /> {errors.name && <Text>{errors.name.message}</Text>} <Controller name="email" control={control} render={({ field: { onChange, value } }) => ( <TextInput placeholder="Email" onChangeText={onChange} value={value} keyboardType="email-address" /> )} /> {errors.email && <Text>{errors.email.message}</Text>} <Pressable onPress={handleSubmit(onSubmit)}> <Text>Sign up</Text> </Pressable> </View> ); } Flutter - form validation is built into the SDK:
final _formKey = GlobalKey<FormState>(); final _nameController = TextEditingController(); final _emailController = TextEditingController(); Form( key: _formKey, child: Column( children: [ TextFormField( controller: _nameController, decoration: const InputDecoration(labelText: 'Name'), validator: (value) => (value == null || value.isEmpty) ? 'Name is required' : null, ), TextFormField( controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress, validator: (value) => (value != null && value.contains('@')) ? null : 'Enter a valid email', ), ElevatedButton( onPressed: () { if (_formKey.currentState!.validate()) { // submit } }, child: const Text('Sign up'), ), ], ), ) Flutter's built-in Form + TextFormField + validator is a genuine ergonomic win. React Native requires three separate packages (react-hook-form, zod, @hookform/resolvers) to achieve equivalent functionality. Both patterns work well; Flutter's requires less setup and fewer dependencies.
REST API WITH LOADING STATE
React Native with TanStack Query (v5 - production standard for client-side data fetching):
import { useQuery } from '@tanstack/react-query'; import { FlatList, ActivityIndicator, Text } from 'react-native'; type Post = { id: number; title: string }; async function fetchPosts(): Promise<Post[]> { const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10'); if (!res.ok) throw new Error('Network response failed'); return res.json(); } export default function PostsScreen() { const { data, isLoading, error } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, }); if (isLoading) return <ActivityIndicator />; if (error) return <Text>Error: {error.message}</Text>; return ( <FlatList data={data} keyExtractor={(post) => String(post.id)} renderItem={({ item }) => <Text>{item.title}</Text>} /> ); } Flutter with FutureBuilder (built-in) or Riverpod for more complex state:
Future<List<Post>> fetchPosts() async { final response = await http.get( Uri.parse('https://jsonplaceholder.typicode.com/posts?_limit=10'), ); if (response.statusCode != 200) throw Exception('Failed to load posts'); final List data = json.decode(response.body); return data.map((e) => Post.fromJson(e)).toList(); } FutureBuilder<List<Post>>( future: fetchPosts(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const CircularProgressIndicator(); } if (snapshot.hasError) { return Text('Error: ${snapshot.error}'); } return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, i) => ListTile(title: Text(snapshot.data![i].title)), ); }, ) FILE-BASED NAVIGATION (EXPO ROUTER)
One of the biggest React Native improvements in recent years is Expo Router - file-based routing that works exactly like Next.js App Router:
app/ _layout.tsx # root layout (NavigationContainer, providers) (tabs)/ _layout.tsx # tab bar definition index.tsx # Home tab → / profile.tsx # Profile tab → /profile post/ [id].tsx # Dynamic route → /post/123 // app/(tabs)/_layout.tsx import { Tabs } from 'expo-router'; export default function TabLayout() { return ( <Tabs> <Tabs.Screen name="index" options={{ title: 'Home' }} /> <Tabs.Screen name="profile" options={{ title: 'Profile' }} /> </Tabs> ); } // app/post/[id].tsx import { useLocalSearchParams } from 'expo-router'; export default function PostScreen() { const { id } = useLocalSearchParams<{ id: string }>(); return <Text>Post {id}</Text>; } If your team already works with Next.js, this is almost zero learning curve.
Performance
THE HEADLINE: THE GAP IS MOSTLY CLOSED
Flutter still has a raw advantage in sustained 60/120fps animations. Because it draws every pixel itself via Impeller (the newer Skia replacement, default on iOS since Flutter 3.10 and Android since 3.16), there is no negotiation with the platform's native rendering pipeline. Frame timing is consistent.
React Native with New Architecture is very close for typical application patterns. The JSI bridge is effectively zero-cost for most operations. react-native-reanimated v4 (the animation library) runs entirely on the UI thread via worklets - it does not touch the JS thread during animation. For the ~95% of apps that do not involve custom particle systems or frame-by-frame video processing, React Native's performance is not a limiting factor.
Where Flutter still pulls ahead: applications with long lists of complex custom-rendered items, multi-step transitions with many animated properties, or intensive canvas work. Where React Native has the edge: anything that embeds native platform components - WebViews, native maps, video players. Flutter wraps these in a PlatformView, which renders through a separate texture layer and can cause jank or visual glitches in complex layouts.
APP SIZE
Release build sizes vary by app complexity, but published benchmarks put minimal apps at:
| Android (AAB) | iOS (after App Thinning) | |
|---|---|---|
| React Native (bare, Hermes) | ~7-10 MB | ~20-25 MB |
| Flutter | ~5-8 MB | ~16-22 MB |
Flutter edges ahead on baseline size because there is no JS bundle and no Hermes runtime to ship. In practice, as app complexity grows both frameworks converge - the difference becomes negligible against the size of your own code, assets, and libraries.
React Native's Hermes engine compiles JavaScript to .hbc bytecode at build time rather than at runtime. This means zero JS parsing on the device at startup - a significant improvement to cold start time and a 3-7 MB reduction in effective bundle size compared to shipping raw JS.
Ecosystem
REACT NATIVE HAS THE DEEPER ECOSYSTEM
The React Native ecosystem benefits from a decade of JavaScript library development. Almost anything you need has a well-maintained package:
| Use case | React Native | Flutter |
|---|---|---|
| Animation | react-native-reanimated v4.3.1 | Built-in AnimationController |
| Camera + ML | react-native-vision-camera v5.0.10 (frame processors via worklets) | camera plugin (less capable) |
| Navigation | expo-router (file-based) / @react-navigation v7 | GoRouter (officially recommended) |
| State management | zustand v5, jotai, redux | Riverpod, Provider, Bloc |
| Server state / caching | @tanstack/react-query v5 | Riverpod + custom (no direct equivalent) |
| Forms | react-hook-form v7 + zod | Built into SDK |
| Canvas / 2D | react-native-skia | Built-in (Flutter is Skia-native) |
| OTA updates | EAS Update (Expo) | Code Push (third-party, or Shorebird) |
| CI/CD | EAS Build | Codemagic, GitHub Actions |
Flutter's ecosystem is smaller but growing fast. The packages that exist are generally well-maintained and backed by Google. The gap that matters most in practice: server-state caching (TanStack Query has no Flutter equivalent with comparable maturity) and camera/ML processing (vision-camera's frame processor worklets are a genuinely unique capability with no Flutter match).
Flutter's built-in advantage: Form, TextFormField, DatePicker, Drawer, and the full Material 3 and Cupertino widget libraries are part of the SDK with zero third-party dependencies.
Real pain points
REACT NATIVE
- Platform divergence adds up. Even with a shared codebase, you will write Platform.OS === 'ios' branches. Keyboard behaviour, shadow rendering, font rendering, scroll physics, StatusBar behaviour - iOS and Android handle all of these differently. In a real production app, 15–25% of your UI code will have platform-specific paths. This is rarely discussed in comparison articles.
- Metro bundler slows down on large projects. Cold start of the Metro bundler on a project with 1,000+ modules can take 30-60 seconds. Hot reload is fast once running, but that initial start is a daily tax on developer experience. There is active work on this (faster resolver, lazy bundling) but it has not been fully solved.
- The New Architecture migration was painful. RN 0.82 removed the legacy bridge entirely. Teams that relied on third-party native modules found that many packages were slow to ship TurboModule support. The findNodeHandle API was removed silently, breaking code that used it. Shadow thread removal changed layout timing in ways that caused subtle animation bugs. If you have a brownfield RN app older than 2024, budget 2-8 weeks for the migration depending on how much custom native code you have.
- Native crash debugging is immature. When Hermes or JSI crashes at the native layer, symbolication - mapping the crash back to ...
