React Native Interview Questions and Answers

Last updated:

Check out 45 of the most common React Native interview questions, then take an AI-powered practice interview

ReactJavaScriptExpoNative ModulesRedux
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

What actually happens when you render a <View> in React Native, and how does that differ from a <div> in React DOM?

BasicRendering Model

Answer

There is no DOM and no browser. Your JavaScript component tree is reconciled by React exactly as on the web, but the host config is the React Native renderer rather than react-dom. Under the New Architecture that renderer is Fabric, written in C++.

React produces a tree of immutable shadow nodes, Yoga computes flexbox layout on those nodes, and the mounting layer then creates or updates real platform views on the main thread: an android.view.ViewGroup on Android and a UIView on iOS. A <Text> becomes a native text view, an <Image> becomes an ImageView backed by Fresco on Android or a UIImageView backed by the iOS image pipeline. Styles are not CSS.

There is no cascade, no inheritance except a narrow case for nested <Text>, no media queries, no grid, and no calc(). Numeric style values are density independent units, dp on Android and points on iOS, never CSS pixels, which is why you write width: 200 and not width: '200px'. Yoga also differs from the web in its defaults: flexDirection is column, alignItems is stretch, position is relative, and flex: 1 expands to flexGrow: 1, flexShrink: 1, flexBasis: 0%.

Interviewers use this question to check whether you think in terms of native view hierarchies or in terms of HTML with different tags. The practical consequence is real: every extra nested <View> is an extra native view to measure, lay out, and draw, so flattening a deeply nested layout is a genuine performance fix, not a style preference.

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

export function Card({ title, subtitle }) {
  return (
    <View style={styles.card}>
      <Text style={styles.title} numberOfLines={1}>{title}</Text>
      <Text style={styles.subtitle}>{subtitle}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    // default flexDirection is 'column', not 'row' as on the web
    padding: 16,          // dp on Android, points on iOS
    borderRadius: 12,
    backgroundColor: '#fff',
  },
  title: { fontSize: 16, fontWeight: '600' },
  // colour does NOT cascade from card to subtitle
  subtitle: { fontSize: 13, color: '#6b7280' },
});

Key Points

  • React reconciles JS elements; Fabric turns them into native views
  • Yoga defaults differ from web CSS: column direction, stretch alignment
  • Values are dp or points, never CSS pixels; no cascade or inheritance
  • Every nested View costs a real native view, so flatten hot layouts
Q2

What does Metro do, and which metro.config.js options actually matter in a real project?

BasicTooling

Answer

Metro is React Native's bundler and dev server. It resolves your module graph, transforms each file with Babel, serialises everything into one JavaScript bundle, and serves it at http://localhost:8081/index.bundle. In development it also owns Fast Refresh and the source map endpoint; in release builds the Gradle and Xcode scripts invoke Metro to produce main.jsbundle or index.android.bundle, which Hermes then compiles to bytecode.

The config keys you touch in practice live under resolver, transformer, and serializer. resolver.sourceExts and resolver.assetExts are what you edit to support SVG through react-native-svg-transformer: you remove svg from assetExts and push it into sourceExts. resolver.extraNodeModules and watchFolders are what make a monorepo work, since Metro will not follow symlinks outside the project root without them. transformer.getTransformOverride or babelTransformerPath swaps the transformer, and transformer.minifierPath plus minifierConfig control release minification. Recent Metro versions resolve Node package exports by default, which is the usual cause of a library suddenly failing with a Unable to resolve module error after an upgrade, and unstable_enablePackageExports is the flag people toggle while debugging it. Two operational facts interviewers like: Metro caches aggressively in the OS temp directory, so npx react-native start --reset-cache is the first thing to try when a stale transform sticks, and Metro does not tree shake the way webpack or Rollup does, so importing a whole library rather than a submodule genuinely inflates your bundle.

// metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

const defaultConfig = getDefaultConfig(__dirname);

module.exports = mergeConfig(defaultConfig, {
  transformer: {
    babelTransformerPath: require.resolve('react-native-svg-transformer'),
  },
  resolver: {
    assetExts: defaultConfig.resolver.assetExts.filter((e) => e !== 'svg'),
    sourceExts: [...defaultConfig.resolver.sourceExts, 'svg'],
    // monorepo: let Metro see hoisted packages
    nodeModulesPaths: [`${__dirname}/../../node_modules`],
  },
  watchFolders: [`${__dirname}/../../packages`],
});
💡 Pro Tip: When a build fails right after an upgrade, run npx react-native start --reset-cache and delete node_modules/.cache before you start editing config. Most mystery resolution errors are stale Metro cache.
Q3

Why does StyleSheet.create exist if a plain object works, and what does it give you in recent versions?

BasicStyling

Answer

Historically StyleSheet.create registered your style object once and returned an integer ID, so repeated renders sent a number across the bridge instead of re-serialising the whole object. With JSI and Fabric that serialisation cost is largely gone, and in recent versions StyleSheet.create is effectively an identity function with extra type checking. It is still the right default for three reasons.

First, it gives you static typing: TypeScript validates every key against the ViewStyle, TextStyle, and ImageStyle unions, so a typo like fontWeigth or an invalid value like display: 'grid' fails at compile time instead of being silently ignored at runtime. Second, it hoists the object out of the render function, so you are not allocating a fresh style object on every render, which matters inside a FlatList renderItem that runs hundreds of times while scrolling. Third, it keeps styles greppable and colocated rather than scattered inline.

The style prop accepts arrays, and later entries win, which is how you compose a base style with a conditional override: style={[styles.button, disabled && styles.buttonDisabled]}. A falsy entry is ignored, so the && pattern is safe. The gotcha interviewers look for is inline object literals in list rows and animated components, since a new object identity defeats React.memo and forces the row to re-render. If you use a styling library, note that recent React Native releases and libraries like Unistyles or NativeWind still compile down to these same style objects, so understanding the primitive is not optional.

import { StyleSheet, Pressable, Text } from 'react-native';

function Button({ disabled, label, onPress }) {
  return (
    <Pressable
      onPress={onPress}
      disabled={disabled}
      // array composition: later entries win, falsy entries ignored
      style={({ pressed }) => [
        styles.base,
        pressed && styles.pressed,
        disabled && styles.disabled,
      ]}
    >
      <Text style={styles.label}>{label}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  base: { paddingVertical: 12, borderRadius: 8, backgroundColor: '#111827' },
  pressed: { opacity: 0.8 },
  disabled: { backgroundColor: '#9ca3af' },
  label: { color: '#fff', textAlign: 'center', fontWeight: '600' },
});

Key Points

  • Gives compile-time validation against ViewStyle, TextStyle, ImageStyle
  • Hoists style objects so renderItem does not allocate per row
  • style accepts arrays; later entries override, falsy entries are skipped
  • Inline object literals break React.memo on list rows
Q4

When should you use Pressable instead of TouchableOpacity or TouchableHighlight?

BasicCore Components

Answer

Pressable is the current recommendation and the Touchable family is legacy. Pressable exposes the full press lifecycle as separate callbacks, onPressIn, onPress, onPressOut, onLongPress, and gives you a style function that receives { pressed } so you decide the feedback yourself rather than inheriting a fixed opacity fade. It also supports hitSlop to enlarge the touch target without changing layout and pressRetentionOffset to control how far a finger can drift before the press cancels.

On Android, android_ripple gives you a native Material ripple that respects borderless and radius, which TouchableOpacity cannot produce. The practical reasons to migrate are accessibility and touch target size: Google and Apple both expect roughly 48 dp and 44 points of tappable area respectively, and a 24 dp icon wrapped in Pressable with hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} satisfies that without visual change. Pair it with accessibilityRole="button" and accessibilityLabel so TalkBack and VoiceOver announce it properly.

A frequent production bug worth mentioning: Pressable and the Touchables are handled by the JavaScript responder system, so if the JS thread is blocked, taps feel laggy or get dropped entirely. If you need press handling that survives a busy JS thread, or you need press combined with pan and swipe, use react-native-gesture-handler which processes gestures on the UI thread. Finally, TouchableWithoutFeedback is still occasionally correct for dismissing a keyboard, but it gives users no visual confirmation and should never wrap a primary action.

import { Pressable, Text } from 'react-native';

<Pressable
  onPress={onSave}
  onLongPress={onShowOptions}
  delayLongPress={400}
  hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
  android_ripple={{ color: '#e5e7eb', borderless: false }}
  accessibilityRole="button"
  accessibilityLabel="Save contact"
  style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1, padding: 10 })}
>
  <Text>Save</Text>
</Pressable>
💡 Pro Tip: Audit every icon-only button for hitSlop. Small touch targets are the single most common accessibility failure flagged in React Native app reviews.
Q5

How do FlatList, SectionList and ScrollView differ, and at what point does each one break?

BasicLists

Answer

ScrollView mounts every child immediately and keeps all of them in memory. That is fine for a settings screen with fifteen rows and catastrophic for a product feed, because on a 3 GB Android device a few hundred mounted rows with images will push you into GC thrash and eventually an out of memory crash. FlatList is built on VirtualizedList: it mounts only the rows inside the render window plus a buffer, recycles nothing but unmounts far-off rows, and gives you keyExtractor, getItemLayout, onEndReached, ListHeaderComponent, ListEmptyComponent, and refreshControl.

SectionList is the same machinery with sections, section headers, and stickySectionHeadersEnabled. The rule of thumb interviewers want: unbounded or unknown-length data means FlatList, a short fixed list means ScrollView, grouped data means SectionList. Three gotchas come up constantly.

First, never nest a FlatList inside a ScrollView with the same scroll direction, you get the VirtualizedLists should never be nested warning and lose virtualisation entirely; use ListHeaderComponent or a single parent FlatList instead. Second, keyExtractor must return a stable unique string, using the array index breaks row identity when the list reorders or prepends, which shows up as wrong images against wrong names. Third, if rows are a fixed height, supply getItemLayout so the list can compute scroll offsets without measuring, which makes scrollToIndex and initial scroll restoration instant. For heavy feeds many Indian consumer teams now reach for Shopify's FlashList, whose newer versions were rebuilt for the New Architecture and remove the old estimatedItemSize guesswork.

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

const ROW_HEIGHT = 72;

<FlatList
  data={orders}
  keyExtractor={(item) => item.id}            // stable id, never the index
  renderItem={({ item }) => <OrderRow order={item} />}
  getItemLayout={(_, index) => ({
    length: ROW_HEIGHT,
    offset: ROW_HEIGHT * index,
    index,
  })}
  onEndReached={loadNextPage}
  onEndReachedThreshold={0.5}
  ListEmptyComponent={<Text>No orders yet</Text>}
  removeClippedSubviews
/>

Key Points

  • ScrollView mounts everything; FlatList windows the render range
  • Never nest same-direction VirtualizedLists
  • keyExtractor must be a stable id, not the array index
  • getItemLayout unlocks instant scrollToIndex for fixed-height rows
Q6

How do Platform.OS, Platform.select and platform-specific file extensions work together?

BasicPlatform APIs

Answer

There are three levels of platform branching and choosing the right one is a code quality signal. Platform.OS is a string, 'ios' or 'android' (also 'windows' and 'macos' with the out of tree platforms), used for a one-line difference. Platform.select takes an object keyed by platform plus an optional default and returns the matching value, which reads better inside a StyleSheet where you need different shadow properties or fonts per platform.

Platform.Version gives you the OS version, an integer API level on Android and a string on iOS, which is how you gate a behaviour behind Android 13 notification permissions or an iOS API that only exists from a certain release. The third level is file extensions: Metro resolves Button.ios.tsx and Button.android.tsx automatically when you import './Button', and also understands the .native.tsx suffix, which is how a React Native Web codebase keeps one import path for web and mobile implementations. Use file extensions when the two implementations diverge structurally rather than by a few properties, because a component riddled with Platform.OS checks becomes unreadable.

Two things interviewers probe. First, whether you know that Platform.select is evaluated at runtime, not compile time, so both branches ship inside the bundle and you cannot use it to strip a native dependency. Second, whether you understand that platform checks are not a substitute for capability checks: on Android especially, OEM skins from Xiaomi, Oppo, Vivo, and Samsung behave differently on background restrictions and notification delivery, and Platform.OS === 'android' tells you nothing about that.

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

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

// runtime OS version gate (Android 13 = API 33 introduced POST_NOTIFICATIONS)
const needsNotificationPermission =
  Platform.OS === 'android' && Number(Platform.Version) >= 33;

// Metro picks Sheet.ios.tsx or Sheet.android.tsx for this import
import Sheet from './Sheet';
Q7

Why is SafeAreaView from react-native no longer enough, and how do you handle insets correctly?

BasicLayout

Answer

The SafeAreaView exported from react-native is iOS only, it silently renders as a plain View on Android, and it only pads for the static safe area rather than reacting to the keyboard, rotation, or system bars. The community standard is react-native-safe-area-context, which provides SafeAreaProvider at the app root, a SafeAreaView with an edges prop, and the useSafeAreaInsets hook returning { top, right, bottom, left } in dp. The hook is the flexible option because you can apply a single edge, for example paddingBottom: insets.bottom on a sticky checkout bar while letting the background colour extend under the home indicator.

This became much more important with Android 15, which enforces edge to edge drawing for apps targeting API 35 and above: your content now sits under the status bar and the gesture navigation bar by default, so any screen that assumed the system reserved that space will render text under the clock. Teams handle this with react-native-edge-to-edge or the equivalent Expo config, then apply insets explicitly. React Navigation's native stack already applies top insets for its header, so double padding is a common bug, wrap the screen body with edges={['bottom']} rather than the default all edges. Also remember that insets are not constant: on Android the bottom inset differs between three-button navigation and gesture navigation, and it changes when the keyboard opens, so hardcoding 34 for the iPhone home indicator or 24 for a status bar will break on a device you did not test.

import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { View } from 'react-native';

function CheckoutBar() {
  const insets = useSafeAreaInsets();
  return (
    <View
      style={{
        paddingBottom: insets.bottom + 12, // gesture bar aware
        paddingHorizontal: 16,
        paddingTop: 12,
        backgroundColor: '#fff',
      }}
    >
      {/* pay button */}
    </View>
  );
}

export default function App() {
  return (
    <SafeAreaProvider>
      <RootNavigator />
    </SafeAreaProvider>
  );
}
💡 Pro Tip: Test every screen with Android gesture navigation and three-button navigation. The bottom inset differs and it is the fastest way to catch a hardcoded padding.
Q8

How do you keep the keyboard from covering inputs on both platforms?

BasicForms

Answer

The two platforms solve this differently and a working solution needs both halves. On iOS, nothing moves automatically, so you wrap the screen in KeyboardAvoidingView with behavior="padding" and set keyboardVerticalOffset to the height of any header above it, otherwise the view lifts by the wrong amount. On Android the window itself resizes or pans depending on android:windowSoftInputMode in AndroidManifest.xml, adjustResize being the value you almost always want, and KeyboardAvoidingView with behavior="height" often double compensates.

The usual pattern is behavior={Platform.OS === 'ios' ? 'padding' : undefined}. Inside a scrollable form, ScrollView needs keyboardShouldPersistTaps="handled" or the first tap on a button merely dismisses the keyboard and the user has to tap twice, which is one of the most common bug reports on Indian onboarding funnels where forms are long. keyboardDismissMode="on-drag" is a nice touch for long forms. Recent React Native versions also expose the Keyboard module with addListener for keyboardWillShow, keyboardDidShow, keyboardWillHide, and the useAnimatedKeyboard hook in Reanimated, which tracks keyboard height on the UI thread and gives a genuinely smooth follow animation rather than the stepped jump KeyboardAvoidingView produces.

Many teams now use react-native-keyboard-controller for exactly that reason. One more Android detail worth naming in an interview: with edge to edge enabled on Android 15, adjustResize behaves differently and you may need the keyboard inset from the WindowInsets API rather than assuming the window shrinks.

import { KeyboardAvoidingView, ScrollView, Platform, TextInput } from 'react-native';

<KeyboardAvoidingView
  style={{ flex: 1 }}
  behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight : 0}
>
  <ScrollView
    contentContainerStyle={{ padding: 16 }}
    keyboardShouldPersistTaps="handled"
    keyboardDismissMode="on-drag"
  >
    <TextInput placeholder="Full name" returnKeyType="next" />
    <TextInput placeholder="PIN code" keyboardType="number-pad" maxLength={6} />
  </ScrollView>
</KeyboardAvoidingView>
Q9

Expo or the React Native CLI in 2026: how do you actually decide, and what does prebuild change?

BasicTooling

Answer

The old framing of Expo as the limited beginner option is out of date. Modern Expo is a framework on top of React Native with an escape hatch: npx expo prebuild generates the native android and ios directories from your app.json or app.config.ts, applying config plugins that patch AndroidManifest.xml, Info.plist, Gradle files, and Podfile programmatically. That means you can use any native library and still keep the native folders out of version control, regenerating them on demand, which is called the continuous native generation workflow.

You only lose that if you start hand editing native code, at which point you are effectively bare. The React Native Community CLI remains the right choice when your app is brownfield, when you already have a large hand-written native layer, or when your organisation has custom Gradle and Xcode build infrastructure it will not give up. What Expo buys you concretely: EAS Build so you can build iOS without owning a Mac, which matters for many Indian teams; EAS Update for over the air JavaScript updates; expo-dev-client so you can keep a custom native build with the convenience of a dev menu; and a maintained set of modules (expo-image, expo-notifications, expo-file-system, expo-secure-store) that are tested together against each SDK.

The trade-off is version coupling: each Expo SDK pins a specific React Native version, so you upgrade on their cadence rather than yours. For a greenfield consumer app in 2026 the default answer in most interviews is Expo with prebuild, and being able to explain why without sounding dogmatic is the point.

# Greenfield with Expo, still fully native-capable
npx create-expo-app@latest my-app
npx expo install expo-dev-client react-native-mmkv
npx expo prebuild --clean      # regenerates android/ and ios/
npx expo run:android           # local native build

# Bare React Native CLI
npx @react-native-community/cli@latest init MyApp
cd ios && pod install && cd ..
npx react-native run-ios

Key Points

  • Config plugins patch native files, so prebuild is not a limitation
  • EAS Build removes the hard Mac requirement for iOS builds
  • Expo SDK pins a React Native version, so upgrades follow their cadence
  • CLI still wins for brownfield apps and custom build infrastructure
Q10

What is Fast Refresh, which state survives it, and when do you need a full reload?

BasicDeveloper Experience

Answer

Fast Refresh is React Native's hot reloading implementation and it replaced both the old Live Reload and Hot Module Replacement. When you save a file, Metro sends only the changed modules over the websocket and React re-renders the affected subtree. The rule that decides whether your state survives is specific: if the edited file only exports React components, Fast Refresh preserves the state of those components.

If the file exports anything that is not a component, for example a constant, a helper function, or a Redux slice, Fast Refresh cannot reason about it and falls back to a full reload of the module graph, losing state. This is why a file that mixes a component and a utility export will feel unreliable while a pure component file feels instant. Hooks state is preserved for a re-rendered component unless you change the order or the number of hooks, which forces a remount.

Edits to files outside the React tree, such as changing a native module, editing metro.config.js, adding a new dependency, or touching anything under android or ios, need a full rebuild, not a refresh. If a runtime error is thrown during a Fast Refresh, the red box appears and the next successful save recovers automatically without a manual reload. Practical tips interviewers appreciate: keep one component per file so refresh stays stateful, use the dev menu (shake the device, or press d in the Metro terminal) to reach the reload and DevTools options, and remember that changes to environment variables loaded at module scope will not appear until a full reload.

💡 Pro Tip: If Fast Refresh keeps blowing away your form state, check whether the file also exports a non-component value. Moving that constant into its own file usually restores stateful refresh.
Q11

What networking gotchas hit React Native apps that never appear in a browser?

BasicNetworking

Answer

fetch in React Native is implemented on top of a native networking stack, OkHttp on Android and NSURLSession on iOS, exposed through the XMLHttpRequest polyfill. That single fact explains most surprises. There is no browser origin, so CORS does not apply, and candidates who claim their mobile app hit a CORS error are usually looking at a proxy response.

Cookies are handled by the native cookie jar, so credentials behave differently from a browser and you often need explicit header based auth. Android blocks cleartext HTTP by default from API 28 onwards, so a local http://192.168.x.x API fails with a Cleartext HTTP traffic not permitted error unless you add a network security config; iOS App Transport Security enforces the same thing through NSAppTransportSecurity in Info.plist. Never ship either exception to production. fetch has no timeout option, an idle request can hang until the OS gives up, so you pass an AbortSignal, and AbortSignal.timeout is available in recent versions.

File uploads use FormData with an object shaped { uri, name, type }, not a browser File object, and the uri must be a local file path from the image picker. Response streaming is limited: response.body as a ReadableStream is not fully supported the way it is on the web, so server-sent events and streaming LLM responses usually need react-native-sse, expo-fetch style streaming, or a websocket. Finally, always handle the offline case explicitly with @react-native-community/netinfo, because on Indian networks a request does not fail cleanly, it hangs on a captive portal or a stalled 2G fallback.

async function uploadKyc(fileUri: string, token: string) {
  const form = new FormData();
  form.append('document', {
    uri: fileUri,                 // file:// path from the picker
    name: 'pan.jpg',
    type: 'image/jpeg',
  } as any);

  const res = await fetch('https://api.example.in/v1/kyc', {
    method: 'POST',
    body: form,
    headers: { Authorization: `Bearer ${token}` },
    // fetch has no timeout option; use an abort signal
    signal: AbortSignal.timeout(20000),
  });

  if (!res.ok) throw new Error(`KYC upload failed: ${res.status}`);
  return res.json();
}

Key Points

  • No CORS in a native app; cookies live in the native cookie jar
  • Cleartext HTTP is blocked by default on Android 9+ and by iOS ATS
  • fetch has no timeout; use AbortSignal.timeout
  • FormData uploads take { uri, name, type }, not a File object
Q12

Why was AsyncStorage removed from React Native core, and what should you use for local storage now?

BasicStorage

Answer

AsyncStorage was extracted from core into @react-native-async-storage/async-storage as part of the lean core effort, where the React Native team moved non-essential modules to community ownership. The API stayed the same, getItem, setItem, multiGet, multiSet, removeItem, clear, all string based and all asynchronous. What matters in interviews is knowing its limits.

It is not encrypted, so an auth token stored there is readable on a rooted or jailbroken device and by anyone with a backup of the app container. On Android the default implementation historically used SQLite with a size ceiling around six megabytes unless you raise AsyncStorage_db_size_in_MB in gradle.properties, and storing large JSON blobs there is slow because every read parses a string. Because every call returns a promise that hops through the native module layer, doing it inside a render path or on every keystroke will visibly stutter on a low-end device.

The modern choices: react-native-mmkv for fast synchronous key value storage built on JSI (no async, no bridge hop, with optional encryption), expo-secure-store or react-native-keychain for tokens and anything sensitive since they use the iOS Keychain and Android Keystore, and SQLite through op-sqlite, expo-sqlite, or WatermelonDB for relational or large datasets. For redux-persist users, MMKV has a drop-in storage adapter. The interview follow up is usually about migration: you cannot swap storage engines silently, you need a one-time migration that reads old keys, writes to the new store, sets a migrated flag, and keeps the old reader for at least one release in case a user skips versions.

import { MMKV } from 'react-native-mmkv';
import AsyncStorage from '@react-native-async-storage/async-storage';

export const storage = new MMKV({ id: 'app', encryptionKey: undefined });

// one-time migration from AsyncStorage to MMKV
export async function migrateLegacyStorage() {
  if (storage.getBoolean('migrated_v1')) return;
  const keys = await AsyncStorage.getAllKeys();
  const pairs = await AsyncStorage.multiGet(keys);
  for (const [key, value] of pairs) {
    if (value != null) storage.set(key, value);
  }
  storage.set('migrated_v1', true);
}

// synchronous reads, safe in render
const theme = storage.getString('theme') ?? 'system';
💡 Pro Tip: Never store JWTs or refresh tokens in AsyncStorage. Use expo-secure-store or react-native-keychain so the value lands in the Keychain or the Android Keystore.
Q13

How does <Image> resolve sources, and why does a remote image sometimes render as nothing?

BasicImages

Answer

There are two source forms and they behave differently. A static asset written as source={require('./logo.png')} is resolved at bundle time: Metro reads the file, records its intrinsic width and height, and picks the right density variant from logo.png, logo@2x.png, logo@3x.png automatically. Because the dimensions are known, the image lays out correctly with no explicit size.

A remote asset written as source={{ uri: 'https://cdn.example.in/a.jpg' }} has unknown dimensions at layout time, so if you do not give the Image a width and height (or flex plus a defined parent) it computes to zero by zero and renders invisibly. That is the answer to the classic why is my image not showing question, and interviewers ask it because it separates people who have shipped from people who have followed a tutorial. Other things to know: resizeMode accepts cover, contain, stretch, repeat, and center, and defaults to cover; Image.prefetch warms the cache for images you know are coming next; Image.getSize fetches remote dimensions if you genuinely need them; and defaultSource or a placeholder avoids layout shift.

On Android the decoder is Fresco, which caches decoded bitmaps in memory, so a grid of large JPEGs can consume far more RAM than the file sizes suggest. In 2026 most teams use expo-image or react-native-fast-image instead of the core component, because they add disk caching, blurhash or thumbhash placeholders, better recycling in lists, and a transition prop. Always request server-side resized images sized to the display box rather than downloading a 3000 pixel wide original for a 120 dp thumbnail.

import { Image } from 'react-native';
import { Image as ExpoImage } from 'expo-image';

// static: dimensions known at bundle time, no explicit size needed
<Image source={require('../assets/logo.png')} />

// remote: MUST have a size or it renders 0x0
<Image
  source={{ uri: 'https://cdn.example.in/p/123.jpg' }}
  style={{ width: 120, height: 120, borderRadius: 8 }}
  resizeMode="cover"
/>

// list-friendly: disk cache + placeholder + fade
<ExpoImage
  source={{ uri: item.thumbUrl }}
  style={{ width: 120, height: 120 }}
  placeholder={{ blurhash: item.blurhash }}
  contentFit="cover"
  cachePolicy="memory-disk"
  transition={150}
/>

Key Points

  • require() sources carry intrinsic dimensions; uri sources do not
  • A remote Image without width and height lays out as 0x0
  • Android decodes through Fresco, so memory cost is bitmap size, not file size
  • expo-image or FastImage add disk caching and placeholders
Q14

What do you need to get TextInput right, including OTP autofill on Indian Android devices?

BasicForms

Answer

TextInput is controlled through value and onChangeText, and the first production trap is that a controlled input with slow state can drop or reorder characters on Android, because the native edit text advances while React is still rendering the previous value. The fixes are to keep the state update cheap (never run validation or a network call inside onChangeText), debounce derived work, and use defaultValue with a ref for uncontrolled fields inside big forms, which is what react-hook-form does. Set keyboardType deliberately: 'number-pad' for PIN codes and OTPs, 'phone-pad' for mobile numbers, 'email-address' for email, and note that 'numeric' shows a keyboard that still contains a decimal point.

Use returnKeyType with a ref based focus chain so the keyboard's next button moves between fields, and set blurOnSubmit={false} on all but the last field or the keyboard closes between steps. For OTP autofill, which matters enormously for Indian login funnels built on SMS OTP, you need both platforms: on iOS set textContentType="oneTimeCode" and the system offers the code from the notification banner; on Android set autoComplete="sms-otp" and, for zero-tap retrieval, wire the SMS Retriever API through a library such as react-native-otp-verify, which requires an eleven character app hash appended to the SMS body. Also set autoComplete="tel" on the phone field, importantForAutofill on Android, and secureTextEntry only where it is genuinely a password, since it disables suggestion and can conflict with autofill. Finally, always set maxLength on OTP and PIN fields and test with Gboard, SwiftKey, and the Samsung keyboard, since IME behaviour differs.

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

<TextInput
  value={otp}
  onChangeText={(t) => setOtp(t.replace(/[^0-9]/g, ''))}
  keyboardType="number-pad"
  maxLength={6}
  // iOS: pulls the code from the SMS notification banner
  textContentType="oneTimeCode"
  // Android: lets Autofill / SMS Retriever fill the field
  autoComplete={Platform.OS === 'android' ? 'sms-otp' : 'one-time-code'}
  importantForAutofill="yes"
  autoFocus
  style={{ letterSpacing: 8, fontSize: 22, textAlign: 'center' }}
/>
💡 Pro Tip: If OTP autofill silently does nothing on Android, check that the SMS body ends with the correct eleven character app hash for that exact signing key. Debug and release builds have different hashes.
Q15

In React Navigation, when do you use the native stack instead of the JS stack, and how do you type routes?

BasicNavigation

Answer

React Navigation ships two stack navigators. createNativeStackNavigator from @react-navigation/native-stack delegates to the platform primitives, UINavigationController on iOS and Fragment based navigation on Android, through react-native-screens. You get native push animations, native large titles, native back swipe, and correct memory behaviour because off-screen screens are detached rather than kept in the view hierarchy. createStackNavigator from @react-navigation/stack renders the transitions in JavaScript, which is slower but fully customisable, so you pick it only when you need a bespoke transition that the native stack cannot express. The default advice in 2026 is native stack for almost everything.

Setup requires NavigationContainer at the root, react-native-screens and react-native-safe-area-context installed, and enableScreens is on by default in recent versions. For typing, define a ParamList type and pass it as a generic to the navigator and to useNavigation, then declare it globally so useNavigation is typed everywhere without repeating the generic. Interviewers commonly probe three things: how you pass data between screens (route params for identifiers only, never entire objects, since params are serialised into navigation state and large payloads bloat state persistence and deep link URLs), how you reset a stack after login (navigation.reset with an index and routes array, not repeated goBack calls), and how you handle the Android hardware back button (useFocusEffect with a BackHandler listener, returning true to consume the event). Expo Router builds on top of the same library with file based routing and typed routes, which many new projects now start with.

import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { NavigationContainer } from '@react-navigation/native';

export type RootStackParamList = {
  Home: undefined;
  OrderDetail: { orderId: string };  // pass ids, never whole objects
};

declare global {
  namespace ReactNavigation {
    interface RootParamList extends RootStackParamList {}
  }
}

const Stack = createNativeStackNavigator<RootStackParamList>();

export const Root = () => (
  <NavigationContainer>
    <Stack.Navigator screenOptions={{ headerShown: true, animation: 'slide_from_right' }}>
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="OrderDetail" component={OrderDetailScreen} />
    </Stack.Navigator>
  </NavigationContainer>
);

// after login, replace the whole stack
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
Q16

How do shadows, elevation and zIndex behave differently on iOS and Android?

BasicStyling

Answer

Shadows are the classic cross-platform trap. iOS honours shadowColor, shadowOffset, shadowOpacity and shadowRadius, and it draws the shadow from the view's alpha channel, which means a view without a backgroundColor produces a shadow around its content rather than its box, and shadowRadius on a view with overflow hidden can be clipped away. Android ignores those four properties entirely and uses elevation, an integer in dp that feeds the Material elevation model: it controls both the shadow and the draw order, and the shadow colour is system controlled (recent Android versions added limited shadow colour control, but you should not rely on it). Because elevation also affects stacking, an Android view with a higher elevation paints above siblings regardless of zIndex, which is why a floating action button disappears behind a card on Android but not on iOS. zIndex works on both platforms within the same parent, but it does not escape a parent that has overflow: 'hidden', and on Android the elevation value can override the intent.

Practical rules: define shadows in a single Platform.select style helper so every card in the app shares one definition, always give a shadowed view an explicit backgroundColor, and for anything that must float above the rest (modals, toasts, dropdowns) use a portal or a top level absolutely positioned container rather than fighting stacking contexts. Recent React Native versions added boxShadow and filter style props that follow the CSS syntax and work on both platforms, which is the direction to move for new code, though you should still verify rendering on an older Android device.

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

export const shadow = (level: number) =>
  Platform.select({
    ios: {
      shadowColor: '#000',
      shadowOffset: { width: 0, height: level },
      shadowOpacity: 0.08 + level * 0.02,
      shadowRadius: level * 2,
    },
    android: { elevation: level },   // also changes draw order
    default: {},
  });

const styles = StyleSheet.create({
  card: {
    backgroundColor: '#fff',   // required, or the iOS shadow follows content alpha
    borderRadius: 12,
    ...shadow(3),
  },
});
Q17

Remote JS debugging was removed, so how do you inspect a Hermes app today?

BasicDebugging Tools

Answer

The old Debug JS Remotely option ran your JavaScript inside Chrome's V8 engine over a websocket, which meant you were debugging a different engine than production, synchronous native calls did not work, and timing was wildly different. It was deprecated and then removed. Flipper was also dropped from the default template.

The current tool is React Native DevTools, opened from the dev menu (shake the device, press Cmd+D on the iOS simulator, Cmd+M or the d key in the Metro terminal on Android) and choosing Open DevTools. It attaches to Hermes directly through the Chrome DevTools Protocol, so you get real breakpoints in the engine that actually runs your code, a console wired to the app, a network panel, a memory panel with heap snapshots, and the React Components and Profiler tabs built in. Because it speaks CDP to Hermes, source maps map bytecode frames back to your TypeScript.

Alongside it you still use the in-app Perf Monitor for JS and UI frame rates, LogBox for warnings and errors, and platform tools for anything native: Android Studio's Logcat and Memory Profiler, Xcode's console and Instruments. For network inspection many teams keep a proxy such as Charles or Proxyman, or add a Reactotron client, because the DevTools network panel does not always capture requests made by native modules. The interview point is that you know the engine matters: a bug that only reproduces on Hermes, for example a difference in Intl support or in Date parsing of a non-standard string, will never show up in a V8 based remote debugger.

💡 Pro Tip: Add the Hermes memory panel to your routine, not just the console. Taking a heap snapshot before and after navigating in and out of a screen ten times is the fastest way to spot a retained subscription.
Q18

Walk through what happens on npx react-native run-android, and why Unable to load script appears.

BasicBuild Process

Answer

The command does three things in sequence. First it starts Metro on port 8081 if nothing is already listening. Second it invokes the Gradle wrapper, ./gradlew app:installDebug, which compiles your Java and Kotlin sources, runs the React Native Gradle plugin, links the native libraries for each ABI, packages an APK, and installs it on the connected device or emulator through adb.

Third it launches the main activity with adb shell am start. In a debug build the JavaScript is not packaged inside the APK by default: the app asks the Metro dev server for index.android.bundle at runtime. That is exactly why you see the red Unable to load script.

Make sure you're either running Metro or that your bundle index.android.bundle is packaged correctly for release message. The usual causes are Metro not running, the device not being able to reach your machine, or a stale reverse port mapping. On a physical device over USB, adb reverse tcp:8081 tcp:8081 is what makes localhost on the phone point at your laptop, and run-android sets it up but it is lost when the cable is replugged or the device sleeps.

On an emulator the special host address is 10.0.2.2. In release builds the Gradle bundleRelease task runs Metro to produce the bundle, Hermes compiles it to bytecode, and both land in the APK or AAB, so the dev server is never contacted. Related failure modes worth naming: a port 8081 conflict with another Metro instance, SDK location not found, which means missing local.properties or ANDROID_HOME, and Duplicate class errors from mismatched transitive dependency versions.

# Metro cannot be reached from a physical device
adb reverse tcp:8081 tcp:8081

# Something else is holding the port
lsof -ti:8081 | xargs kill -9
npx react-native start --reset-cache

# Verify the release bundle really is inside the artifact
cd android && ./gradlew assembleRelease
unzip -l app/build/outputs/apk/release/app-release.apk | grep index.android.bundle

# Clean Gradle state when the build gets weird
cd android && ./gradlew clean && cd .. && npx react-native run-android
Q19

What does Hermes do differently from JavaScriptCore, and when would you still turn it off?

IntermediateHermes

Answer

Hermes is a JavaScript engine built specifically for React Native's constraints, and it is the default on both platforms. Its defining trick is ahead of time compilation: instead of parsing and compiling JavaScript on the device at startup, the build pipeline runs hermesc over your Metro bundle and ships Hermes bytecode. The app then memory maps that bytecode and starts executing almost immediately, which cuts time to interactive substantially, most visibly on entry level Android hardware where parse time dominated.

Hermes also uses a garbage collector tuned for small heaps and produces a smaller memory footprint than JSC, and it omits a full optimising JIT, which is a deliberate trade: startup and memory beat peak throughput, and a mobile app is rarely CPU bound in JavaScript. You can verify Hermes is active at runtime by checking for the global HermesInternal object. Practical consequences: your production stack traces are bytecode offsets, so you must upload the Hermes source map (index.android.bundle.map, generated when hermesFlags include the source map option) to Sentry or Crashlytics or your crash reports are unreadable; a bundle inspection with strings will show bytecode, not source, which slightly raises the bar for casual reverse engineering; and engine differences do exist, historically around Intl, Proxy behaviour, and some regular expression edge cases, though recent Hermes builds ship full Intl support.

Reasons to disable it in 2026 are rare and specific: a dependency that requires a JSC-only API, a need for JIT-level throughput in a heavy computation you cannot move native, or debugging an engine-specific bug. On the horizon, the Static Hermes work aims to compile typed JavaScript to native code, but treat it as experimental in interviews.

// runtime check
const isHermes = () => !!global.HermesInternal;
console.log('engine:', isHermes() ? 'Hermes' : 'JSC');

// android/gradle.properties
// hermesEnabled=true

// upload Hermes source maps so crash traces symbolicate
// package.json script
// "sentry:sourcemaps": "sentry-cli sourcemaps upload --debug-id-reference \
//   android/app/build/generated/sourcemaps/react/release/index.android.bundle.map"

Key Points

  • Bytecode is precompiled by hermesc, so no on-device parse at startup
  • Smaller heap and lower memory, no full optimising JIT
  • global.HermesInternal confirms the engine at runtime
  • Production traces need the Hermes source map uploaded for symbolication
Q20

What is JSI, and what specifically changed compared with the old asynchronous bridge?

IntermediateNew Architecture

Answer

JSI, the JavaScript Interface, is a lightweight C++ API that lets a JavaScript engine hold references to C++ objects and call their methods directly. Under the legacy architecture, every interaction between JavaScript and native code was serialised to JSON, pushed onto a message queue, batched, and delivered asynchronously to the native side, with the response taking the same trip back. Three costs followed: serialisation overhead proportional to payload size, mandatory asynchrony even for trivially cheap reads, and no way to share memory, so an image buffer or a database result had to be copied through a string.

JSI removes the queue. A native object can be exposed as a HostObject or a host function installed on the JS runtime's global scope, and calling it is a direct C++ invocation on the JS thread. That makes synchronous calls possible, which is why react-native-mmkv can offer storage.getString() with no promise, and why libraries like react-native-vision-camera can hand a frame to JavaScript as a typed array without copying it through JSON.

It also enables shared values in Reanimated, where the same memory is readable from both the JS thread and the UI thread. The caution interviewers want to hear: synchronous does not mean free. A synchronous JSI call runs on the JS thread and blocks it, so a heavy synchronous native call is just as capable of dropping frames as a heavy JavaScript loop. JSI is the foundation that TurboModules and Fabric are built on, so a question about either usually starts here.

// Consumer side: synchronous because MMKV is a JSI HostObject,
// no promise, no bridge hop, safe to read during render.
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();

function Greeting() {
  const name = storage.getString('user.name') ?? 'there';   // sync
  return <Text>Hi {name}</Text>;
}

// Legacy bridge equivalent: always async, always serialised
import AsyncStorage from '@react-native-async-storage/async-storage';
useEffect(() => {
  AsyncStorage.getItem('user.name').then(setName);          // promise
}, []);
Q21

How does a TurboModule differ from a legacy native module, and what does codegen generate?

IntermediateNew Architecture

Answer

A legacy native module was registered eagerly at startup, every module in the app was instantiated whether or not you used it, the method signatures existed only as strings, and all calls were asynchronous through the bridge. A TurboModule is lazily loaded on first access, is reached through JSI so calls can be synchronous, and is type safe because its interface is declared once in a TypeScript spec file and code generation produces the matching C++, Objective-C, and Java or Kotlin interfaces. The spec lives in a file named NativeSomething.ts, exports an interface extending TurboModule, and registers itself with TurboModuleRegistry.getEnforcing.

You then declare codegenConfig in package.json with a name, a type of modules or components or all, a jsSrcsDir, and the Android package name. At build time codegen writes the native base classes into build output, and your native implementation extends or conforms to them, so if the TypeScript spec and the native implementation disagree the build fails rather than crashing at runtime. Supported types in a spec are deliberately narrow: string, number, boolean, object shapes, arrays, Promise, void, callbacks, and enums; you cannot pass arbitrary classes.

The migration path for an existing module is to keep the same public JavaScript surface, add the spec, and implement the generated interface, which lets consumers upgrade without changing calling code. Interviewers usually follow with the interop question: apps on bridgeless mode can still use unmigrated legacy modules through the interop layer, but you lose the lazy init and synchronous call benefits and some modules that reached into RCTBridge internals break outright.

// specs/NativeDeviceInfo.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  getBuildNumber(): string;              // sync is allowed
  isRooted(): Promise<boolean>;
  getStorageBytes(path: string): Promise<number>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeDeviceInfo');

// package.json
// "codegenConfig": {
//   "name": "AppSpecs",
//   "type": "modules",
//   "jsSrcsDir": "specs",
//   "android": { "javaPackageName": "com.example.specs" }
// }

Key Points

  • Lazy initialisation instead of eager registration at startup
  • Spec file plus codegenConfig generates typed native interfaces
  • Type mismatches fail the build rather than crashing at runtime
  • Legacy modules still run under the interop layer, without the benefits
Q22

What does the Fabric renderer change about layout, measurement and concurrent rendering?

IntermediateNew Architecture

Answer

Fabric is the New Architecture renderer, written in C++ and shared across platforms. It maintains three trees: the React element tree in JavaScript, an immutable shadow tree of C++ shadow nodes that owns layout, and the host view tree of real UIViews or Android Views. On every update Fabric clones the affected shadow nodes rather than mutating them, runs Yoga on the new tree, diffs old against new, and commits a mutation list to the mounting layer on the main thread.

Immutability is what makes concurrent React usable: React can prepare a tree in the background and throw it away without ever having touched the views the user is looking at. Three practical differences from the legacy UIManager. First, layout information can be read synchronously through JSI, so onLayout races and the old callback based measure() dance largely go away, and measurement of text is done by the C++ core consistently on both platforms.

Second, view flattening happens in the shadow tree, so a View that only exists for layout and has no visual properties may not create a host view at all, which is a real memory win in deep trees but also means findNodeHandle on such a view returns nothing. Third, event handling is prioritised: discrete events like a press are dispatched at higher priority than continuous events like a scroll, so a tap does not queue behind scroll updates. What breaks during migration are libraries that reached directly into UIManager, called requireNativeComponent with legacy view managers, or used setNativeProps, which is deprecated under Fabric in favour of Reanimated shared values or state driven updates.

Key Points

  • Immutable C++ shadow tree cloned per update, enabling concurrent React
  • Synchronous layout reads through JSI, consistent text measurement
  • View flattening removes layout-only views from the host tree
  • setNativeProps and direct UIManager access are the usual migration breakages
Q23

What can useNativeDriver actually animate, and what happens when you set it on the wrong property?

IntermediateAnimation

Answer

The Animated API by default computes each frame in JavaScript and pushes the resulting value to the native view. If the JS thread is busy, the animation stutters, which is why a spinner freezes exactly when you are parsing a large API response. Setting useNativeDriver: true serialises the whole animation description to the native side once, after which the platform's animation driver advances it on the UI thread with no per-frame JavaScript involvement, so it keeps running even while JavaScript is blocked.

The limitation is that only non-layout properties are supported: transform (translateX, translateY, scale, rotate), opacity, and on newer versions a limited set of others. Layout properties like width, height, top, left, flex, margin, padding, and backgroundColor cannot use the native driver, because changing them requires a Yoga layout pass, which lives outside the animation driver. If you set useNativeDriver: true on an unsupported property you get a clear error at runtime, Style property 'height' is not supported by native animated module, rather than a silent fallback.

The idiomatic workaround is to express the effect as a transform, animate scaleY instead of height, translateY instead of top, and use opacity instead of toggling display. Two further gotchas: an Animated.Value driven natively cannot be read with getValue reliably from JavaScript mid-flight and you should attach a listener instead (which costs a bridge or JSI callback per frame, so use it sparingly), and mixing a native driven and a JS driven animation on the same node throws. For anything beyond simple transitions, most 2026 codebases use Reanimated, which runs the whole animation, including layout-affecting values, on the UI thread through worklets.

import { Animated, Easing } from 'react-native';
import { useRef, useEffect } from 'react';

function FadeSlideIn({ children }) {
  const progress = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    Animated.timing(progress, {
      toValue: 1,
      duration: 250,
      easing: Easing.out(Easing.cubic),
      useNativeDriver: true,          // opacity + transform only
    }).start();
  }, [progress]);

  return (
    <Animated.View
      style={{
        opacity: progress,
        transform: [
          { translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }) },
        ],
      }}
    >
      {children}
    </Animated.View>
  );
}
💡 Pro Tip: Animate scaleY rather than height and translateY rather than top. That single habit lets almost every entrance animation run on the native driver.
Q24

How do Reanimated worklets work, and what causes the error about calling a function from a different thread?

IntermediateAnimation

Answer

Reanimated runs animation logic on the UI thread inside a second JavaScript runtime. A worklet is a function marked with the 'worklet' directive, or created implicitly by hooks like useAnimatedStyle, useAnimatedScrollHandler, useAnimatedGestureHandler, and the Gesture callbacks, which the Reanimated Babel plugin extracts and makes executable in that UI runtime. Values shared between runtimes are useSharedValue objects: reading or writing .value from the UI runtime is synchronous and does not involve the JS thread at all, which is why a drag follows your finger even while the JS thread is parsing a payload.

The rules that trip people up follow from having two runtimes. A worklet captures variables by value at creation time, so a plain let variable mutated later on the JS thread will look stale inside the worklet; state must live in a shared value. You cannot call an ordinary JS thread function, including setState, a navigation call, or a fetch, from inside a worklet, doing so throws Tried to synchronously call a non-worklet function on the UI thread, and the fix is runOnJS(fn)(args).

Symmetrically, calling a worklet from the JS thread when it must run on the UI thread uses runOnUI. Animations are declared by assigning withTiming, withSpring, withDecay, or withSequence to a shared value, and useAnimatedStyle returns a style object recomputed on the UI thread whenever any shared value it reads changes. Recent Reanimated versions require the New Architecture and add CSS style animation and transition APIs, so check the version matrix before upgrading. Do not forget the Babel plugin in babel.config.js, its absence produces confusing errors about worklets being undefined.

import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS } from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

function SwipeCard({ onDismiss }) {
  const x = useSharedValue(0);

  const pan = Gesture.Pan()
    .onUpdate((e) => { x.value = e.translationX; })          // worklet, UI thread
    .onEnd(() => {
      if (Math.abs(x.value) > 120) {
        runOnJS(onDismiss)();                                // hop back to JS thread
      }
      x.value = withSpring(0);
    });

  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: x.value }, { rotate: `${x.value / 20}deg` }],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={style} />
    </GestureDetector>
  );
}

Key Points

  • Worklets execute in a second JS runtime on the UI thread
  • Shared values are the only safe cross-runtime mutable state
  • runOnJS to call React state or navigation from a worklet
  • react-native-reanimated/plugin must be last in babel.config.js
Q25

How do you split server state, client state and persisted state in a React Native app, and what makes rehydration tricky?

IntermediateState Management

Answer

The split most teams settle on in 2026 is: TanStack Query (or RTK Query) owns anything that came from an API, a small store such as Zustand or Redux Toolkit owns genuine client state like the cart draft, filters, or auth session, and only a deliberate subset is persisted to disk. Dumping the whole Redux tree into redux-persist is the classic mistake, because you then rehydrate stale server data on launch and render prices or order statuses that are hours old. React Native adds two wrinkles the web does not have.

First, there is no window focus event, so TanStack Query's refetchOnWindowFocus does nothing until you wire focusManager to AppState, and its online detection does nothing until you wire onlineManager to @react-native-community/netinfo. Without that, a user who backgrounds your app during a train ride and returns two hours later sees cached data with no refetch. Second, rehydration is asynchronous if you use AsyncStorage, so your first render happens before persisted auth exists, and the app flashes the login screen then jumps to home.

The fix is to hold the native splash screen (react-native-bootsplash or expo-splash-screen) until the store reports rehydrated, or to use react-native-mmkv, whose reads are synchronous through JSI so there is no hydration gap at all. Interviewers also probe versioning: persisted state outlives app upgrades, so a store without a version number and a migrate function will crash on a shape change that shipped three releases ago.

import { AppState, Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { focusManager, onlineManager, QueryClient } from '@tanstack/react-query';

// RN has no window focus event: bind it to AppState
AppState.addEventListener('change', (status) => {
  if (Platform.OS !== 'web') focusManager.setFocused(status === 'active');
});

// RN has no navigator.onLine: bind it to NetInfo
onlineManager.setEventListener((setOnline) =>
  NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected && state.isInternetReachable !== false);
  }),
);

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 30_000, retry: 2, gcTime: 24 * 60 * 60 * 1000 },
  },
});

Key Points

  • Server cache in TanStack Query, client state in Zustand or RTK, persist a subset
  • focusManager needs AppState and onlineManager needs NetInfo in React Native
  • Async rehydration causes a login-screen flash; hold the splash or use MMKV
  • Persisted stores need a version plus migrate function across app upgrades
Q26

Trace a push notification from FCM to an Indian Android user's tray. Where does it get dropped?

IntermediatePush Notifications

Answer

The chain is: your server calls the FCM HTTP v1 API with a device token, FCM routes to Google Play services on the device (or to APNs for iOS builds registered with an APNs auth key and the aps-environment entitlement), the RN Firebase messaging module surfaces it, and the OS draws it. Each hop drops messages for a different reason. Tokens rotate, so if you do not persist the value from onTokenRefresh and delete dead tokens when FCM returns UNREGISTERED, you are pushing into the void.

From Android 13 (API 33) POST_NOTIFICATIONS is a runtime permission, so a fresh install shows nothing until you request it, and users who deny twice are permanently blocked until they change settings. Since Android 8 every notification must belong to a channel, and importance is fixed at channel creation time: create it with IMPORTANCE_DEFAULT and you can never make it heads-up later without a new channel id. Payload type matters: a notification payload is drawn by the system when the app is backgrounded and your JS never runs, whereas a data-only payload always invokes setBackgroundMessageHandler, which is what you need if you want to decorate or localise the message with notifee.

The India-specific killer is OEM power management. Xiaomi MIUI, Oppo ColorOS, Vivo Funtouch and Realme aggressively kill background processes and require autostart to be enabled manually, so delivery on those devices is materially worse than on Pixel or Samsung. Use high priority for genuinely time-sensitive messages, track delivery against opens per manufacturer, and never assume a silent data message will wake a force-stopped app: on Android a user swipe-to-kill or Force Stop blocks FCM entirely until the app is opened again.

import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';
import { PermissionsAndroid, Platform } from 'react-native';

// Android 13+: runtime permission, otherwise nothing is ever shown
if (Platform.OS === 'android' && Number(Platform.Version) >= 33) {
  await PermissionsAndroid.request(
    PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
  );
}

// importance is frozen when the channel is created: pick it deliberately
const channelId = await notifee.createChannel({
  id: 'orders-v2',
  name: 'Order updates',
  importance: AndroidImportance.HIGH,
});

// data-only messages reach this even when the app is backgrounded
messaging().setBackgroundMessageHandler(async (msg) => {
  await notifee.displayNotification({
    title: msg.data?.title,
    body: msg.data?.body,
    android: { channelId, pressAction: { id: 'default' } },
  });
});

messaging().onTokenRefresh((token) => syncTokenToServer(token));
💡 Pro Tip: Report push delivery split by device manufacturer. If MIUI and ColorOS sit far below Samsung and Pixel, the problem is OEM battery policy, not your payload.
Q27

How do you make a deep link land on the right screen from a cold start, and why do Universal Links silently fail?

IntermediateDeep Linking

Answer

There are two entry paths and you must handle both. If the app is already running, Linking.addEventListener('url', handler) fires. If the OS launched the app to handle the link, that event never fires and you must read Linking.getInitialURL() once at startup.

React Navigation wraps both behind the linking prop on NavigationContainer, where you give prefixes (your custom scheme plus your https origins) and a screens map that translates path segments and query strings into route params. Add a fallback so an unknown path lands on a sensible screen rather than a blank stack, and keep the cold-start splash visible until the initial URL resolves, otherwise the user sees home for a moment then gets pushed sideways. Custom schemes like myapp:// always work but look untrustworthy in a WhatsApp message and cannot be pasted into a browser.

Verified https links are the real requirement. On Android that means an intent-filter with android:autoVerify="true" and a /.well-known/assetlinks.json file served over HTTPS containing the SHA-256 fingerprint of the certificate that actually signs the installed app. The most common production failure is putting the upload key fingerprint there when Play App Signing re-signs with a different key, so verification passes in your internal build and fails for every Play install: the link then opens a browser instead.

Verify with adb shell pm get-app-links. On iOS you need the Associated Domains capability with applinks:yourdomain.com and an apple-app-site-association file at the root or /.well-known, served as application/json over HTTPS with no redirects, and the OS caches the result, so a fix may need an app reinstall to observe.

const linking = {
  prefixes: ['goodspaceapp://', 'https://app.example.in'],
  config: {
    screens: {
      Home: '',
      JobDetail: 'jobs/:jobId',
      Profile: { path: 'u/:userId', parse: { userId: String } },
      NotFound: '*',
    },
  },
  async getInitialURL() {
    // cold start: the 'url' event never fires for the launching link
    return (await Linking.getInitialURL()) ?? null;
  },
};

<NavigationContainer linking={linking} fallback={<Splash />}>
  <RootNavigator />
</NavigationContainer>;

// verify from the shell
// adb shell am start -a android.intent.action.VIEW -d "https://app.example.in/jobs/42"
// adb shell pm get-app-links com.example.app
// xcrun simctl openurl booted "https://app.example.in/jobs/42"

Key Points

  • getInitialURL for cold start, the url event for a warm app
  • assetlinks.json must carry the Play App Signing SHA-256, not the upload key
  • apple-app-site-association must be application/json over HTTPS with no redirect
  • adb shell pm get-app-links tells you whether verification actually succeeded
Q28

What happens during a release Android build, and how do you get from assembleRelease to a signed AAB on Play?

IntermediateRelease Builds

Answer

In a release build the React Native Gradle plugin runs a bundle task that invokes Metro to produce index.android.bundle, hands it to hermesc to compile Hermes bytecode, and packs the bytecode plus assets into the artifact, so no dev server is involved at runtime. Gradle then applies your release signingConfig, runs R8 if minifyEnabled is true, and emits either an APK from assembleRelease or an Android App Bundle from bundleRelease. Play accepts only the AAB, and it splits that bundle per device so users download only the density, ABI, and language resources they need, which typically cuts install size by a third or more compared with a universal APK.

Signing has two keys under Play App Signing: your upload key, which you keep in a keystore referenced from gradle.properties or an environment variable and never commit, and the app signing key held by Google, which is the one users' devices verify. R8 is where most release-only crashes come from, because it renames and strips classes reached only through reflection: keep rules for your native modules, any Gson or Jackson model classes, and anything a third-party SDK looks up by name, and always smoke-test the minified build rather than only the debug one. Other things interviewers expect you to name: versionCode must increase monotonically for every upload, Play enforces a minimum target API level each year so targetSdkVersion cannot lag, and you can inspect the finished artifact with bundletool build-apks plus apkanalyzer to see exactly what the bytecode and native libraries cost.

// android/app/build.gradle
android {
  signingConfigs {
    release {
      storeFile file(System.getenv("KEYSTORE_PATH") ?: "upload.keystore")
      storePassword System.getenv("KEYSTORE_PASSWORD")
      keyAlias System.getenv("KEY_ALIAS")
      keyPassword System.getenv("KEY_PASSWORD")
    }
  }
  buildTypes {
    release {
      signingConfig signingConfigs.release
      minifyEnabled true
      shrinkResources true
      proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
    }
  }
}

# build and inspect
# ./gradlew bundleRelease
# bundletool build-apks --bundle=app/build/outputs/bundle/release/app-release.aab \
#   --output=app.apks --mode=universal
# apkanalyzer apk file-size app-release.apk
💡 Pro Tip: Never let the first minified build be the one you upload to Play. Run assembleRelease locally and click through the whole app once, because R8 stripping only shows up in release.
Q29

Beyond pressing Archive in Xcode, what does shipping a React Native iOS build in 2026 actually require?

IntermediateiOS Release

Answer

Four things bite teams repeatedly. First, CocoaPods: every native dependency change needs npx pod-install or pod install in ios/, Podfile.lock must be committed so CI and laptops resolve identically, and a mismatch between the Pods project and the workspace produces linker errors that look like missing symbols rather than a dependency problem. Second, signing: the bundle identifier, provisioning profile, and entitlements must agree, so if you enable push notifications, Associated Domains, App Groups, or HealthKit in code without adding the capability to the App ID and regenerating the profile, the archive uploads and then fails validation.

Third, privacy manifests. Apple requires a PrivacyInfo.xcprivacy in the app and in third-party SDKs, declaring collected data types and a reason code for required-reason APIs such as UserDefaults access (CA92.1), file timestamps, disk space, and system boot time. React Native and popular libraries ship their own manifests now, but a stale dependency without one triggers an App Store Connect email rather than a build error, which is why the failure surfaces late.

If you touch the IDFA for attribution you also need App Tracking Transparency and the ATT prompt. Fourth, symbolication: upload dSYMs and the Hermes source map to your crash reporter as part of the archive step, or every production stack trace is hex. Most Indian teams run this through Fastlane or EAS Build in CI rather than a developer laptop, partly for reproducibility and partly because EAS Build removes the need for every engineer to own a Mac. TestFlight is the staging gate: internal testers get builds immediately, external groups need a review pass.

<!-- ios/YourApp/PrivacyInfo.xcprivacy -->
<dict>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>CA92.1</string></array>
    </dict>
  </array>
  <key>NSPrivacyTracking</key>
  <false/>
</dict>

# after any native dependency change
# npx pod-install ios
# xcodebuild -workspace ios/App.xcworkspace -scheme App -configuration Release archive
# or: eas build --platform ios --profile production
Q30

How do you set up Jest for React Native, and which modules always need mocking?

IntermediateTesting

Answer

Start with the react-native Jest preset (or jest-expo on an Expo project), which wires the Babel transform, the React Native module mocks, and the haste config. The first error everyone hits is SyntaxError: Cannot use import statement outside a module, thrown when a node_modules package ships untranspiled ESM. Jest ignores node_modules for transformation by default, so you widen transformIgnorePatterns to allow-list react-native and the specific packages that need Babel.

Beyond that, mock anything that reaches native: react-native-reanimated has a bundled mock, react-native-gesture-handler ships jestSetup, and libraries like MMKV, Firebase, or the camera need jest.mock in a setup file because their JSI bindings do not exist in a Node process. Write component tests with @testing-library/react-native, querying by accessibility role and label rather than test IDs where possible, since that doubles as an accessibility check. Prefer userEvent over fireEvent for realistic press and typing behaviour, and wrap screens that use hooks from React Navigation in a NavigationContainer.

Avoid large snapshot tests: they pass through real regressions and turn every legitimate style change into a diff nobody reads. Network calls belong behind MSW or a hand-written fetch mock, not a live endpoint. Jest cannot tell you whether the app actually launches, so pair it with an end-to-end layer: Detox drives a real build with grey-box synchronisation and catches native crashes, while Maestro describes flows in short YAML files and is much faster to write and maintain, which is why many teams run Maestro on the critical login and checkout paths in CI.

// jest.config.js
module.exports = {
  preset: 'react-native',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  transformIgnorePatterns: [
    'node_modules/(?!(?:@react-native|react-native|react-native-reanimated'
      + '|@react-navigation|react-native-gesture-handler)/)',
  ],
};

// jest.setup.js
require('react-native-gesture-handler/jestSetup');
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
jest.mock('react-native-mmkv', () => ({ MMKV: jest.fn(() => ({ getString: jest.fn(), set: jest.fn() })) }));

// LoginScreen.test.tsx
import { render, screen, userEvent } from '@testing-library/react-native';

test('blocks submit until a 10 digit number is entered', async () => {
  render(<LoginScreen />);
  const user = userEvent.setup();
  await user.type(screen.getByLabelText('Mobile number'), '98765');
  expect(screen.getByRole('button', { name: 'Send OTP' })).toBeDisabled();
});

Key Points

  • transformIgnorePatterns is the fix for untranspiled ESM in node_modules
  • Reanimated, Gesture Handler and any JSI library need explicit mocks
  • Query by accessibility role and label so tests double as an a11y check
  • Detox or Maestro for real device flows; Jest cannot catch native crashes
Q31

Why did react-native-gesture-handler replace PanResponder, and how do you stop a horizontal swipe from fighting a vertical scroll?

IntermediateGestures

Answer

PanResponder is implemented in the JavaScript responder system: every touch event crosses into JS, so a busy JS thread makes drags lag or drop, and it cannot negotiate with native scroll views, which is why a PanResponder card inside a ScrollView usually behaves badly. react-native-gesture-handler attaches real platform gesture recognizers, UIGestureRecognizer on iOS and a custom orchestrator on Android, so recognition happens on the UI thread and can interoperate with native scrolling. Combined with Reanimated, the callbacks are worklets, so a drag follows the finger even while JavaScript is blocked. Setup detail that costs people an hour: GestureHandlerRootView must wrap your app, and on Android nothing works without it, silently.

The modern API is declarative. Gesture.Pan(), Gesture.Tap(), Gesture.LongPress(), Gesture.Pinch() are composed with Gesture.Simultaneous, Gesture.Exclusive, and Gesture.Race, and cross-component relationships use .simultaneousWithExternalGesture(ref) or .requireExternalGestureToFail(ref). The classic conflict, a horizontal carousel inside a vertically scrolling feed, is solved with activeOffsetX and failOffsetY: the pan only activates after roughly ten points of horizontal movement and fails outright if the finger moves vertically first, so the outer scroll wins the ambiguous case. Other things interviewers listen for: gesture callbacks are worklets so calling setState needs runOnJS, .enabled(false) is how you disable during an animation rather than unmounting the detector, and react-native-screens plus the native stack already give you the iOS interactive back swipe, so you should not rebuild it by hand.

import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';

function Carousel() {
  const x = useSharedValue(0);

  const pan = Gesture.Pan()
    .activeOffsetX([-10, 10])   // only claim the touch after horizontal intent
    .failOffsetY([-8, 8])       // vertical intent hands it to the parent scroll
    .onUpdate((e) => { x.value = e.translationX; });

  const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={style} />
    </GestureDetector>
  );
}

// index.js: without this root view, Android gestures do nothing
export default () => (
  <GestureHandlerRootView style={{ flex: 1 }}>
    <App />
  </GestureHandlerRootView>
);
💡 Pro Tip: If gestures work on iOS and do nothing on Android, check for GestureHandlerRootView at the very root before debugging anything else.
Q32

How do over-the-air JavaScript updates work, and what can they never change?

IntermediateOTA Updates

Answer

An OTA update ships a new JavaScript bundle and its assets to installed apps without a store review. The mainstream tool now is EAS Update through expo-updates, since Microsoft retired App Center and with it CodePush in its original form; expo-updates can also be self hosted against the open update protocol if you do not want a vendor. The mechanism: each build declares a runtimeVersion, the client asks your update server for the newest update matching that runtime version and platform, downloads it in the background, and applies it on the next app launch, or immediately if you call Updates.reloadAsync().

The hard boundary is the native binary. An OTA bundle cannot add a native module, change a permission, bump the app version shown in the store, or alter anything in AndroidManifest.xml or Info.plist. If you publish JavaScript that imports a native module which is not compiled into the installed binary, those users crash on launch, which is precisely why runtimeVersion exists and why the fingerprint policy, which hashes your native dependency graph, is safer than hand-managed strings.

Operationally you want channels mapped to build profiles (production, preview), a staged rollout rather than pushing to everyone at once, and a rollback path, which is republishing a known good update rather than deleting the bad one. Record Updates.updateId in your analytics and crash reporter or you cannot tell which JS revision produced a crash on a given binary. Apple's guideline 3.3.2 permits JavaScript updates that do not change the app's primary purpose, so use OTA for fixes and small changes, not to smuggle in a different product.

// app.json
{
  "expo": {
    "runtimeVersion": { "policy": "fingerprint" },
    "updates": { "url": "https://u.expo.dev/<project-id>", "checkAutomatically": "ON_LOAD" }
  }
}

// force a check on resume rather than only on cold start
import * as Updates from 'expo-updates';

async function applyUpdateIfAny() {
  if (__DEV__) return;                       // dev builds never receive updates
  const result = await Updates.checkForUpdateAsync();
  if (!result.isAvailable) return;
  await Updates.fetchUpdateAsync();
  await Updates.reloadAsync();               // restarts into the new bundle
}

// tag every crash and event with the JS revision
Sentry.setTag('update_id', Updates.updateId ?? 'embedded');

Key Points

  • runtimeVersion pairs a JS bundle to a compatible native binary
  • OTA cannot add native modules, permissions, or change the store version
  • Roll out in stages and roll back by republishing a known good update
  • Log Updates.updateId so crashes map to a specific JS revision
Q33

A user reports a crash you cannot reproduce. What does your React Native observability setup need to answer it?

IntermediateMonitoring

Answer

Start by classifying the failure, because the three kinds need different plumbing. A JavaScript exception shows a red box in development and, in release, either unwinds silently or takes the app down through the global handler. A native crash is a signal such as SIGSEGV, an unhandled Java or Kotlin exception, or an Objective-C exception, and no JavaScript error boundary will ever see it.

An ANR on Android is neither: the main thread was blocked past the timeout, and Play Console counts it against your vitals independently of your crash rate. A working setup captures all three. @sentry/react-native hooks both the JS layer and the native layer, and Crashlytics is common alongside it for native. The part teams get wrong is symbolication.

Hermes ships bytecode, so a production JS stack trace is meaningless unless you upload the Hermes source map produced by the release build, and native frames are meaningless unless you upload iOS dSYMs and the Android R8 mapping.txt for that exact build. Release and dist values must match versionName and versionCode or the uploaded artifacts never get matched to the event. Add context that makes triage possible: an ErrorUtils global handler and React error boundaries for JS, navigation breadcrumbs so you know the screen, network breadcrumbs, the current update id if you ship OTA, and device model plus OS version. That last one matters in India specifically, because out-of-memory kills cluster hard on 3 GB and 4 GB devices and look like random crashes until you group by RAM tier and manufacturer.

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

Sentry.init({
  dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
  // must match versionName / versionCode or source maps never bind
  release: `${pkg.name}@${nativeVersion}`,
  dist: String(nativeBuildNumber),
  tracesSampleRate: 0.1,
  enableNativeCrashHandling: true,
  integrations: [Sentry.reactNativeTracingIntegration()],
});

// catch JS errors that escape every boundary
const previous = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
  Sentry.captureException(error, { level: isFatal ? 'fatal' : 'error' });
  previous(error, isFatal);
});

# upload symbols as part of the release build
# npx sentry-cli sourcemaps upload --release "$REL" --dist "$DIST" ./dist
# npx sentry-cli debug-files upload ./ios/build
💡 Pro Tip: Group crashes by device RAM and manufacturer before you look at stack traces. On low-end Android a large share of what looks like random crashes is the system killing you for memory.
Q34

Your feed drops frames while scrolling on a 4 GB Android phone. Which FlatList knobs do you turn, and in what order?

IntermediateList Performance

Answer

Measure before you tune. Open the Perf Monitor and read the two frame rates: if the UI thread is at 60 and the JS thread has collapsed, the cost is in your JavaScript render path; if the UI thread itself is dropping, the cost is in the native view hierarchy or in image decoding. Then work in this order.

First make the row cheap: extract renderItem into a component wrapped in React.memo, keep its props primitive and stable so memoisation actually holds, define renderItem and keyExtractor outside the parent render so their identity does not change, and remove inline style objects and inline arrow props from the row. Second, remove work the list does not need: supply getItemLayout for fixed-height rows so nothing is measured, set initialNumToRender to just over one screenful rather than the default ten regardless of row height, and let removeClippedSubviews detach off-screen rows on Android. Third, trade blank space against smoothness with windowSize, whose default of 21 viewports of retained content is far too generous on a 4 GB device, and with maxToRenderPerBatch plus updateCellsBatchingPeriod, which control how much work each render pass does between frames.

Fourth, attack images, which are usually the real culprit: request server-resized variants matched to the display box, use expo-image or FastImage with a disk cache and a recyclingKey, and never render a 2000 pixel JPEG into a 120 dp thumbnail, because on Android the decoded bitmap sits in memory at full resolution. If the list is still janky after all that, move to FlashList, which recycles views rather than unmounting them. Blank white cells during a fast fling specifically mean the renderer cannot keep up, which points at windowSize and maxToRenderPerBatch, not at your row component.

const ROW_H = 96;

const Row = React.memo(function Row({ id, title, thumb }) {
  return (
    <View style={styles.row}>
      <ExpoImage source={{ uri: thumb }} style={styles.thumb} recyclingKey={id} cachePolicy="memory-disk" />
      <Text numberOfLines={2} style={styles.title}>{title}</Text>
    </View>
  );
});

const renderItem = ({ item }) => <Row id={item.id} title={item.title} thumb={item.thumb} />;
const keyExtractor = (item) => item.id;
const getItemLayout = (_, i) => ({ length: ROW_H, offset: ROW_H * i, index: i });

<FlatList
  data={items}
  renderItem={renderItem}
  keyExtractor={keyExtractor}
  getItemLayout={getItemLayout}
  initialNumToRender={8}
  maxToRenderPerBatch={6}
  updateCellsBatchingPeriod={60}
  windowSize={7}              // default 21 is far too much on 4 GB devices
  removeClippedSubviews
/>

Key Points

  • Read JS versus UI frame rate first; they point at different fixes
  • Memoised row plus stable renderItem and keyExtractor identities
  • windowSize and maxToRenderPerBatch trade blank cells against memory
  • Oversized images are the most common real cause on low-RAM Android
Q35

How do you upgrade React Native two or three minor versions without wrecking the native projects?

IntermediateUpgrades

Answer

Upgrade one minor at a time and treat the native folders as generated output wherever you can. The React Native Upgrade Helper shows the exact diff of the template between any two versions, which is the file-by-file source of truth for android/ and ios/ changes: Gradle plugin versions, the JDK the build expects, Kotlin and AGP bumps, Podfile structure, and MainApplication or AppDelegate rewrites. On a bare project you apply that diff by hand or with the CLI upgrade command; on an Expo project you run npx expo install --fix to realign every dependency to the SDK's pinned versions, then npx expo prebuild --clean to regenerate the native directories, which is why continuous native generation makes upgrades so much cheaper.

Run npx expo-doctor or @rnx-kit/align-deps to catch libraries pinned to an incompatible React Native range before the build does. Order of operations that saves a day: read the changelog for removals first, bump React Native and React together, then Reanimated, Gesture Handler, Screens, and Safe Area Context as a set because they bind to internals, then everything else. Clear every cache before concluding anything is broken: watchman watch-del-all, delete node_modules, ios/Pods, and Podfile.lock if the CocoaPods resolution is stuck, then ./gradlew clean and Metro with --reset-cache.

If you carry local fixes to a dependency, keep them in patch-package so the upgrade tells you loudly when a patch no longer applies instead of silently losing it. Finally, validate with a release build on a real low-end Android device, because a large share of upgrade regressions (R8 rules, Hermes behaviour, native module init) never appear in a debug build.

# Expo managed / prebuild flow
npx expo install expo@latest
npx expo install --fix          # realign every dep to the SDK's pinned versions
npx expo-doctor
npx expo prebuild --clean       # regenerate android/ and ios/ from config plugins

# Bare React Native flow
npx @react-native-community/cli upgrade 0.xx.x
# then diff against https://react-native-community.github.io/upgrade-helper/

# nuke every cache before you believe a failure
watchman watch-del-all
rm -rf node_modules ios/Pods ios/build android/.gradle
yarn && npx pod-install ios
cd android && ./gradlew clean && cd ..
npx react-native start --reset-cache

# keep local library fixes reproducible
npx patch-package react-native-some-lib
💡 Pro Tip: Upgrade Reanimated, Gesture Handler, Screens and Safe Area Context in the same commit as React Native. They bind to internals, and mixing versions produces errors that point at the wrong library.
Q36

An attacker has your release APK. What can they extract, and what actually raises the bar?

IntermediateSecurity

Answer

Assume everything inside the binary is readable. Unzipping an APK gives resources, the manifest, native libraries, and the JavaScript bundle. Hermes bytecode is not source, which stops casual grepping, but public tooling can disassemble it and recover string literals easily, so any API key, secret, or signing token compiled into the bundle is effectively published.

That includes values injected by react-native-config or Expo public environment variables, which are compile-time substitutions, not runtime secrets. The correct model is that the app holds no secrets: third-party keys must be restricted at the provider (a Maps key locked to your package name and signing SHA-1, a Razorpay key that is the public key id only), and anything privileged goes behind your own backend. For user credentials, use the platform stores through react-native-keychain or expo-secure-store so tokens live in the iOS Keychain or Android Keystore rather than in AsyncStorage.

On the network, certificate pinning against mitmproxy and Frida-style interception is worth doing for payment and KYC flows, but plan for rotation: a pinned certificate that expires with no fallback pin bricks every installed app. Layer attestation rather than relying on detection: the Play Integrity API and iOS App Attest let your server verify the request came from an unmodified build of your app, which is stronger than a client-side root check from a library like jail-monkey, though that is still a useful signal. Housekeeping that reviewers look for: strip console statements in release, set android:allowBackup to false, never log OTPs or tokens, validate deep link parameters before acting on them, restrict WebView with originWhitelist, and apply FLAG_SECURE on screens showing documents or OTPs to block screenshots and screen recording.

// babel.config.js: strip console.* from release bundles
module.exports = {
  presets: ['module:@react-native/babel-preset'],
  env: {
    production: { plugins: ['transform-remove-console'] },
  },
};

// tokens go to Keychain / Keystore, never AsyncStorage
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('refresh_token', token, {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});

// block screenshots on a KYC screen (Android)
import { NativeModules, Platform } from 'react-native';
if (Platform.OS === 'android') NativeModules.SecureFlag?.enable();

<!-- android/app/src/main/AndroidManifest.xml -->
<application android:allowBackup="false"
             android:networkSecurityConfig="@xml/network_security_config" />

Key Points

  • Hermes bytecode is not encryption; bundled keys are public
  • Restrict third-party keys at the provider and keep privileged calls server side
  • Keychain and Android Keystore for tokens, never AsyncStorage
  • Play Integrity and App Attest beat client-side root detection
Q37

How do you measure and cut cold start time on a low-end Android device?

AdvancedStartup Performance

Answer

First define the timeline, because most people optimise the wrong segment. A cold start runs: process creation from zygote, Application.onCreate with every eagerly initialised SDK, React Native runtime setup, Hermes memory mapping the bytecode bundle, evaluation of the bundle's module graph, the first React render, the first Fabric commit and native view mount, then your own time-to-interactive marker. Measure each part rather than guessing. adb shell am start-W gives you TotalTime for the activity launch; Play Console's Android Vitals reports real-world cold start percentiles across your actual install base, which on Indian traffic looks nothing like your test device; a Perfetto trace shows the main thread second by second; and you should add your own marker from native start to the first onLayout of your home screen and report it as a metric, because that is the number users feel.

Then the levers, roughly in order of payoff. Stop importing the world at module scope: a single top-level import of an analytics SDK, a date library with all locales, or a barrel index file drags its whole subgraph into the startup path, and enabling inline requires makes those imports lazy at first use. Defer non-essential SDK initialisation until after the first interactive frame with InteractionManager.runAfterInteractions.

Lazy-load screens so only the launch route's code is evaluated. Under the New Architecture TurboModules are already lazy, which is one of the concrete reasons to migrate. Keep the bundle small, since evaluation time scales with it.

On the native side, audit Application.onCreate, since Firebase, Sentry and attribution SDKs each add tens of milliseconds, and consider a baseline profile. A reasonable target for a consumer app on a 4 GB Android device is under two seconds to interactive.

# measure the real launch, repeatedly, on a real device
adb shell am force-stop com.example.app
adb shell am start -W -n com.example.app/.MainActivity | grep TotalTime

// babel.config.js: make module-scope imports lazy
module.exports = {
  presets: [['module:@react-native/babel-preset', { inlineRequires: true }]],
};

// defer everything that is not needed for the first frame
import { InteractionManager } from 'react-native';

useEffect(() => {
  const task = InteractionManager.runAfterInteractions(() => {
    initAnalytics();
    initAttribution();
    prefetchNextScreenData();
  });
  return () => task.cancel();
}, []);

// report your own TTI, not just the activity launch time
import { AppRegistry } from 'react-native';
const nativeStart = global.nativePerformanceNow?.() ?? 0;
onHomeLaidOut(() => track('tti_ms', Math.round(performance.now() - nativeStart)));

Key Points

  • Split the timeline: process, native init, bundle eval, first commit, TTI
  • adb am start -W plus Android Vitals percentiles, not a single laptop run
  • Inline requires and lazy screens remove code from the startup graph
  • Defer SDK init with InteractionManager; TurboModules are lazy by design
Q38

Explain the thread model and how you would diagnose a dropped-frame problem across it.

AdvancedProfiling

Answer

Four execution contexts matter. The main or UI thread creates, measures and draws native views and handles OS input. The JS thread runs React, your business logic, and the legacy Animated driver.

Layout runs in C++ through Yoga, invoked by Fabric during the commit phase rather than on a separate JavaScript thread as in the old architecture. Reanimated adds a second JavaScript runtime pinned to the UI thread for worklets, and native modules run on their own queues unless they explicitly ask for main. At 60 Hz you have 16.67 ms per frame, and on the 120 Hz panels now common on mid-range Indian phones you have 8.33 ms, which means an animation that felt fine on an old test device can visibly stutter on a newer one.

Diagnose by symptom. If touch responds but an animation stutters, the animation is JS driven and the JS thread is busy: move it to the native driver or to a Reanimated worklet. If everything freezes, including scrolling, the main thread is blocked, typically by a huge view hierarchy, a synchronous JSI call doing real work, a large image decode, or a native module that wrongly requested main queue setup.

If the list shows blank cells, that is virtualisation lag, not a thread problem. Tools: the Perf Monitor gives the two frame rates at a glance, React DevTools' profiler shows which commits are expensive and why, a Perfetto trace on Android shows exactly which thread stalled and for how long, and Instruments' Time Profiler plus Core Animation does the same on iOS. The general fix pattern is to move work off the JS thread with worklets or native code, chunk unavoidable JavaScript with InteractionManager, cut the number of native views, and never do image resizing on device when the server can do it.

💡 Pro Tip: Always record the frame trace on the cheapest device your users actually have. A profile from an M-series simulator will tell you nothing about a 4 GB Android phone on 120 Hz.
Q39

An app slowly grows to an out-of-memory kill after twenty minutes of browsing. How do you find the leak?

AdvancedMemory

Answer

Separate the two heaps first, because the tooling is different. The JavaScript heap lives in Hermes and holds your closures, component state, and cached API responses. The native heap holds decoded bitmaps, native views, C++ objects held by JSI, and library buffers.

A crash with no JavaScript stack, or an Android log line showing the system killing your process under memory pressure, points at the native side; steadily rising GC pressure and slower renders point at the JS side. For the JS heap, use the memory panel in React Native DevTools: take a heap snapshot, navigate into and out of the suspect screen ten times, take a second snapshot, and compare retained size by constructor. Anything whose instance count grows linearly with those ten iterations is your leak.

The recurring causes in React Native are subscriptions without cleanup (AppState, NetInfo, Dimensions, DeviceEventEmitter, a socket listener), timers created in an effect and never cleared, a module-level array or Map used as a cache that nobody bounds, a Redux or Query cache that keeps every page of an infinite list forever, and closures captured by a long-lived native callback that pin an entire screen's props. On the native side, use Android Studio's Memory Profiler and LeakCanary in a debug build to catch retained Activities and Fragments, and Instruments Allocations on iOS. The most common native offender is images: a grid of full-resolution JPEGs decoded to bitmaps consumes memory proportional to pixel count, not file size, so four megapixel photos in a scroll view will exhaust a 3 GB device long before the file sizes look alarming. Bound the image cache, request server-side thumbnails, and drop off-screen data instead of holding every page in state.

// every subscription and timer needs a matching teardown
useEffect(() => {
  const sub = AppState.addEventListener('change', onAppStateChange);
  const net = NetInfo.addEventListener(onConnectivity);
  const id = setInterval(pollOrderStatus, 15_000);
  const controller = new AbortController();

  fetchOrders({ signal: controller.signal }).then(setOrders).catch(noop);

  return () => {
    sub.remove();        // not AppState.removeEventListener, which is gone
    net();               // NetInfo returns an unsubscribe function
    clearInterval(id);
    controller.abort();  // stop the in-flight request from setting state
  };
}, []);

// bound caches instead of letting them grow forever
const queryClient = new QueryClient({
  defaultOptions: { queries: { gcTime: 10 * 60 * 1000 } },
});

Key Points

  • Hermes heap snapshots before and after ten navigations isolate JS leaks
  • LeakCanary and Instruments cover retained native views and Activities
  • Unremoved listeners, uncleared timers and unbounded caches are the usual JS causes
  • Bitmap memory scales with pixels, not file size, so oversized images kill 3 GB devices
Q40

You are moving a four-year-old app to bridgeless New Architecture. What breaks, and how do you sequence the migration?

AdvancedNew Architecture

Answer

The switch itself is a flag: newArchEnabled=true in android/gradle.properties and RCT_NEW_ARCH_ENABLED=1 before pod install on iOS, with recent versions defaulting it on and the legacy architecture marked deprecated. The work is everything around it. Interop layers exist so you are not forced to rewrite every dependency at once: unmigrated native modules run through the TurboModule interop layer, and legacy view managers can run under the Fabric interop layer, though you give up lazy initialisation and synchronous access, and some components need to be registered for interop explicitly.

What genuinely breaks is code that reached under the API. setNativeProps is deprecated, direct UIManager calls and findNodeHandle against flattened views stop returning what you expect, anything holding an RCTBridge reference fails because bridgeless has no bridge, custom RCTRootView hosting needs to move to the surface API, and native modules that assumed eager startup initialisation now run only when first called. Libraries that ship prebuilt binaries against the old architecture simply will not link. Sequence it like this: audit every dependency against its New Architecture support before touching anything and replace or fork the dead ones; get onto a recent React Native version on the legacy architecture first so you are changing one variable at a time; enable the New Architecture behind a separate build variant so you can ship both; run your Detox or Maestro suite plus a manual pass on the payment and login paths; then release to internal testers and watch the native crash rate and ANR rate rather than only the JS crash rate. Capture startup time, memory, and list scroll metrics before and after, because that is the evidence a manager will ask for and an interviewer will expect you to have collected.

# android/gradle.properties
newArchEnabled=true
hermesEnabled=true

# ios: regenerate pods with the flag set
RCT_NEW_ARCH_ENABLED=1 npx pod-install ios

// confirm at runtime which renderer you are on
import { unstable_isFabric } from 'react-native';   // availability varies by version
console.log('fabric:', !!global.nativeFabricUIManager);
console.log('bridgeless:', !!global.RN$Bridgeless);

// opt a legacy view manager into the Fabric interop layer (iOS AppDelegate)
// - (NSDictionary *)thirdPartyFabricComponents { ... }
// or in JS for supported versions:
// unstable_LegacyComponents: ['RNLegacyMapView']
💡 Pro Tip: Ship the New Architecture as a separate build variant first. Being able to flip a user back to the legacy build while you fix one library is worth the extra CI job.
Q41

Walk through building a custom Fabric component with codegen. What does the spec file actually produce?

AdvancedNative Components

Answer

A Fabric component starts as a TypeScript spec named SomethingNativeComponent.ts that calls codegenNativeComponent with a props type. The prop types are deliberately restricted: string, boolean, the numeric aliases Int32, Float and Double, WithDefault for defaults, string unions for enums, ColorValue, and event handlers declared as DirectEventHandler or BubblingEventHandler with a typed payload. You then declare codegenConfig in package.json with type set to components or all.

At build time, before Gradle compiles or CocoaPods builds, codegen reads the spec and generates the C++ ComponentDescriptor, the Props struct, the ShadowNode, and the EventEmitter, plus an Objective-C++ component protocol on iOS and a ViewManagerInterface and ViewManagerDelegate on Android. Your job is to implement against those generated types: on iOS a class deriving from RCTViewComponentView that overrides updateProps and exposes a componentDescriptorProvider, and on Android a ViewManager that implements the generated interface and receives typed setters instead of parsing a ReadableMap. Because the interface is generated from the spec, a mismatch is a compile error rather than an undefined value at runtime, which is the whole point.

Imperative operations use codegenNativeCommands so calling ref methods stays typed. The operational gotchas: codegen runs during the native build, so editing the spec and only restarting Metro changes nothing until you rebuild; events must be named onSomething in the props type and are emitted through the generated EventEmitter, not through RCTEventEmitter; and if you must support both architectures, keep the legacy ViewManager alongside the Fabric implementation and let the build pick.

// specs/RNMapViewNativeComponent.ts
import type { ViewProps, HostComponent } from 'react-native';
import type { Int32, WithDefault, DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands';

type MarkerTapEvent = Readonly<{ markerId: string; lat: Double; lng: Double }>;

export interface NativeProps extends ViewProps {
  zoom?: WithDefault<Int32, 12>;
  mapStyle?: WithDefault<'standard' | 'satellite', 'standard'>;
  showsTraffic?: boolean;
  onMarkerTap?: DirectEventHandler<MarkerTapEvent>;
}

export type MapViewRef = { animateTo: (lat: Double, lng: Double) => void };

export const Commands = codegenNativeCommands<MapViewRef>({
  supportedCommands: ['animateTo'],
});

export default codegenNativeComponent<NativeProps>('RNMapView') as HostComponent<NativeProps>;

// package.json
// "codegenConfig": { "name": "AppSpecs", "type": "all", "jsSrcsDir": "specs",
//   "android": { "javaPackageName": "com.example.specs" } }
Q42

How do you embed React Native into an existing native app, and which thread does a native module method run on?

AdvancedBrownfield

Answer

Brownfield integration means the native app owns the process and React Native renders one or more surfaces inside it. On Android you initialise a ReactHost from your Application class and mount a surface inside an existing Activity or Fragment rather than subclassing ReactActivity, so the host app keeps control of navigation and lifecycle. On iOS you create the React Native factory or host once in the app delegate and host a surface view inside an existing UIViewController.

The rule that matters: one runtime per process, many surfaces. Spinning up a second host to isolate two features doubles memory and startup cost. Data flows in through initial properties when you create the surface, and back out through a TurboModule or an event emitter rather than through globals.

The real friction is not the API, it is dependency resolution: Kotlin, AndroidX, OkHttp and Gson versions clash between the host app and React Native's transitive graph, and on iOS a host app using static libraries and one using use_frameworks! resolve Pods differently. Size is the other cost, since Hermes and the React Native native libraries add several megabytes. Threading is a favourite follow-up.

On Android an @ReactMethod runs on the native modules queue by default, and touching views requires UiThreadUtil.runOnUiThread. On iOS, methodQueue decides where a method runs and requiresMainQueueSetup decides whether the module is constructed on the main queue, which is why heavy work in init produces the main-queue setup warning and lengthens startup. Under the New Architecture a synchronous TurboModule method executes on the JS thread and blocks it, so anything slow must stay asynchronous even though JSI now permits synchronous calls.

// Android: one ReactHost for the whole process, surfaces mounted per screen
class MainApplication : Application(), ReactApplication {
  override val reactNativeHost = DefaultReactNativeHost(this) { /* packages, bundle */ }
  override val reactHost by lazy { getDefaultReactHost(applicationContext, reactNativeHost) }
}

// mount a surface inside an existing Activity
val delegate = ReactDelegate(activity, reactHost, "CheckoutScreen",
  Bundle().apply { putString("orderId", orderId) })
delegate.loadApp()
container.addView(delegate.reactRootView)

// Android threading: view work must hop to the UI thread
@ReactMethod
fun flashScreen(promise: Promise) {
  UiThreadUtil.runOnUiThread { doNativeViewWork(); promise.resolve(null) }
}

// iOS threading
// + (BOOL)requiresMainQueueSetup { return NO; }   // keep init off the main queue
// - (dispatch_queue_t)methodQueue { return dispatch_queue_create("com.app.mod", NULL); }

Key Points

  • One React Native runtime per process, multiple surfaces on top of it
  • Gradle and CocoaPods version conflicts are the real integration cost
  • @ReactMethod runs on the native modules queue; UI work needs the main thread
  • Synchronous TurboModule calls block the JS thread even though JSI allows them
Q43

What do React 19 concurrent features actually change in a React Native app, and when do they not help?

AdvancedConcurrent React

Answer

Concurrent rendering only works because Fabric's shadow tree is immutable: React can build a tree, decide it is stale, and discard it without ever having mutated a view the user is looking at. On the legacy architecture none of this applies. What you get in practice: automatic batching, so several state updates inside a promise, a timeout, or a native callback produce a single render rather than one each; useTransition and startTransition to mark an update as non-urgent, which keeps a text input responsive while an expensive filtered list re-renders at lower priority; useDeferredValue for the same effect without managing a pending flag; Suspense with React.lazy for screen-level code splitting; and useOptimistic for showing a sent message or a like immediately while the mutation is in flight.

The honest limits matter more in an interview than the feature list. Priority scheduling only reorders JavaScript work, so if your jank comes from creating hundreds of native views, decoding images, or a blocked main thread, a transition changes nothing. Concurrent rendering may render a component more than once before committing, so any side effect written in the render body, a counter increment, an analytics call, a mutation of a module-level object, becomes a visible bug rather than a style complaint.

StrictMode double-invoking effects in development exists to surface exactly that. Third-party stores that hold mutable module state without useSyncExternalStore can tear, showing two different values in the same commit. And a transition cannot make a slow network fast: it changes when a render is scheduled, not how long your data takes to arrive.

import { useDeferredValue, useMemo, useState, useTransition } from 'react';

function CandidateSearch({ all }) {
  const [query, setQuery] = useState('');
  const deferred = useDeferredValue(query);           // list lags, input never does
  const [isPending, startTransition] = useTransition();

  const results = useMemo(
    () => all.filter((c) => c.name.toLowerCase().includes(deferred.toLowerCase())),
    [all, deferred],
  );

  return (
    <>
      <TextInput value={query} onChangeText={setQuery} placeholder="Search candidates" />
      {isPending ? <ActivityIndicator /> : null}
      <FlatList
        data={results}
        keyExtractor={(c) => c.id}
        renderItem={({ item }) => <CandidateRow candidate={item} />}
      />
      <Pressable onPress={() => startTransition(() => setFilter('remote'))}>
        <Text>Remote only</Text>
      </Pressable>
    </>
  );
}
💡 Pro Tip: If a transition does not fix your jank, the bottleneck is the UI thread, not React scheduling. Check native view count and image decoding before adding more startTransition calls.
Q44

Your Android download size is 90 MB and installs are dropping. How do you cut it?

AdvancedApp Size

Answer

Measure the composition first with apkanalyzer or Android Studio's APK Analyzer, which breaks the artifact into native libraries per ABI, resources, assets, dex, and the JavaScript bundle. In a typical React Native app the biggest blocks are the .so files (Hermes, the React Native core libraries, plus anything from image, video, ML or map SDKs), image assets, and icon fonts, with the JS bundle usually smaller than people assume. The single largest win is shipping an Android App Bundle rather than a universal APK, because Play then delivers only the one ABI, the one screen density, and the languages the device needs, which commonly removes a third or more of the download.

Next, enable R8 with minifyEnabled and shrinkResources and restrict locales with resConfigs, since many SDKs ship dozens of translations you will never use. Convert PNG sets to WebP or vector drawables. Audit react-native-vector-icons or its successors, because pulling every icon family bundles several font files when you use two.

Move large media (onboarding videos, illustration packs, ML models) out of the binary and download them on first use, which also helps users on metered data. Then audit dependencies honestly: a single analytics or video SDK can add ten megabytes, and duplicated functionality across two libraries is common in apps that grew fast. On the JavaScript side, Metro does not tree shake the way a web bundler does, so import submodules rather than package roots, strip console statements in release, and make sure no source maps or development-only libraries are being packaged.

On iOS the equivalent levers are app thinning through asset catalogs and on-demand resources. Track download size per release in CI so a regression is caught by a diff, not by a drop in installs.

# what is actually in the artifact
apkanalyzer apk file-size app-release.apk
apkanalyzer files list --file-size app-release.apk | sort -k1 -h | tail -30

# what Play will really send to a specific device
bundletool build-apks --bundle=app-release.aab --output=app.apks \
  --device-spec=pixel6.json
bundletool get-size total --apks=app.apks

// android/app/build.gradle
android {
  defaultConfig {
    resConfigs "en", "hi"          // drop locales no user of yours reads
  }
  buildTypes {
    release { minifyEnabled true; shrinkResources true }
  }
}

// Metro does not tree shake: import the submodule, not the package root
import debounce from 'lodash/debounce';   // not: import { debounce } from 'lodash'

Key Points

  • apkanalyzer and bundletool tell you the real per-device download
  • An AAB with Play splits is the single biggest reduction
  • R8, shrinkResources, resConfigs, WebP and trimmed icon fonts follow
  • Metro does not tree shake, so submodule imports genuinely matter
Q45

Design offline-first sync for an app used on patchy networks. What does the client actually need?

AdvancedOffline Sync

Answer

Offline-first means the local database is the source of truth for rendering and the network is a background reconciler, which is a different design from caching API responses. Pick storage by data shape: WatermelonDB or SQLite through op-sqlite or expo-sqlite with a query builder when you have relational data and thousands of rows, and a persisted TanStack Query cache when the app is read-mostly and you only need it to survive a cold start. Writes go into a durable outbox table, not into a promise.

Give every record a client-generated id, a UUID or ULID, so the row exists and renders instantly before any server round trip, and attach an idempotency key to each queued mutation so a retry after a timeout cannot create a second order or a second payment. The outbox is flushed in order with exponential backoff, triggered by app foreground and by a NetInfo connectivity change rather than by a timer, because scheduled background work is unreliable on OEM-restricted Android. Pull changes with a cursor made of updated_at plus id rather than a bare timestamp, since device clocks drift and two rows can share a millisecond, and use tombstone rows so deletions propagate instead of records reappearing.

Conflict policy should be per field, not global: last write wins is fine for a profile bio, but anything involving money or inventory must be server-authoritative with an explicit rejection that surfaces to the user. Two India-specific details worth naming: NetInfo's isConnected can be true on a captive portal or a stalled 2G fallback, so check isInternetReachable and still set request timeouts, and every row that has not synced yet should show its own pending state so the user is never blocked behind a full-screen spinner.

// outbox flush: ordered, idempotent, retried with backoff
async function flushOutbox() {
  const pending = await db.getAll(
    'SELECT * FROM outbox WHERE state = ? ORDER BY seq ASC LIMIT 50', ['pending'],
  );

  for (const op of pending) {
    try {
      const res = await fetch(op.url, {
        method: op.method,
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': op.clientId,   // retry cannot double-charge
        },
        body: op.payload,
        signal: AbortSignal.timeout(15_000),
      });

      if (res.status >= 500 || res.status === 429) throw new Error('retryable');
      if (!res.ok) { await db.run('UPDATE outbox SET state=? WHERE seq=?', ['rejected', op.seq]); continue; }

      await db.run('UPDATE outbox SET state=? WHERE seq=?', ['done', op.seq]);
    } catch {
      await db.run('UPDATE outbox SET attempts=attempts+1 WHERE seq=?', [op.seq]);
      return;                               // preserve ordering, retry later
    }
  }
}

NetInfo.addEventListener((s) => { if (s.isInternetReachable) flushOutbox(); });

Key Points

  • Local database is the render source of truth; the network reconciles in the background
  • Client-generated ids plus idempotency keys make retries safe
  • Cursor on updated_at plus id, with tombstones for deletes
  • Flush on foreground and connectivity change, not on OEM-throttled background work

Companies Hiring React Native

Swiggy
Meesho
Zomato
Flipkart
Tata 1mg
Khatabook
Microsoft
Amazon

Salary Insights

Average in India
₹6-22 LPA

Frequently Asked Questions

What does a React Native developer earn in India in 2026?

The broad band is ₹6-22 LPA. Freshers and developers with under two years typically land ₹4-8 LPA at services companies and agencies, mid-level engineers with three to five years and shipped apps in production sit around ₹12-20 LPA, and senior or lead engineers at product companies such as Swiggy, Meesho, Zomato, Flipkart, Tata 1mg, or the India teams of Microsoft and Amazon regularly cross ₹25 LPA, with staff-level offers higher again. The premium goes to people who are not purely JavaScript developers: if you can write a TurboModule, read a Perfetto trace, debug a native crash in Logcat, and own the Play Console and App Store Connect release process, you are competing in a much thinner pool than someone who has only assembled screens.

How long should I prepare for React Native interviews?

If you already write React daily and have shipped at least one app to a store, three to four weeks of focused work is usually enough: one week on the New Architecture (JSI, TurboModules, Fabric, bridgeless) because that is where most 2026 panels start, one week on performance (list tuning, cold start, memory, the thread model), one week on release and production topics (signing, AAB, OTA updates, crash symbolication, push delivery), and a few days rehearsing one real incident you personally fixed. If you are coming from web React with no mobile experience, plan on two to three months, and spend a large share of it actually building and releasing something rather than reading, because interviewers can tell within a few questions whether you have ever dealt with a Play Store rejection or an out-of-memory crash on a real device.

How different are fresher and experienced React Native interviews?

Freshers are tested on fundamentals and whether they can build: rendering and styling, FlatList versus ScrollView, navigation, forms and keyboard handling, fetching data, and one or two debugging scenarios. A working app on GitHub or on a store listing carries more weight than any certificate. From roughly three years of experience the questions change shape completely. You are asked to explain a specific frame drop you diagnosed, how you cut cold start, what broke when you enabled the New Architecture, how you handled a production crash spike, and how you decided between Expo and the bare CLI for a given project. Senior rounds add system design (offline sync, push architecture, release strategy, monorepo and code sharing) and often a code review exercise. Experienced candidates also get asked about native fluency, because a lead who cannot open Xcode or Android Studio becomes a bottleneck.

Is React Native still worth learning in 2026?

Yes, and the New Architecture is the reason it stopped being a compromise. Fabric, TurboModules and JSI removed the bridge that caused most of the performance objections, Hermes made startup competitive on low-end Android, and the ecosystem around Expo, EAS Build, Reanimated and Gesture Handler is mature enough that a small team can ship both platforms properly. In India specifically the demand is steady because most consumer products need Android and iOS from day one with a team that is already fluent in React, and hiring one React Native engineer is cheaper and faster than hiring separate Android and iOS engineers. The caveat worth saying out loud in an interview: React Native is not the right answer for graphics-heavy games, for apps that are essentially a thin wrapper around platform-specific hardware APIs, or for teams that already have strong native benches.

React Native or Flutter for the Indian job market?

Both have real demand and similar salary bands, so the honest differentiator is your starting point and the kind of company you want. React Native reuses JavaScript and TypeScript, so a web React developer is productive in weeks, and it dominates in companies that already run React on the web and want shared logic, shared hiring, and code that can extend to React Native Web. Flutter compiles Dart ahead of time and draws its own widgets, which gives very consistent rendering across devices and appeals to teams starting fresh with no web React investment. In Indian job listings React Native postings skew toward product startups and consumer apps with existing React teams, while Flutter appears more in agencies, fintech, and greenfield builds. Learning one well beats knowing both shallowly, and interviewers notice when a candidate can articulate the trade-off without turning it into advocacy.

Do I need to know native Android and iOS to get hired?

You do not need to write production Kotlin or Swift, but you cannot be helpless in the native projects. The practical bar for a mid-level role is: you can read a Gradle file and fix a dependency conflict, open Logcat and identify a native stack trace, run pod install and understand what Podfile.lock is doing, configure signing and produce a release build, add a permission to AndroidManifest.xml and Info.plist, and follow a library's native installation instructions when autolinking does not cover it. For senior roles the expectation rises to writing a TurboModule or a Fabric component when no library exists, profiling with Android Studio or Instruments, and owning the release pipeline. Candidates who treat android/ and ios/ as folders they never open are the ones who stall at a specific salary band, because every hard bug in a React Native app eventually lands in native territory.

Introduction

React Native in 2026 is a very different framework from the one most tutorials describe. Since version 0.76 the New Architecture is the default: Fabric replaces the old UIManager, TurboModules replace the asynchronous native module registry, and JSI lets JavaScript hold direct references to C++ objects instead of posting JSON messages across a bridge. Hermes is the standard engine, bridgeless mode is the norm on fresh apps, and the legacy architecture has been marked deprecated in recent releases. If your mental model still starts with the phrase batched bridge, an interviewer will notice inside the first five minutes of the technical round.

Indian interviews for React Native roles are unusually practical because the devices are unforgiving. A large share of users run 3 GB to 4 GB Android phones on patchy networks, so panels dig into list virtualisation, image memory, cold start time, APK and AAB size, offline behaviour, and OTP autofill rather than trivia about component names. Companies hiring these skills in India include Swiggy, Meesho, Zomato, Flipkart, Tata 1mg, Khatabook, plus the India engineering arms of Microsoft and Amazon. Expect at least one round where you are asked to explain a real frame drop, a real out of memory crash, or a real Play Store rejection you personally fixed.

This set works through 45 React Native interview questions ordered from fundamentals to senior-level depth: 18 basic, 18 intermediate, and 9 advanced. Every technical answer names the actual API, config key, or error string involved, and more than half carry a runnable code example. Use the basic block to firm up rendering, styling, lists, navigation, and tooling. The intermediate block covers Hermes, JSI, TurboModules, Reanimated, release builds, testing, and push notifications. The advanced block is where offers are decided: thread-level profiling, cold start budgets, bridgeless migration, custom Fabric components, memory leaks, and brownfield integration.

Ready to practice React Native interviews?

Don't just read, practice these React Native questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview