React Native Fundamentals: How It Renders, the New Architecture, FlatList Performance and Common Build Errors

Key takeaways

React Native runs your React code in a JavaScript engine and renders real platform views. Understanding that split, and the native build layer underneath it, explains most of the performance problems and build errors you will hit.

What React Native actually is

React Native lets you write React components that render to real iOS and Android views. It is not a WebView wrapper, and it is not a compiler that turns JavaScript into Swift or Kotlin. At runtime there are two worlds:

  • JavaScript: your components, state, and business logic run in a JavaScript engine (Hermes by default since React Native 0.70). Hermes compiles your bundle to bytecode at build time, which shortens startup compared with parsing JavaScript on the device.
  • Native: React Native’s renderer turns your component tree into platform views, and native modules expose device features (camera, storage, sensors) to JavaScript.

How these two sides talk has changed. The legacy architecture sent serialized JSON messages across an asynchronous “bridge”, which added latency and made some interactions (measuring a view, synchronous layout) awkward. The New Architecture replaces it with JSI, a C++ interface that lets JavaScript hold direct references to native objects:

  • Fabric is the new renderer. It can do layout synchronously when needed and supports React’s concurrent features.
  • TurboModules are native modules loaded lazily and called through JSI instead of the bridge.

The New Architecture became the default for new projects in React Native 0.76, and later releases removed the option to switch back. For you as an app developer this mostly matters in one way: native libraries must support it. Most maintained libraries do; abandoned ones are where upgrade problems come from.

Understanding the JS/native split explains most React Native performance advice. Anything that blocks the JavaScript thread (a large JSON.parse, a synchronous loop over thousands of items, an expensive re-render) delays touch handling and JS-driven animations, even though native views keep drawing.


Expo, development builds, and prebuild

For new projects, the React Native team recommends starting with a framework, and Expo is the main one.

npx create-expo-app@latest MyApp
cd MyApp
npx expo start

There are three ways to run the result, and mixing them up causes a lot of confusion:

  1. Expo Go is a prebuilt app from the App Store/Play Store that loads your JavaScript bundle. It is great for the first hour, but it contains only the native modules bundled in the Expo SDK.
  2. A development build is your own app binary, containing exactly the native dependencies in your package.json, plus Expo’s dev tools. You create it with npx expo run:ios / npx expo run:android locally, or with EAS Build in the cloud.
  3. A release build is what you ship.

The moment you install a library with its own native code that is not in Expo Go, you need a development build. Before that switch, the typical failure on the New Architecture looks like:

Invariant Violation: TurboModuleRegistry.getEnforcing(...): '<ModuleName>' could not be found.
Verify that a module by this name is registered in the native binary.

The JavaScript is correct; the native half of the library simply is not in the binary you are running. The same error appears in a bare project if you add a native dependency and reload JavaScript without rebuilding the app.

Prebuild and why hand-editing ios/ and android/ backfires

npx expo prebuild generates the ios/ and android/ directories from app.json and the config plugins of your installed libraries. Expo calls this Continuous Native Generation: the native projects are build artifacts, not source code.

This is the mistake I have seen most often in Expo projects that grew up: someone opens ios/ in Xcode, adds a permission string or entitlement by hand, and it works. Weeks later, an SDK upgrade or npx expo prebuild --clean regenerates the native projects and the change silently disappears, usually surfacing as a crash when a permission prompt is missing. If you use prebuild, native changes belong in app.json or a config plugin. If you truly need to own the native code, commit ios/ and android/ and stop running prebuild; both approaches work, but mixing them does not.

EAS Build, Update and Submit, Expo Router, and push notifications are covered in detail in the Expo guide. This article focuses on React Native itself.


Core components and the rules that differ from the web

WebReact NativeNote
<div><View>No text allowed directly inside
<p>, <span><Text>The only component that can render strings
<img><Image>Remote images need explicit width and height
<input><TextInput>
<button><Pressable>Preferred over the older Touchable* components
<ul> + map<FlatList>Virtualized; use for anything long
import { View, Text, Image, Pressable, StyleSheet } from 'react-native';

type User = { name: string; avatar: string };

export function ProfileCard({ user, onFollow }: { user: User; onFollow: () => void }) {
  return (
    <View style={styles.card}>
      <Image source={{ uri: user.avatar }} style={styles.avatar} />
      <Text style={styles.name}>{user.name}</Text>
      <Pressable
        onPress={onFollow}
        style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
      >
        <Text style={styles.buttonText}>Follow</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  card: { padding: 16, backgroundColor: '#fff', borderRadius: 12 },
  avatar: { width: 64, height: 64, borderRadius: 32 },
  name: { fontSize: 18, fontWeight: '600', marginTop: 8 },
  button: { backgroundColor: '#007AFF', padding: 12, borderRadius: 8, marginTop: 12 },
  buttonPressed: { opacity: 0.7 },
  buttonText: { color: '#fff', textAlign: 'center', fontWeight: '600' },
});

Rules that trip web developers:

  • All strings must be inside <Text>. Otherwise you get Text strings must be rendered within a <Text> component. The classic way to trigger it without writing a bare string is {count && <Badge />} when count is 0: React renders 0, a number, directly inside a <View>. Use {count > 0 && <Badge />}.
  • No cascade. Styles don’t inherit from parent Views. Only nested <Text> inherits text styles from its parent <Text>.
  • Units are density-independent pixels, written as plain numbers. Percent strings work for sizes; there is no rem or em.
  • Flexbox is the only layout system, and the defaults differ from CSS: flexDirection is column, and flex: 1 means “fill the available space along the main axis”. A screen that renders blank is very often a parent missing flex: 1, so it has zero height.

Lists and FlatList performance

FlatList only renders the rows near the viewport and unmounts the rest, which is why it handles long lists that a ScrollView + map would choke on. It is also where most React Native performance complaints come from, because each visible row is a React component that can re-render too often.

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

type Item = { id: string; title: string };
const ROW_HEIGHT = 56;

const Row = memo(function Row({ item, onPress }: { item: Item; onPress: (id: string) => void }) {
  return (
    <View style={{ height: ROW_HEIGHT, justifyContent: 'center', paddingHorizontal: 16 }}>
      <Text onPress={() => onPress(item.id)}>{item.title}</Text>
    </View>
  );
});

export function ItemList({ items, onSelect, selectedId }: {
  items: Item[]; onSelect: (id: string) => void; selectedId: string | null;
}) {
  const renderItem = useCallback(
    ({ item }: { item: Item }) => <Row item={item} onPress={onSelect} />,
    [onSelect],
  );

  return (
    <FlatList
      data={items}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })}
      extraData={selectedId}
      onEndReachedThreshold={0.5}
    />
  );
}

Why each piece is there:

  • keyExtractor gives rows stable identities. Without stable keys, inserting at the top forces React to treat every row as changed.
  • memo on the row plus a stable renderItem means a parent state change (a search box keystroke, say) does not re-render every visible row. An inline arrow renderItem is fine on its own; what matters is that the row component and the props you pass it are stable.
  • getItemLayout tells the list each row’s size up front, so it doesn’t have to measure rows asynchronously. This makes scrollToIndex reliable and removes blank flashes during fast scrolls. Only use it when rows really are fixed height; wrong values produce overlapping or misplaced rows.
  • extraData: FlatList is a pure component. If renderItem depends on something outside data (like selectedId), the list won’t re-render when it changes unless you pass it as extraData. The symptom is “I tapped a row and the highlight didn’t move”.

Two warnings worth recognizing. VirtualizedList: You have a large list that is slow to update means your rows are expensive to render, so memoize them and move work out of render. VirtualizedLists should never be nested inside plain ScrollViews with the same orientation means you wrapped a FlatList in a ScrollView, which disables virtualization entirely; put the extra content in ListHeaderComponent / ListFooterComponent instead.

Also: never judge list performance in development mode. Dev builds run extra checks and unminified code and are noticeably slower; profile a release build before optimizing. If a well-tuned FlatList still isn’t enough, Shopify’s FlashList, which recycles row views instead of mounting new ones, is the usual next step.

For grouped data, SectionList has the same API plus sections and renderSectionHeader.


Platform-specific code

You will need it, for UI conventions and for API differences.

import { Platform, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  card: {
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, shadowOffset: { width: 0, height: 2 } },
      android: { elevation: 3 },
    }),
  },
});

if (Platform.OS === 'android') {
  // Android-only behavior, e.g. hardware back button handling
}

For larger differences, use file extensions: DatePicker.ios.tsx and DatePicker.android.tsx, imported as ./DatePicker. Metro picks the right file per platform at bundle time.

Other areas where the platforms genuinely differ and deserve testing on both: keyboard handling (KeyboardAvoidingView usually needs behavior="padding" on iOS and different handling on Android), safe areas around notches and system bars (use react-native-safe-area-context), and permissions, which Android and iOS request at different times and with different “don’t ask again” semantics.


Two mainstream options:

  • Expo Router: file-based routing built on top of React Navigation. The default in new Expo projects, and covered in the Expo guide.
  • React Navigation directly, configured in code:
import { Text } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';

type RootStack = { Home: undefined; Detail: { postId: string } };
const Stack = createNativeStackNavigator<RootStack>();

function HomeScreen({ navigation }: NativeStackScreenProps<RootStack, 'Home'>) {
  return <Text onPress={() => navigation.navigate('Detail', { postId: '123' })}>Open post</Text>;
}

function DetailScreen({ route }: NativeStackScreenProps<RootStack, 'Detail'>) {
  return <Text>Post {route.params.postId}</Text>;
}

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Detail" component={DetailScreen} options={{ title: 'Post' }} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

The native stack uses the platform’s own navigation controllers, so transitions and swipe-back gestures behave natively. Typing the param list (RootStack) is worth the few extra lines: a missing or misspelled route param becomes a compile error instead of undefined at runtime.

One behavior that surprises web developers: pushing a screen does not unmount the previous one. HomeScreen stays mounted under DetailScreen, so a useEffect that should re-run “when the user comes back” won’t. Use useFocusEffect from React Navigation for that.


Animations and the JS thread

The Animated API computes frames in JavaScript by default. If the JS thread is busy, animations stutter. Setting useNativeDriver: true hands the animation to the native side so it keeps running smoothly, but only for non-layout properties like transform and opacity. Animating layout properties with the native driver fails with an error such as Style property 'width' is not supported by native animated module.

For gesture-driven or complex animations, react-native-reanimated runs animation logic on the UI thread, which is why it is the standard choice for anything interactive.


Storage and state

  • AsyncStorage (@react-native-async-storage/async-storage) is an unencrypted key-value store. Fine for preferences and caches; not for tokens.
  • Secure storage (Keychain on iOS, Keystore-backed storage on Android, e.g. expo-secure-store) is where auth tokens belong.
  • SQLite (e.g. expo-sqlite) for structured or queryable data.

State management works the same as in React web: useState and context for local state, Zustand or Redux Toolkit for shared client state, and TanStack Query for server state. Zustand’s persist middleware accepts AsyncStorage via createJSONStorage(() => AsyncStorage).

One persistence pitfall: persisted state is loaded asynchronously, so on the first render your store holds initial values. If navigation decides “logged in or not” on that first render, users briefly see the login screen, or get redirected there. Wait for rehydration (keep the splash screen up) before deciding.


Common build and bundler errors

Most time lost in React Native goes into the build layer rather than React code. The ones I hit most often, and what they actually mean:

Unable to resolve module <name> from <file> (Metro). The import path is wrong, the package isn’t installed, or Metro’s cache is stale after switching branches or changing Babel config. Restart with a clean cache: npx expo start --clear in Expo, npx react-native start --reset-cache in a bare project.

Changes to babel.config.js, .env handling, or Metro config have no effect. Also cache. Same fix.

CocoaPods could not find compatible versions for pod ... (iOS). Your local spec repo is out of date or two libraries require incompatible versions of a shared pod. Try cd ios && pod install --repo-update. If you use prebuild, npx expo prebuild --clean regenerates the Podfile from scratch. After any native dependency change in a bare project, pod install has to run again before building.

SDK location not found. Define a valid SDK location with an ANDROID_HOME environment variable... (Android). Gradle can’t find the Android SDK. Set ANDROID_HOME (or create android/local.properties with sdk.dir=...).

It builds, but a native module is null or “could not be found”. You added a native dependency without rebuilding the binary, or you are in Expo Go. Rebuild the development build.

My rule of thumb from upgrade weeks: when something breaks after a React Native or Expo SDK upgrade, check the library list before touching your own code. Run npx expo install --check in Expo projects to align dependency versions with the SDK, and look for libraries that haven’t had a release since before the New Architecture became the default. An unmaintained native library is far more likely to be the cause than your components.