InterviewHack.ai
Empezar gratis
Blog/React Native Interview Questions and How to Answer Them (40+ Questions)

React Native Interview Questions and How to Answer Them (40+ Questions)

September 16, 2026

react-nativemobile

Comprehensive React Native interview prep article with 45 numbered questions, detailed answers, and real code examples targeting LATAM developers preparing for English-speaking tech company interviews.

React Native Interview Questions and How to Answer Them (40+ Questions)

React Native sits at the intersection of mobile development and JavaScript expertise. Interviewers test both breadth (can you ship a real app?) and depth (do you understand the bridge, the thread model, and the rendering pipeline?). This guide covers 40+ questions — from fundamentals to architecture, performance, and system design — with the exact level of detail that separates hired candidates from filtered ones.

Work through every question. Say each answer out loud. That retrieval practice under pressure is what actually sticks.


How This Guide Is Organized

  • Section 1 — Core Concepts (Q1–Q10): The foundation every interviewer tests first.
  • Section 2 — Components, State, and Hooks (Q11–Q20): Day-to-day React Native work.
  • Section 3 — Navigation and Architecture (Q21–Q28): How real apps are structured.
  • Section 4 — Performance (Q29–Q35): Where senior candidates separate from juniors.
  • Section 5 — Native Modules and Platform APIs (Q36–Q40): Bridge-level knowledge.
  • Section 6 — System Design and Behavioral (Q41–Q45): Final-round questions.

Section 1 — Core Concepts

Q1. What is React Native and how does it differ from a WebView-based approach like Cordova?

What the interviewer is testing: Whether you understand the actual rendering model, not just "it uses JavaScript."

Answer:

React Native compiles your component tree into real native UI elements. When you render , the framework instructs the native platform to create an android.view.View or a UIView — actual platform primitives, not HTML elements inside a browser.

Cordova (and similar hybrid frameworks) wrap a WebView that renders HTML, CSS, and JavaScript. The UI runs in a browser engine. Touch events, animations, and scrolling go through the web layer before hitting native code, which creates a performance ceiling.

The practical difference: React Native apps look and feel like native apps because they *are* native apps at the UI layer. Cordova apps feel like websites because they are websites.

| | React Native | Cordova/WebView |

|---|---|---|

| UI rendering | Native components | HTML in a WebView |

| Performance | Close to native | Web-speed |

| Access to native APIs | Through bridge/JSI | Through plugins |

| App store approval | Standard | Standard |

Code comparison:

jsx
// React Native — this creates a real UILabel on iOS, a real TextView on Android
<Text style={{ fontSize: 18, color: '#333' }}>Hello, world</Text>

// Cordova — this renders an <p> tag inside a WebView
<p style="font-size: 18px; color: #333;">Hello, world</p>

Q2. Explain the React Native architecture: the old bridge vs. the new JSI.

What the interviewer is testing: Whether you understand concurrency, serialization overhead, and why the new architecture matters.

Answer:

Old architecture (the bridge):

The JavaScript thread and native threads communicate through an asynchronous message-passing bridge. Every call serializes data to JSON, sends it across the bridge, and deserializes it on the other side. This is:

  • Asynchronous (fire-and-forget, no direct return values)
  • Serialization-heavy (JSON.stringify/parse on every cross-thread call)
  • A bottleneck for high-frequency operations like animations and gestures
JS Thread ──[JSON serialize]──> Bridge ──[JSON deserialize]──> Native Thread
Native Thread ──[JSON serialize]──> Bridge ──[JSON deserialize]──> JS Thread

New architecture (JSI — JavaScript Interface):

JSI provides a C++ layer that JavaScript can call *synchronously* and *directly*, without serialization. Instead of sending JSON messages, JavaScript holds a direct reference to a native host object.

The new architecture also introduces:

  • Fabric: The new rendering system that can prioritize UI updates.
  • TurboModules: Lazy-loaded native modules using JSI instead of the bridge.
  • Codegen: Automatically generates C++ glue code from TypeScript types, eliminating hand-written bridge code.
// Old: asynchronous, serialized
NativeModules.CameraModule.takePicture(options, callback);

// New (TurboModule): synchronous reference, no serialization
const camera = TurboModuleRegistry.get<Spec>('CameraModule');
camera.takePicture(options); // direct C++ call

Why it matters in interviews: When you're asked about jank, animation performance, or why useNativeDriver: true exists, the bridge is the root cause. Knowing this shows architectural depth.


Q3. What are the three main threads in React Native and what does each do?

What the interviewer is testing: Whether you can reason about why UI freezes happen and where to look when debugging them.

Answer:

1. JavaScript Thread

Runs your application logic: component renders, state updates, business logic, API calls. This is a single-threaded JavaScript engine (Hermes or V8). Heavy computation here blocks re-renders.

2. Main/UI Thread (Native Thread)

Handles native UI rendering, user input, and touch events. This thread must never be blocked — a blocked UI thread means a frozen, unresponsive app. The 16ms budget per frame (60 fps) lives here.

3. Shadow Thread (Layout Thread)

Runs Yoga (Facebook's cross-platform layout engine). Takes your flexbox styles and computes the actual pixel positions and dimensions. Results are passed to the UI thread for rendering.

Bonus — Background Threads:

Native modules can spin their own threads (networking, database, image decoding). This is important for understanding why async storage or SQLite operations don't block the UI.

Interview tip — how to connect this to real problems:

jsx
// This blocks the JS thread — causes dropped frames
const handlePress = () => {
  const result = heavyComputation(); // 300ms synchronous work
  setData(result);
};

// Better: move off the JS thread
const handlePress = async () => {
  const result = await runInWorker(() => heavyComputation());
  setData(result);
};

Q4. What is Hermes and why does React Native use it?

What the interviewer is testing: Basic knowledge of the JS engine layer and performance implications.

Answer:

Hermes is a JavaScript engine built by Meta specifically for React Native. It replaced JavaScriptCore as the default engine.

Key differences from JavaScriptCore:

| | Hermes | JavaScriptCore |

|---|---|---|

| Compilation | AOT (Ahead of Time) to bytecode | JIT (Just in Time) |

| App startup | Faster (bytecode pre-compiled) | Slower (JIT warmup) |

| Memory usage | Lower | Higher |

| Bundle | Ships bytecode, not raw JS | Ships raw JS |

Why AOT matters:

Hermes compiles JavaScript to bytecode at build time, not at runtime. The device never needs to parse and compile your JS — it executes bytecode directly. This is particularly impactful on low-end Android devices where JIT is slow.

bash
# Check if Hermes is enabled in your app
npx react-native info

# In your Metro bundle, you'll see .hbc (Hermes Bytecode) instead of .jsbundle

Enable Hermes (React Native 0.70+ it's the default):

js
// android/app/build.gradle
project.ext.react = [
    enableHermes: true
]

Q5. What is the difference between `StyleSheet.create` and plain JavaScript objects for styles?

What the interviewer is testing: Whether you understand how styles are processed and when optimization matters.

Answer:

StyleSheet.create is not just syntactic sugar. It has concrete benefits:

1. Validation at development time

StyleSheet validates style properties during development. A typo in a style key throws an error immediately instead of silently doing nothing.

jsx
// StyleSheet.create catches this at dev time
const styles = StyleSheet.create({
  container: {
    backgrondColor: '#fff', // typo! throws in dev
  },
});

// Plain object silently ignores the typo
const style = {
  backgrondColor: '#fff', // no error, no styling applied
};

2. Performance: ID-based referencing

StyleSheet.create sends styles to the native thread once and assigns each style an integer ID. Subsequent renders pass the integer instead of re-serializing the full object.

jsx
// StyleSheet: sends { container: 1 } across the bridge on each render
// (the actual style object was sent once at creation time)
<View style={styles.container} />

// Inline object: re-serializes the full object on every render
<View style={{ padding: 16, backgroundColor: '#fff' }} />

When inline styles are unavoidable:

Dynamic values that change per-render can't be pre-compiled:

jsx
// This can't be a StyleSheet — it depends on runtime state
<View style={[styles.base, { opacity: animatedValue }]} />

Best practice:

jsx
const styles = StyleSheet.create({
  base: {
    flex: 1,
    padding: 16,
  },
  active: {
    backgroundColor: '#007AFF',
  },
});

// Combine static StyleSheet with dynamic value
<View style={[styles.base, isActive && styles.active, { opacity: dynamicOpacity }]} />

Q6. How does Flexbox work in React Native compared to CSS Flexbox?

What the interviewer is testing: Practical layout knowledge, and awareness of React Native's differences from web CSS.

Answer:

React Native uses Yoga, Meta's cross-platform layout engine, which implements a subset of CSS Flexbox with some key differences:

Differences from web Flexbox:

| Property | Web CSS | React Native |

|---|---|---|

| flexDirection default | row | column |

| Units | px, %, em, rem, vw | unitless numbers (density-independent pixels) |

| display property | any value | only flex or none |

| position | static, relative, absolute, fixed, sticky | relative, absolute |

| No float, grid, table | available | not available |

The most common mistake:

jsx
// Wrong: forgetting that default flexDirection is 'column'
<View style={{ flex: 1 }}>
  {/* These stack vertically, not horizontally */}
  <Text>First</Text>
  <Text>Second</Text>
</View>

// Correct: explicit when you want horizontal layout
<View style={{ flex: 1, flexDirection: 'row' }}>
  <Text>First</Text>
  <Text>Second</Text>
</View>

Key patterns:

jsx
// Full-screen container
<View style={{ flex: 1 }} />

// Center content both axes
<View style={{
  flex: 1,
  justifyContent: 'center',
  alignItems: 'center',
}}>
  <Text>Centered</Text>
</View>

// Space items evenly
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
  <View style={{ flex: 1 }} />
  <View style={{ flex: 2 }} />
  <View style={{ flex: 1 }} />
</View>

Q7. What is the Virtual DOM and how does React Native's reconciliation work?

What the interviewer is testing: Whether you understand how state changes translate to UI updates without direct DOM manipulation.

Answer:

React Native does not use a browser DOM. Instead, it maintains a virtual tree of component descriptions in JavaScript. When state changes, React:

  1. 1Re-renders the component tree in JavaScript (fast, in-memory)
  2. 2Runs the reconciler (Fiber in modern React) to diff the old and new virtual trees
  3. 3Computes the minimal set of changes needed
  4. 4Sends only those changes across the bridge/JSI to the native thread
  5. 5The native thread applies the changes to real UI components

Why the diffing step matters:

Without reconciliation, every state change would re-create the entire native UI. With reconciliation, React identifies exactly which native components need to update.

jsx
// Before state update:
// Virtual tree: <View> <Text>0</Text> <Button /> </View>

// After setState({ count: 1 }):
// React diffs and finds only the Text changed
// Sends ONE update to native: "update Text node to '1'"

const Counter = () => {
  const [count, setCount] = useState(0);
  return (
    <View>
      <Text>{count}</Text>           // only this updates
      <Button onPress={() => setCount(c => c + 1)} title="+" />  // this doesn't
    </View>
  );
};

Keys and list reconciliation:

jsx
// Without keys: React re-renders all items on insertion
{items.map(item => <Item text={item.text} />)}

// With keys: React identifies items by key, only updates what changed
{items.map(item => <Item key={item.id} text={item.text} />)}

Never use array index as a key for reorderable lists — it defeats reconciliation.


Q8. What is the difference between `Platform.OS`, `Platform.select`, and platform-specific file extensions?

What the interviewer is testing: Practical knowledge of writing code that behaves correctly on both iOS and Android.

Answer:

React Native provides three mechanisms for platform-specific code, each appropriate for different situations.

Platform.OS — inline conditional logic:

jsx
import { Platform } from 'react-native';

const styles = StyleSheet.create({
  container: {
    paddingTop: Platform.OS === 'ios' ? 44 : 0, // status bar height
    shadowColor: Platform.OS === 'ios' ? '#000' : undefined,
    elevation: Platform.OS === 'android' ? 4 : undefined,
  },
});

Platform.select — cleaner multi-value selection:

jsx
const containerStyle = Platform.select({
  ios: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  android: {
    elevation: 5,
  },
  default: {
    // for web, desktop, etc.
  },
});

Platform-specific file extensions — complete component replacement:

When the platform-specific logic is large enough that a single file becomes unreadable, split into separate files:

Button.ios.tsx       // loaded on iOS
Button.android.tsx   // loaded on Android
Button.tsx           // fallback for other platforms
tsx
// Button.ios.tsx
export const Button = () => (
  <TouchableOpacity style={styles.iosStyle}>...</TouchableOpacity>
);

// Button.android.tsx
export const Button = () => (
  <TouchableNativeFeedback>...</TouchableNativeFeedback>
);

// Usage — Metro resolves the right file automatically
import { Button } from './Button';

Rule of thumb:

  • Single value differences → Platform.OS
  • Multiple properties → Platform.select
  • Different rendering logic → file extensions

Q9. What is Metro and what does it do?

What the interviewer is testing: Understanding of the build toolchain, critical for debugging import errors and bundle optimization.

Answer:

Metro is React Native's JavaScript bundler, developed by Meta. It's equivalent to webpack in the web world but optimized for mobile development.

What Metro does:

  1. 1Resolution: Finds all files starting from your entry point (index.js), following all import and require statements.
  2. 2Transformation: Runs each file through Babel transformers (converts JSX, TypeScript, and modern JS to JavaScript the engine understands).
  3. 3Serialization: Combines all transformed modules into a single bundle (or split bundles for lazy loading).

Development vs. production mode:

bash
# Development: Metro serves the bundle live over HTTP
# The app fetches http://localhost:8081/index.bundle
npx react-native start

# Production: Metro builds a static bundle file
npx react-native bundle \
  --platform android \
  --dev false \
  --entry-file index.js \
  --bundle-output android/app/src/main/assets/index.android.bundle

metro.config.js — common customizations:

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

const config = {
  resolver: {
    // Add custom file extensions
    assetExts: ['glb', 'gltf', 'png', 'jpg'],
    sourceExts: ['js', 'jsx', 'ts', 'tsx', 'json'],
  },
  transformer: {
    // Custom transformer for SVGs
    babelTransformerPath: require.resolve('react-native-svg-transformer'),
  },
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

Hot Module Replacement (HMR):

Metro supports HMR, which updates changed modules without a full app reload, preserving component state during development.


Q10. What are the differences between `TouchableOpacity`, `TouchableHighlight`, `TouchableNativeFeedback`, and `Pressable`?

What the interviewer is testing: Awareness of the touchable API evolution and when to use which.

Answer:

| Component | Visual feedback | Platform | Status |

|---|---|---|---|

| TouchableOpacity | Dims the child | Both | Stable, widely used |

| TouchableHighlight | Highlights with underlay color | Both | Stable, less common |

| TouchableNativeFeedback | Platform ripple effect | Android only | Stable |

| Pressable | Fully customizable | Both | Recommended (React Native 0.63+) |

TouchableOpacity — the workhorse:

jsx
<TouchableOpacity
  activeOpacity={0.7}
  onPress={handlePress}
  onLongPress={handleLongPress}
>
  <Text>Press me</Text>
</TouchableOpacity>

Pressable — the modern replacement:

jsx
<Pressable
  onPress={handlePress}
  style={({ pressed }) => [
    styles.button,
    pressed && styles.buttonPressed, // style changes on press
  ]}
>
  {({ pressed }) => (
    <Text style={pressed ? styles.textPressed : styles.text}>
      {pressed ? 'Pressing...' : 'Press me'}
    </Text>
  )}
</Pressable>

Pressable is preferred because:

  • The pressed state is passed as a render prop — you can style any child
  • Supports onPressIn, onPressOut, onLongPress with timing control
  • Supports hitSlop for expanding the tap area without changing layout
jsx
<Pressable
  hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
  onPress={handlePress}
>
  <Icon name="close" size={16} /> {/* Small icon, large tap area */}
</Pressable>

Section 2 — Components, State, and Hooks

Q11. Explain the difference between `useState` and `useReducer`. When do you choose one over the other?

What the interviewer is testing: Whether you can reason about state management complexity, not just syntax.

Answer:

useState is for simple, independent pieces of state. useReducer is for complex state where the next state depends on the previous state, or when multiple sub-values need to change together.

useState — simple state:

jsx
const [count, setCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

The problem useReducer solves:

When multiple state values change together, coordinating multiple useState calls creates race conditions and cognitive overhead:

jsx
// Fragile: three separate state updates for one logical operation
const fetchUser = async () => {
  setIsLoading(true);
  setError(null);
  try {
    const user = await api.getUser(id);
    setUser(user);
    setIsLoading(false);
  } catch (e) {
    setError(e.message);
    setIsLoading(false);
  }
};

useReducer — coordinated state:

tsx
type State = {
  user: User | null;
  isLoading: boolean;
  error: string | null;
};

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: User }
  | { type: 'FETCH_ERROR'; payload: string };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'FETCH_START':
      return { user: null, isLoading: true, error: null };
    case 'FETCH_SUCCESS':
      return { user: action.payload, isLoading: false, error: null };
    case 'FETCH_ERROR':
      return { user: null, isLoading: false, error: action.payload };
  }
};

const UserProfile = ({ id }: { id: string }) => {
  const [state, dispatch] = useReducer(reducer, {
    user: null,
    isLoading: false,
    error: null,
  });

  const fetchUser = async () => {
    dispatch({ type: 'FETCH_START' });
    try {
      const user = await api.getUser(id);
      dispatch({ type: 'FETCH_SUCCESS', payload: user });
    } catch (e) {
      dispatch({ type: 'FETCH_ERROR', payload: e.message });
    }
  };
};

Decision rule:

Use useReducer when:

  • State has more than 2-3 sub-values that change together
  • The next state depends on the previous in non-trivial ways
  • You want to test state transitions in isolation (reducers are pure functions)

Q12. What is `useCallback` and when does it actually matter?

What the interviewer is testing: Whether you understand reference equality, unnecessary re-renders, and when optimization has real cost.

Answer:

useCallback returns a memoized version of a callback function. The function reference stays stable between renders unless its dependencies change.

Without useCallback — the problem:

jsx
const Parent = () => {
  const [count, setCount] = useState(0);

  // New function reference on every render
  const handlePress = () => {
    console.log('pressed');
  };

  return (
    <View>
      <Text>{count}</Text>
      <Button onPress={handlePress} /> {/* re-renders on every count change */}
      <ExpensiveList onItemPress={handlePress} /> {/* also re-renders */}
    </View>
  );
};

With useCallback — stable reference:

jsx
const Parent = () => {
  const [count, setCount] = useState(0);

  // Same function reference unless dependencies change
  const handlePress = useCallback(() => {
    console.log('pressed');
  }, []); // empty deps: never recreated

  return (
    <View>
      <Text>{count}</Text>
      <Button onPress={handlePress} />
      <ExpensiveList onItemPress={handlePress} /> {/* skips re-render */}
    </View>
  );
};

useCallback only helps when the child is memoized:

jsx
// useCallback on handlePress is only useful if Button is wrapped in React.memo
const Button = React.memo(({ onPress, title }) => (
  <Pressable onPress={onPress}>
    <Text>{title}</Text>
  </Pressable>
));

Common mistake — over-memoizing:

jsx
// This adds overhead without benefit — handlePress has no dependencies
// and the component that receives it doesn't use React.memo
const handlePress = useCallback(() => {
  doSomethingSimple();
}, []);

Rule: Profile first. Add useCallback when you can measure a re-render problem it would solve.


Q13. How does `useMemo` differ from `useCallback` and when should you use it?

What the interviewer is testing: Understanding of computational memoization vs. function reference stability.

Answer:

  • useCallback(fn, deps) — memoizes the *function itself*
  • useMemo(() => value, deps) — memoizes the *computed value* returned by a function

useMemo for expensive computations:

jsx
const ProductList = ({ products, searchQuery, sortOrder }) => {
  // Without useMemo: this runs on every render
  // With useMemo: only runs when products, searchQuery, or sortOrder changes
  const filteredAndSorted = useMemo(() => {
    return products
      .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
      .sort((a, b) =>
        sortOrder === 'asc' ? a.price - b.price : b.price - a.price
      );
  }, [products, searchQuery, sortOrder]);

  return (
    <FlatList
      data={filteredAndSorted}
      renderItem={({ item }) => <ProductCard product={item} />}
    />
  );
};

useMemo for stable object references:

jsx
const UserCard = ({ userId, theme }) => {
  // Without useMemo: new object reference on every render
  // causes child components using this style prop to re-render
  const cardStyle = useMemo(() => ({
    backgroundColor: theme.colors.card,
    borderRadius: theme.radii.lg,
    padding: theme.spacing.md,
  }), [theme]);

  return <AnimatedCard style={cardStyle} />;
};

Mental model:

  • useCallback(fn, deps) ≡ useMemo(() => fn, deps)
  • Both prevent unnecessary work downstream, but useMemo prevents the work *inside* the component, while useCallback prevents work *in the child*.

Q14. What is `useRef` and what are its React Native-specific uses?

What the interviewer is testing: Practical knowledge beyond "mutable value that doesn't trigger re-render."

Answer:

useRef returns a mutable object ({ current: value }) that persists across renders. Writing to .current does not trigger a re-render.

Three distinct use cases in React Native:

1. Holding a DOM/native node reference:

jsx
const TextInputFocus = () => {
  const inputRef = useRef<TextInput>(null);

  useEffect(() => {
    // Focus the input after mount
    inputRef.current?.focus();
  }, []);

  return <TextInput ref={inputRef} placeholder="Name" />;
};

2. Keeping a mutable value across renders without triggering re-renders:

jsx
const VoiceRecorder = () => {
  const recordingRef = useRef<Recording | null>(null);
  const [isRecording, setIsRecording] = useState(false);

  const startRecording = async () => {
    // Store the recording object without triggering re-render
    recordingRef.current = await Audio.Recording.createAsync(
      Audio.RECORDING_OPTIONS_PRESET_HIGH_QUALITY
    );
    setIsRecording(true);
  };

  const stopRecording = async () => {
    await recordingRef.current?.stopAndUnloadAsync();
    recordingRef.current = null;
    setIsRecording(false);
  };
};

3. Storing the previous value of a prop or state:

jsx
const usePrevious = <T>(value: T): T | undefined => {
  const ref = useRef<T>();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
};

const Counter = () => {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <Text>
      Current: {count}, Previous: {prevCount ?? 'none'}
    </Text>
  );
};

Q15. How do you handle forms in React Native? Compare controlled vs. uncontrolled approaches.

What the interviewer is testing: Practical form management knowledge, and awareness of performance in long forms.

Answer:

Controlled inputs — React owns the value:

jsx
const LoginForm = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const validate = () => {
    const newErrors: typeof errors = {};
    if (!email.includes('@')) newErrors.email = 'Valid email required';
    if (password.length < 8) newErrors.password = 'At least 8 characters';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = () => {
    if (validate()) {
      api.login({ email, password });
    }
  };

  return (
    <View>
      <TextInput
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
        autoCapitalize="none"
        autoCorrect={false}
      />
      {errors.email && <Text style={styles.error}>{errors.email}</Text>}

      <TextInput
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      {errors.password && <Text style={styles.error}>{errors.password}</Text>}

      <Button onPress={handleSubmit} title="Log In" />
    </View>
  );
};

Performance problem with large forms:

Every keystroke in a controlled input calls setState, which re-renders the entire form. For a form with 20 fields, this is noticeable.

Solutions:

jsx
// Option 1: useReducer to batch all field updates
const [form, dispatch] = useReducer(formReducer, initialValues);

// Option 2: react-hook-form (uncontrolled by default, best performance)
import { useForm, Controller } from 'react-hook-form';

const { control, handleSubmit } = useForm({
  defaultValues: { email: '', password: '' },
});

<Controller
  control={control}
  name="email"
  rules={{ required: true, pattern: /\S+@\S+\.\S+/ }}
  render={({ field: { onChange, value }, fieldState: { error } }) => (
    <>
      <TextInput value={value} onChangeText={onChange} />
      {error && <Text>{error.message}</Text>}
    </>
  )}
/>

react-hook-form uses uncontrolled inputs with refs under the hood — the native input keeps the value, and React only reads it on submit. This can cut form re-renders by 80%+ on complex forms.


Q16. What is `FlatList` and how does it improve performance over `ScrollView`?

What the interviewer is testing: One of the most common React Native performance questions — list rendering.

Answer:

ScrollView renders all its children at once. For a list of 500 items, all 500 native views exist in memory and are rendered immediately. This causes:

  • Slow initial render
  • High memory usage
  • Laggy scrolling

FlatList uses windowing (also called virtualization): it only renders the items visible on screen, plus a small buffer above and below. As you scroll, off-screen items are unmounted and new ones are mounted.

jsx
// ScrollView — renders ALL 500 items immediately
<ScrollView>
  {items.map(item => <ItemCard key={item.id} item={item} />)}
</ScrollView>

// FlatList — renders ~20 items, recycles views as you scroll
<FlatList
  data={items}
  keyExtractor={item => item.id}
  renderItem={({ item }) => <ItemCard item={item} />}
  // Performance tuning props:
  initialNumToRender={10}       // items rendered before first paint
  maxToRenderPerBatch={10}      // items added per render batch
  windowSize={5}                // units of screen height to render (above + below)
  removeClippedSubviews={true}  // detach off-screen views (Android)
  getItemLayout={(data, index) => ({  // skip dynamic measurement if items are fixed height
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  })}
/>

keyExtractor is not optional:

Without keys, FlatList can't identify which items changed. Use a stable, unique ID from your data:

jsx
keyExtractor={item => item.id.toString()}

When to use ScrollView instead:

  • A small, fixed number of items (fewer than 20-30)
  • Heterogeneous content (header, content, footer) where list semantics don't apply
  • Forms

Q17. How do you handle keyboard avoidance in React Native?

What the interviewer is testing: Awareness of a common UX problem on mobile that breaks forms and chats.

Answer:

When the software keyboard appears, it covers part of the screen. Inputs near the bottom become invisible behind the keyboard unless you compensate.

KeyboardAvoidingView — the built-in solution:

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

const CommentForm = () => (
  <KeyboardAvoidingView
    style={{ flex: 1 }}
    behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 0}
  >
    <ScrollView>
      {/* content */}
    </ScrollView>
    <TextInput
      style={styles.input}
      placeholder="Add a comment..."
    />
  </KeyboardAvoidingView>
);

The behavior prop matters:

  • 'padding' — adds padding at the bottom of the view equal to keyboard height (works well on iOS)
  • 'height' — reduces the view height (works better on Android)
  • 'position' — repositions the view

react-native-keyboard-aware-scroll-view — more reliable:

The built-in solution has edge cases. This library handles them:

jsx
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';

const Form = () => (
  <KeyboardAwareScrollView>
    <TextInput ... />
    <TextInput ... />
    <TextInput ... />
    <Button ... />
  </KeyboardAwareScrollView>
);

Listening to keyboard events directly:

jsx
useEffect(() => {
  const showSub = Keyboard.addListener('keyboardDidShow', (e) => {
    setKeyboardHeight(e.endCoordinates.height);
  });
  const hideSub = Keyboard.addListener('keyboardDidHide', () => {
    setKeyboardHeight(0);
  });

  return () => {
    showSub.remove();
    hideSub.remove();
  };
}, []);

Q18. What are custom hooks and how do you design them?

What the interviewer is testing: Whether you write reusable, composable logic or copy-paste code across components.

Answer:

A custom hook is a function that starts with use and calls other hooks. It extracts stateful logic out of components so it can be shared and tested independently.

Example — useFetch:

tsx
type FetchState<T> = {
  data: T | null;
  isLoading: boolean;
  error: string | null;
  refetch: () => void;
};

const useFetch = <T>(url: string): FetchState<T> => {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const fetch = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const json = await response.json();
      setData(json);
    } catch (e) {
      setError(e.message);
    } finally {
      setIsLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetch();
  }, [fetch]);

  return { data, isLoading, error, refetch: fetch };
};

// Usage — clean, no repeated logic
const UserScreen = ({ userId }) => {
  const { data: user, isLoading, error, refetch } = useFetch<User>(
    `/api/users/${userId}`
  );

  if (isLoading) return <ActivityIndicator />;
  if (error) return <ErrorView message={error} onRetry={refetch} />;
  return <UserProfile user={user} />;
};

Design principles for custom hooks:

  1. 1Single responsibility — one hook does one thing
  2. 2Name describes the value, not the mechanism — useCurrentUser, not useUserFetch
  3. 3Return an object, not a tuple, when returning 3+ values — named properties are clearer than positional
  4. 4Cleanup side effects — always return a cleanup function from useEffect

Q19. How does `useContext` work and what are its performance implications?

What the interviewer is testing: Practical context usage and awareness of re-render propagation.

Answer:

useContext subscribes a component to a React context. Every component that calls useContext(MyContext) re-renders when the context value changes — regardless of which part of the value changed.

Basic usage:

tsx
// 1. Create context
type ThemeContextType = {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// 2. Custom hook for safe access
const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
};

// 3. Provider
const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const value = useMemo(() => ({
    theme,
    toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
  }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
};

// 4. Consume
const Header = () => {
  const { theme, toggleTheme } = useTheme();
  return (
    <View style={{ backgroundColor: theme === 'dark' ? '#000' : '#fff' }}>
      <Button onPress={toggleTheme} title="Toggle" />
    </View>
  );
};

The performance problem:

If your context value is an object and you recreate it on every render, every consumer re-renders:

jsx
// BAD: new object reference on every render → all consumers re-render
const Provider = ({ children }) => (
  <AuthContext.Provider value={{ user, logout }}>
    {children}
  </AuthContext.Provider>
);

// GOOD: useMemo stabilizes the reference
const Provider = ({ children }) => {
  const value = useMemo(() => ({ user, logout }), [user]);
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};

Split contexts by update frequency:

Don't put fast-changing values (like scroll position) in the same context as slow-changing values (like user profile). Split them so components only subscribe to what they need.


Q20. What are the most common React Native animation APIs and when do you choose each?

What the interviewer is testing: Animation architecture knowledge — a common senior-level question.

Answer:

React Native has three main animation systems:

1. Animated API (built-in) — the baseline:

jsx
import { Animated } from 'react-native';

const FadeIn = ({ children }) => {
  const opacity = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    Animated.timing(opacity, {
      toValue: 1,
      duration: 300,
      useNativeDriver: true, // CRITICAL: runs on UI thread, not JS thread
    }).start();
  }, []);

  return (
    <Animated.View style={{ opacity }}>
      {children}
    </Animated.View>
  );
};

useNativeDriver: true is non-negotiable for performance:

Without it, every frame of the animation runs through the JS thread, through the bridge, to native — exactly 1 serialization per frame at 60fps. With useNativeDriver, the animation logic runs entirely on the UI thread after the first setup. Limitation: only transform and opacity support useNativeDriver: true.

2. Reanimated 2/3 — worklets on the UI thread:

jsx
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

const SpringButton = () => {
  const scale = useSharedValue(1);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));

  return (
    <Animated.View style={animatedStyle}>
      <Pressable
        onPressIn={() => { scale.value = withSpring(0.95); }}
        onPressOut={() => { scale.value = withSpring(1); }}
      >
        <Text>Press</Text>
      </Pressable>
    </Animated.View>
  );
};

Reanimated runs animation worklets on the UI thread via JSI — no bridge, no JS thread involvement during animation. This enables 60fps animations even when the JS thread is busy.

3. LayoutAnimation — simple transitions:

jsx
import { LayoutAnimation, UIManager, Platform } from 'react-native';

// Required for Android
if (Platform.OS === 'android') {
  UIManager.setLayoutAnimationEnabledExperimental?.(true);
}

const toggle = () => {
  LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
  setExpanded(e => !e); // layout change animates automatically
};

Decision matrix:

| Scenario | Use |

|---|---|

| Simple fade/slide | Animated with useNativeDriver: true |

| Gesture-driven animations | Reanimated |

| Complex choreography | Reanimated |

| Layout changes (expand/collapse) | LayoutAnimation |

| Lottie animations | lottie-react-native |


Section 3 — Navigation and Architecture

Q21. Compare React Navigation and Expo Router. How does each handle deep linking?

What the interviewer is testing: Architectural knowledge of the most important third-party library in React Native.

Answer:

React Navigation is the de-facto standard. You define your navigation structure imperatively in JavaScript:

tsx
// App.tsx
const Stack = createNativeStackNavigator();

const App = () => (
  <NavigationContainer>
    <Stack.Navigator initialRouteName="Home">
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="Profile" component={ProfileScreen} />
      <Stack.Screen
        name="Settings"
        component={SettingsScreen}
        options={{ headerShown: false }}
      />
    </Stack.Navigator>
  </NavigationContainer>
);

Expo Router uses a file-based routing system (similar to Next.js). The file structure defines the routes:

app/
  index.tsx        → /
  profile.tsx      → /profile
  (tabs)/
    home.tsx       → /home (tab)
    search.tsx     → /search (tab)
  user/
    [id].tsx       → /user/:id (dynamic)

Deep linking with React Navigation:

tsx
const linking = {
  prefixes: ['myapp://', 'https://myapp.com'],
  config: {
    screens: {
      Home: '',
      Profile: 'user/:id',
      Settings: 'settings',
    },
  },
};

<NavigationContainer linking={linking}>
  ...
</NavigationContainer>

Deep linking with Expo Router:

Deep linking works automatically — the file structure IS the URL structure. myapp://user/123 opens app/user/[id].tsx with params.id === '123'.

When to choose which:

  • React Navigation: More control, wider ecosystem support, works in bare React Native
  • Expo Router: Less boilerplate, automatic deep linking, great for Expo-managed projects

Q22. How do you pass data between screens in React Navigation?

What the interviewer is testing: Practical navigation knowledge including params, shared state, and the right approach for different data types.

Answer:

Route params — small, serializable data:

tsx
// Navigating with params
navigation.navigate('Profile', {
  userId: '123',
  fromScreen: 'Home',
});

// Receiving params
const ProfileScreen = ({ route }) => {
  const { userId, fromScreen } = route.params;
  // ...
};

// TypeScript: type your params
type RootStackParamList = {
  Home: undefined;
  Profile: { userId: string; fromScreen: string };
};

What NOT to pass as params:

tsx
// WRONG: non-serializable objects crash deep links and state persistence
navigation.navigate('Profile', {
  user: userObject,      // large object
  onSuccess: () => {},   // function reference
  ref: componentRef,     // ref
});

// RIGHT: pass IDs, fetch data in the screen
navigation.navigate('Profile', {
  userId: user.id,       // just the ID
});

Shared context for deeply nested components:

tsx
// Context is better than drilling params through 3 navigator levels
const CartContext = createContext<CartContextType>(null);

// Any screen can access the cart without passing it as a param
const CheckoutScreen = () => {
  const { items, total } = useCart();
};

Callback pattern for returning results:

tsx
// Screen A navigates to Screen B and wants a result back
navigation.navigate('ColorPicker', {
  onColorSelected: (color: string) => {
    setSelectedColor(color);
  },
});

// Screen B calls the callback
const ColorPicker = ({ route }) => {
  const { onColorSelected } = route.params;

  return (
    <FlatList
      data={colors}
      renderItem={({ item }) => (
        <Pressable onPress={() => {
          onColorSelected(item.hex);
          navigation.goBack();
        }}>
          <ColorSwatch color={item.hex} />
        </Pressable>
      )}
    />
  );
};

Q23. What is the difference between Stack, Tab, and Drawer navigators, and how do you nest them?

What the interviewer is testing: Whether you can design real app navigation hierarchies.

Answer:

Stack Navigator — push/pop navigation with a history stack. Each screen pushed onto the stack can go back.

Tab Navigator — persistent tabs, typically at the bottom. Switching tabs doesn't add to history.

Drawer Navigator — side menu that slides in, usually for settings/secondary navigation.

Nesting — the standard mobile app pattern:

Most production apps nest these: a Tab navigator with Stack navigators inside each tab.

tsx
// Outer: Bottom tabs
const Tab = createBottomTabNavigator();

// Inner: Stack inside each tab
const HomeStack = createNativeStackNavigator();
const SearchStack = createNativeStackNavigator();

const HomeStackNavigator = () => (
  <HomeStack.Navigator>
    <HomeStack.Screen name="Home" component={HomeScreen} />
    <HomeStack.Screen name="ProductDetail" component={ProductDetailScreen} />
  </HomeStack.Navigator>
);

const SearchStackNavigator = () => (
  <SearchStack.Navigator>
    <SearchStack.Screen name="Search" component={SearchScreen} />
    <SearchStack.Screen name="SearchResults" component={SearchResultsScreen} />
  </SearchStack.Navigator>
);

// Root: Tabs containing stacks
const RootNavigator = () => (
  <Tab.Navigator>
    <Tab.Screen name="HomeTab" component={HomeStackNavigator} />
    <Tab.Screen name="SearchTab" component={SearchStackNavigator} />
    <Tab.Screen name="Profile" component={ProfileScreen} />
  </Tab.Navigator>
);

Navigating across nesting levels:

tsx
// From inside HomeStack, navigate to a screen in SearchStack:
navigation.navigate('SearchTab', {
  screen: 'SearchResults',
  params: { query: 'react native' },
});

Q24. How do you manage global state in a React Native app? Compare Context, Redux, and Zustand.

What the interviewer is testing: Architecture decision-making, not just memorizing library APIs.

Answer:

React Context — built-in, best for low-frequency updates:

tsx
// Good for: theme, auth state, user preferences
// Avoid for: frequently-updating state (cart items, search results)

const AuthContext = createContext<AuthState | null>(null);

const AuthProvider = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

Redux Toolkit — enterprise, complex state:

tsx
// store/slices/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] as CartItem[] },
  reducers: {
    addItem: (state, action: PayloadAction<CartItem>) => {
      state.items.push(action.payload); // Immer handles immutability
    },
    removeItem: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter(i => i.id !== action.payload);
    },
  },
});

// Component
const Cart = () => {
  const items = useSelector((state: RootState) => state.cart.items);
  const dispatch = useDispatch();

  return (
    <FlatList
      data={items}
      renderItem={({ item }) => (
        <CartItem
          item={item}
          onRemove={() => dispatch(cartSlice.actions.removeItem(item.id))}
        />
      )}
    />
  );
};

Zustand — simple, minimal boilerplate:

tsx
// store/useCartStore.ts
import { create } from 'zustand';

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  total: () => number;
}

const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id)
  })),
  total: () => get().items.reduce((sum, item) => sum + item.price, 0),
}));

// Component — no Provider needed
const Cart = () => {
  const { items, removeItem, total } = useCartStore();
  // ...
};

Decision matrix:

| | Context | Redux Toolkit | Zustand |

|---|---|---|---|

| Boilerplate | Low | High | Minimal |

| DevTools | None | Excellent | Good |

| Performance | Re-render sensitive | Optimized | Optimized |

| Team size | Small | Large | Any |

| Use case | Auth, theme | Complex, shared | Medium complexity |


Q25. How do you implement offline support in React Native?

What the interviewer is testing: Architecture for real-world mobile apps where connectivity is unreliable.

Answer:

Offline support requires three layers: detecting connectivity, caching data, and queuing mutations.

1. Detect network state:

tsx
import NetInfo from '@react-native-community/netinfo';

const useNetworkStatus = () => {
  const [isConnected, setIsConnected] = useState<boolean | null>(null);

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      setIsConnected(state.isConnected);
    });
    return unsubscribe;
  }, []);

  return isConnected;
};

2. Cache reads with async storage or MMKV:

tsx
import AsyncStorage from '@react-native-async-storage/async-storage';

const cacheKey = (url: string) => `cache:${url}`;

const useCachedFetch = <T>(url: string) => {
  const [data, setData] = useState<T | null>(null);
  const isConnected = useNetworkStatus();

  useEffect(() => {
    const load = async () => {
      // Always show cached data first
      const cached = await AsyncStorage.getItem(cacheKey(url));
      if (cached) setData(JSON.parse(cached));

      // Update from network if online
      if (isConnected) {
        const fresh = await fetch(url).then(r => r.json());
        setData(fresh);
        await AsyncStorage.setItem(cacheKey(url), JSON.stringify(fresh));
      }
    };
    load();
  }, [url, isConnected]);

  return data;
};

3. Queue mutations for later:

tsx
// With React Query + offline support
import { useMutation, useQueryClient } from '@tanstack/react-query';

const useSendMessage = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.sendMessage,
    onMutate: async (newMessage) => {
      // Optimistic update — show message immediately
      await queryClient.cancelQueries(['messages']);
      const previous = queryClient.getQueryData(['messages']);
      queryClient.setQueryData(['messages'], (old: Message[]) => [
        ...old,
        { ...newMessage, id: Date.now(), status: 'sending' },
      ]);
      return { previous };
    },
    onError: (error, newMessage, context) => {
      // Rollback on failure
      queryClient.setQueryData(['messages'], context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries(['messages']);
    },
  });
};

Production recommendation:

Use React Query (TanStack Query) or SWR for server state caching. Use MMKV instead of AsyncStorage for faster reads/writes (MMKV is synchronous C++, AsyncStorage is async JS).


Section 4 — Performance

Q26. What causes "dropped frames" and how do you diagnose them?

What the interviewer is testing: Whether you can profile and fix real performance problems, not just recite theory.

Answer:

A dropped frame occurs when a frame takes longer than 16.67ms to render (for 60fps). On 120Hz screens, the budget is 8.3ms.

Root causes:

  1. 1JS thread overloaded: Heavy computation, large state updates, synchronous storage reads
  2. 2Bridge congestion: Too many native calls per frame (old architecture)
  3. 3Slow native operations on the UI thread: Complex shadow calculations, large image decoding
  4. 4Unnecessary re-renders: Components re-rendering when their data didn't change

Diagnosis tools:

bash
# 1. Flipper — React DevTools + Performance Monitor
# Enable in development build, shows JS/UI thread FPS in real time

# 2. React DevTools Profiler
# Records which components rendered, why, and for how long

# 3. Systrace (Android)
# chrome://tracing — shows native thread activity
adb shell atrace -a com.yourapp -t 10 -b 16384 \
  sched gfx view wm am dalvik > /tmp/trace.systrace

Common fixes:

jsx
// Problem: re-rendering an expensive list item when parent state changes
const ExpensiveItem = ({ item, onPress }) => {
  // This recalculates on every parent render
  const processed = heavyTransform(item);
  return <View>...</View>;
};

// Fix 1: React.memo to skip re-renders when props haven't changed
const ExpensiveItem = React.memo(({ item, onPress }) => {
  const processed = useMemo(() => heavyTransform(item), [item]);
  return <View>...</View>;
});

// Fix 2: Move heavy work off the JS thread with InteractionManager
useEffect(() => {
  InteractionManager.runAfterInteractions(() => {
    // Runs after animations complete, won't compete with transitions
    loadHeavyData();
  });
}, []);

The Performance Monitor (accessible in dev menu):

  • JS FPS: Should stay at 60. Drops indicate JS thread overload.
  • UI FPS: Should stay at 60. Drops indicate UI thread overload.
  • When JS FPS drops but UI FPS stays high: your business logic is too slow
  • When both drop: the JS thread is overloading the bridge

Q27. Explain `React.memo` and when it helps vs. when it adds overhead.

What the interviewer is testing: Nuanced understanding of memoization tradeoffs.

Answer:

React.memo is a higher-order component that wraps a component and skips re-rendering if the props haven't changed (shallow comparison).

When it helps:

jsx
// This component is expensive to render and receives stable props
const UserAvatar = React.memo(({ userId, size }) => {
  const user = useUser(userId); // fetches from cache
  return (
    <FastImage
      source={{ uri: user.avatarUrl }}
      style={{ width: size, height: size, borderRadius: size / 2 }}
    />
  );
});

// Parent re-renders frequently (e.g., scroll position updates)
// Without React.memo: UserAvatar re-renders on every scroll event
// With React.memo: UserAvatar only re-renders when userId or size changes

When it adds overhead without benefit:

jsx
// Memoizing a cheap component that always receives new props
const LoadingDot = React.memo(({ color }) => (
  <View style={{ width: 8, height: 8, backgroundColor: color }} />
));

// If color changes on every render anyway, memo's shallow comparison
// runs every render and never finds a match — pure overhead

The comparison function for complex props:

jsx
const FeedItem = React.memo(
  ({ post, onLike }) => <PostCard post={post} onLike={onLike} />,
  (prevProps, nextProps) => {
    // Custom equality: only re-render if id or like count changed
    return (
      prevProps.post.id === nextProps.post.id &&
      prevProps.post.likeCount === nextProps.post.likeCount
    );
    // Note: onLike reference change is ignored
    // Only works if onLike is stable (useCallback)
  }
);

Three-part rule for React.memo to be worth it:

  1. 1The component renders meaningfully often
  2. 2It re-renders with the same props most of the time
  3. 3The component has non-trivial render cost

If any of those is false, skip the memo.


Q28. How do you optimize images in React Native?

What the interviewer is testing: Real production knowledge — images are the most common performance problem in mobile apps.

Answer:

1. Use FastImage instead of the built-in Image component:

jsx
import FastImage from 'react-native-fast-image';

// FastImage uses SDWebImage (iOS) and Glide (Android)
// Built-in caching, better memory management, progressive loading
<FastImage
  source={{
    uri: 'https://cdn.example.com/photo.jpg',
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable,
  }}
  style={{ width: 200, height: 200 }}
  resizeMode={FastImage.resizeMode.cover}
/>

2. Always specify width and height:

Without explicit dimensions, React Native makes a network request to get image dimensions before layout — two round trips instead of one.

3. Use correctly-sized images:

jsx
// Don't load a 2000x2000 image for a 100x100 thumbnail
// Use image CDN transformations:
const getOptimizedUrl = (url: string, width: number) => {
  const pixelWidth = width * PixelRatio.get(); // account for screen density
  return `${url}?w=${Math.round(pixelWidth)}&q=80&f=webp`;
};

<FastImage source={{ uri: getOptimizedUrl(photo.url, 100) }} style={{ width: 100, height: 100 }} />

4. Lazy load images in lists:

jsx
const FeedCard = React.memo(({ item }) => {
  const [isVisible, setIsVisible] = useState(false);

  return (
    <View
      onLayout={() => {}} // triggers measurement
      // Use Intersection Observer pattern or FlatList's viewability callbacks
    >
      {isVisible ? (
        <FastImage source={{ uri: item.imageUrl }} style={styles.image} />
      ) : (
        <View style={[styles.image, styles.placeholder]} /> // skeleton
      )}
    </View>
  );
});

5. FlatList viewability callbacks for precise lazy loading:

jsx
const onViewableItemsChanged = useCallback(({ viewableItems }) => {
  const visibleIds = new Set(viewableItems.map(({ item }) => item.id));
  setVisibleIds(visibleIds);
}, []);

<FlatList
  data={items}
  onViewableItemsChanged={onViewableItemsChanged}
  viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>

Q29. What is InteractionManager and when should you use it?

What the interviewer is testing: Knowledge of how to defer heavy work until after animations complete.

Answer:

InteractionManager lets you defer work until all active animations and transitions have completed. It prevents heavy computation from competing with animations for the JS thread.

The problem it solves:

jsx
// Navigation push triggers an animation
// Without InteractionManager, this runs during the transition → jank
const ProductDetailScreen = ({ route }) => {
  const { productId } = route.params;

  useEffect(() => {
    // This runs immediately, competing with the push animation
    fetchRelatedProducts(productId);
    loadProductReviews(productId);
    initializeAnalytics(productId);
  }, [productId]);
};

With InteractionManager:

jsx
const ProductDetailScreen = ({ route }) => {
  const { productId } = route.params;
  const [secondaryDataLoaded, setSecondaryDataLoaded] = useState(false);

  useEffect(() => {
    // Primary data: load immediately (above the fold)
    fetchProductDetails(productId);

    // Secondary data: wait for the transition animation to finish
    const task = InteractionManager.runAfterInteractions(async () => {
      await Promise.all([
        fetchRelatedProducts(productId),
        loadProductReviews(productId),
      ]);
      setSecondaryDataLoaded(true);
    });

    return () => task.cancel();
  }, [productId]);
};

Custom interaction registration:

jsx
// Tell InteractionManager about your custom animations
const handle = InteractionManager.createInteractionHandle();

Animated.timing(animation, { toValue: 1, duration: 300 }).start(() => {
  InteractionManager.clearInteractionHandle(handle);
  // Now deferred work can run
});

Q30. How do you prevent memory leaks in React Native?

What the interviewer is testing: Whether you understand cleanup, subscriptions, and async operation lifecycles.

Answer:

Memory leaks in React Native occur when components are unmounted but still hold references to memory — typically from subscriptions, timers, or unresolved async operations that call setState on unmounted components.

Common leak patterns and fixes:

1. Forgotten subscriptions:

jsx
// LEAK: subscription never removed
useEffect(() => {
  const subscription = DeviceEventEmitter.addListener('appStateChange', handler);
  // Missing cleanup!
}, []);

// FIX: return cleanup function
useEffect(() => {
  const subscription = DeviceEventEmitter.addListener('appStateChange', handler);
  return () => subscription.remove();
}, []);

2. setState after unmount:

jsx
// LEAK: setState called on unmounted component
const DataScreen = () => {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(result => {
      setData(result); // may run after component unmounts
    });
  }, []);
};

// FIX: check if still mounted, or use AbortController
useEffect(() => {
  const controller = new AbortController();

  fetchData({ signal: controller.signal })
    .then(result => setData(result))
    .catch(e => {
      if (e.name === 'AbortError') return; // ignore cancelled requests
    });

  return () => controller.abort();
}, []);

3. Timer cleanup:

jsx
// LEAK: timer fires after unmount
useEffect(() => {
  const timer = setInterval(pollForUpdates, 5000);
  // Missing cleanup!
}, []);

// FIX:
useEffect(() => {
  const timer = setInterval(pollForUpdates, 5000);
  return () => clearInterval(timer);
}, []);

4. Animated values:

jsx
useEffect(() => {
  const animation = Animated.loop(
    Animated.timing(spinValue, { toValue: 1, duration: 1000, useNativeDriver: true })
  );
  animation.start();
  return () => animation.stop();
}, []);

Q31. What is `removeClippedSubviews` and `getItemLayout` in FlatList?

What the interviewer is testing: Deep FlatList optimization knowledge.

Answer:

removeClippedSubviews:

When true, React Native detaches off-screen subviews from the native view hierarchy without unmounting them in React. They still exist in memory but are not rendered by the GPU.

  • Reduces memory pressure and rendering cost
  • Android default: true; iOS default: false
  • Can cause visual glitches if your items have complex state — test carefully
jsx
<FlatList
  data={thousandsOfItems}
  removeClippedSubviews={Platform.OS === 'android'}
  renderItem={renderItem}
/>

getItemLayout:

Normally, FlatList measures each item's height dynamically as it comes into view. getItemLayout skips this measurement by telling FlatList the exact height in advance.

Benefits:

  • Enables scrollToIndex and scrollToOffset without rendering intermediate items
  • Faster initial layout calculation
  • Required for accurate scroll restoration
jsx
const ITEM_HEIGHT = 80;
const SEPARATOR_HEIGHT = 1;

<FlatList
  data={contacts}
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,
    offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index,
    index,
  })}
  ItemSeparatorComponent={() => <View style={{ height: SEPARATOR_HEIGHT }} />}
  renderItem={({ item }) => <ContactRow contact={item} style={{ height: ITEM_HEIGHT }} />}
/>

When getItemLayout is not possible:

If items have variable heights, you can't use getItemLayout. Use onLayout callbacks to measure dynamically:

jsx
const [itemLayouts, setItemLayouts] = useState<{ [key: string]: { height: number; offset: number } }>({});

const onItemLayout = (id: string, event: LayoutChangeEvent) => {
  const { height } = event.nativeEvent.layout;
  setItemLayouts(prev => ({ ...prev, [id]: { height, offset: computeOffset(id) } }));
};

Q32. How do you handle large lists with complex items without dropping frames?

What the interviewer is testing: Ability to design high-performance lists, a real pain point in production apps.

Answer:

Multiple techniques compound to get smooth performance on complex list items.

1. React.memo on the item component:

jsx
const FeedItem = React.memo(
  ({ post, onLike, onComment }) => {
    // expensive render
  },
  (prev, next) =>
    prev.post.id === next.post.id &&
    prev.post.likeCount === next.post.likeCount
);

2. Stable callback references with useCallback:

jsx
const handleLike = useCallback((postId: string) => {
  dispatch(likePost(postId));
}, [dispatch]);

// Pass to FlatList — same reference means FeedItem won't re-render due to onLike
<FlatList
  renderItem={({ item }) => (
    <FeedItem post={item} onLike={handleLike} />
  )}
/>

3. Tune the windowing parameters:

jsx
<FlatList
  initialNumToRender={5}      // only render 5 items before first paint
  maxToRenderPerBatch={3}     // add 3 items per render batch (less jank)
  updateCellsBatchingPeriod={50} // ms between batches
  windowSize={7}              // 7 screen heights of rendered content
/>

4. Avoid anonymous functions in renderItem:

jsx
// BAD: new function reference per render → FeedItem always re-renders
<FlatList
  renderItem={({ item }) => <FeedItem post={item} onLike={() => handleLike(item.id)} />}
/>

// GOOD: pass the item ID and let the item component build the handler
<FlatList
  renderItem={({ item }) => <FeedItem post={item} onLike={handleLike} />}
/>

// In FeedItem:
const FeedItem = React.memo(({ post, onLike }) => {
  const handlePress = useCallback(() => onLike(post.id), [post.id, onLike]);
  return <Pressable onPress={handlePress}>...</Pressable>;
});

5. Use FlashList for best-in-class performance:

FlashList from Shopify is a drop-in FlatList replacement that recycles native view instances instead of creating new ones, similar to RecyclerView on Android:

jsx
import { FlashList } from '@shopify/flash-list';

<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={80} // instead of getItemLayout
  keyExtractor={item => item.id}
/>

Section 5 — Native Modules and Platform APIs

Q33. What is a native module and when do you need to write one?

What the interviewer is testing: Awareness of the bridge between JS and native, and practical judgment about when to write vs. use existing libraries.

Answer:

A native module exposes platform-specific functionality to JavaScript that the React Native framework doesn't cover out of the box. You write a native module when:

  • The platform API doesn't have a React Native wrapper (Bluetooth, NFC, proprietary SDKs)
  • Existing third-party libraries have critical bugs or are abandoned
  • You need to perform synchronous native operations that can't go through the async bridge
  • You're integrating a hardware SDK provided as a .aar (Android) or .xcframework (iOS)

Simple native module example (Android):

java
// ToastModule.java
public class ToastModule extends ReactContextBaseJavaModule {
  @Override
  public String getName() {
    return "ToastModule";
  }

  @ReactMethod
  public void show(String message, int duration) {
    Toast.makeText(
      getReactApplicationContext(),
      message,
      duration == 0 ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG
    ).show();
  }
}
objc
// ToastModule.m (iOS)
@implementation ToastModule

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(show:(NSString *)message
                  duration:(NSInteger)duration) {
  // iOS doesn't have Toast, so you'd show a custom overlay
}

@end

Using the native module from JavaScript:

tsx
import { NativeModules } from 'react-native';

const { ToastModule } = NativeModules;

ToastModule.show('Hello from native', 0);

The modern approach — TurboModules with Codegen:

tsx
// NativeToastModule.ts — the spec file
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  show(message: string, duration: number): void;
}

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

Codegen reads this TypeScript spec and generates the C++ glue code automatically — no hand-written bridge code.


Q34. How do you use AsyncStorage and MMKV, and what are the tradeoffs?

What the interviewer is testing: Knowledge of persistent storage options and when each is appropriate.

Answer:

AsyncStorage — the baseline:

tsx
import AsyncStorage from '@react-native-async-storage/async-storage';

// Write
await AsyncStorage.setItem('@user:token', JSON.stringify({ token, expiresAt }));

// Read
const raw = await AsyncStorage.getItem('@user:token');
const data = raw ? JSON.parse(raw) : null;

// Delete
await AsyncStorage.removeItem('@user:token');

// Multiple operations in one round trip
await AsyncStorage.multiSet([
  ['@prefs:theme', 'dark'],
  ['@prefs:lang', 'en'],
]);

MMKV — the performance upgrade:

tsx
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

// Synchronous — no await needed
storage.set('user.token', token);
const token = storage.getString('user.token');
storage.set('user.notificationsEnabled', true);
const enabled = storage.getBoolean('user.notificationsEnabled');

Comparison:

| | AsyncStorage | MMKV |

|---|---|---|

| Read speed | ~1ms (async) | ~0.01ms (sync) |

| API | Async/Promise | Synchronous |

| Encryption | No | Yes (AES-256) |

| Max size | Unlimited (SQLite) | Unlimited (mmap) |

| Listeners | No | Yes (onChange) |

When to use each:

  • AsyncStorage: Simple use cases, teams not wanting native dependencies, storing large JSON blobs
  • MMKV: High-frequency reads (theme, flags, session data), encryption required, need synchronous reads in initialization code

For structured data, use WatermelonDB or SQLite:

tsx
// WatermelonDB — local-first database with sync
import { Database, Model, field } from '@nozbe/watermelondb';

class Post extends Model {
  static table = 'posts';
  @field('title') title: string;
  @field('body') body: string;
  @field('created_at') createdAt: number;
}

Q35. How do you handle push notifications in React Native?

What the interviewer is testing: Practical knowledge of a complex API with many edge cases.

Answer:

Push notifications require platform setup (APNs for iOS, FCM for Android) and a library to handle the JS side.

Setup with @notifee/react-native and Firebase:

tsx
// 1. Request permissions (iOS only — Android grants by default)
import notifee, { AuthorizationStatus } from '@notifee/react-native';

const requestPermissions = async () => {
  const settings = await notifee.requestPermission();
  return settings.authorizationStatus === AuthorizationStatus.AUTHORIZED;
};

// 2. Get the FCM token
import messaging from '@react-native-firebase/messaging';

const getFCMToken = async () => {
  const token = await messaging().getToken();
  await api.saveDeviceToken(token); // send to your backend
  return token;
};

// 3. Handle foreground messages
useEffect(() => {
  const unsubscribe = messaging().onMessage(async (remoteMessage) => {
    await notifee.displayNotification({
      title: remoteMessage.notification?.title,
      body: remoteMessage.notification?.body,
      android: {
        channelId: 'default',
        pressAction: { id: 'default' },
      },
    });
  });

  return unsubscribe;
}, []);

// 4. Handle notification taps (background/quit)
useEffect(() => {
  const unsubscribe = messaging().onNotificationOpenedApp((remoteMessage) => {
    navigation.navigate(remoteMessage.data?.screen as string);
  });

  // Check if the app was opened by a notification from quit state
  messaging()
    .getInitialNotification()
    .then((remoteMessage) => {
      if (remoteMessage) {
        navigation.navigate(remoteMessage.data?.screen as string);
      }
    });

  return unsubscribe;
}, []);

// 5. Handle token refresh
useEffect(() => {
  const unsubscribe = messaging().onTokenRefresh(async (newToken) => {
    await api.saveDeviceToken(newToken);
  });
  return unsubscribe;
}, []);

Local notifications (no server needed):

tsx
// Schedule a local notification
await notifee.createChannel({ id: 'reminders', name: 'Reminders' });

await notifee.createTriggerNotification(
  {
    title: 'Interview tomorrow',
    body: 'Your interview with Acme Corp is in 24 hours. Practice your answers.',
    android: { channelId: 'reminders' },
  },
  {
    type: TriggerType.TIMESTAMP,
    timestamp: interviewDate.getTime() - 24 * 60 * 60 * 1000,
  }
);

Q36. How does Expo differ from bare React Native, and what are the tradeoffs?

What the interviewer is testing: Practical understanding of the ecosystem, not just one path.

Answer:

Expo provides a managed workflow, pre-built native dependencies, and over-the-air (OTA) updates through EAS. You write JavaScript/TypeScript; Expo handles the native layer.

Bare React Native gives you full control over the iOS and Android projects. You manage android/ and ios/ directories directly.

Expo Managed Workflow:

bash
# Create a new Expo app
npx create-expo-app MyApp

# No native directories — Expo handles them
MyApp/
  app.json
  App.tsx
  package.json

Expo Go for development:

Scan a QR code with the Expo Go app — no Xcode or Android Studio needed to run on a device during development.

EAS Build for production:

bash
# Build for app stores in the cloud — no local Xcode/Android Studio required
eas build --platform all

Tradeoffs:

| | Expo Managed | Bare React Native |

|---|---|---|

| Setup time | Minutes | Hours |

| Native code access | Through plugins only | Full access |

| OTA updates | Built-in (EAS Update) | Requires CodePush |

| App size | Larger (full Expo SDK) | Smaller |

| Native libraries | Expo ecosystem first | Any library |

| CI/CD | EAS Build (cloud) | Manual setup |

When to choose Expo:

  • Startup prototyping, small teams
  • Apps that don't need highly customized native code
  • Teams without native iOS/Android engineers

When to choose bare React Native:

  • Existing native codebase integration
  • Custom native modules required immediately
  • Deep control over build configuration

Section 6 — System Design and Behavioral

Q37. Design a real-time chat feature in React Native. Walk through your architecture.

What the interviewer is testing: System design ability, trade-off reasoning, and familiarity with real-time patterns.

Answer (structured for a 15-minute interview discussion):

Requirements to clarify first:

  • Message delivery guarantee (at-least-once, at-most-once, exactly-once)?
  • Offline support required?
  • Media messages (images, voice)?
  • How many concurrent users per chat?

Architecture:

Client (React Native)
  ↕ WebSocket (real-time)
  ↕ REST (initial load, media upload)
Server
  ↕ Redis Pub/Sub (fan-out to multiple servers)
  ↕ PostgreSQL (persistence)
  ↕ S3 (media)

State management on the client:

tsx
// Messages are server state → React Query + WebSocket sync
const useChatMessages = (conversationId: string) => {
  const queryClient = useQueryClient();

  // Initial load
  const { data: messages } = useQuery({
    queryKey: ['messages', conversationId],
    queryFn: () => api.getMessages(conversationId, { limit: 50 }),
  });

  // WebSocket sync
  useEffect(() => {
    const ws = new WebSocket(`wss://api.app.com/chat/${conversationId}`);

    ws.onmessage = (event) => {
      const message: Message = JSON.parse(event.data);
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: Message[] = []) => [...old, message]
      );
    };

    return () => ws.close();
  }, [conversationId, queryClient]);

  return messages ?? [];
};

Optimistic sends:

tsx
const useSendMessage = (conversationId: string) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.sendMessage,
    onMutate: async (newMessage) => {
      const tempId = `temp-${Date.now()}`;
      const optimistic = { ...newMessage, id: tempId, status: 'sending' };

      queryClient.setQueryData(
        ['messages', conversationId],
        (old: Message[]) => [...old, optimistic]
      );

      return { tempId };
    },
    onSuccess: (serverMessage, _, context) => {
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: Message[]) =>
          old.map(m => m.id === context?.tempId ? serverMessage : m)
      );
    },
    onError: (_, __, context) => {
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: Message[]) =>
          old.map(m =>
            m.id === context?.tempId ? { ...m, status: 'failed' } : m
          )
      );
    },
  });
};

List rendering for messages:

jsx
<FlatList
  data={messages}
  inverted // renders newest at bottom
  keyExtractor={m => m.id}
  renderItem={({ item }) => <MessageBubble message={item} />}
  // Don't use getItemLayout — messages have variable heights
  maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
/>

Q38. How would you implement an infinite scroll feed with pagination?

What the interviewer is testing: Practical implementation of a universal pattern, and API design awareness.

Answer:

tsx
import { useInfiniteQuery } from '@tanstack/react-query';

type Post = { id: string; content: string; author: string };
type PageData = { posts: Post[]; nextCursor: string | null };

const useFeed = () => {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam = null }) =>
      api.getFeed({ cursor: pageParam, limit: 20 }),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
};

const FeedScreen = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    refetch,
  } = useFeed();

  const posts = useMemo(
    () => data?.pages.flatMap(page => page.posts) ?? [],
    [data]
  );

  const renderFooter = () => {
    if (!isFetchingNextPage) return null;
    return <ActivityIndicator style={{ padding: 20 }} />;
  };

  const onEndReached = useCallback(() => {
    if (hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLoading) return <FeedSkeleton />;
  if (isError) return <ErrorView onRetry={refetch} />;

  return (
    <FlatList
      data={posts}
      keyExtractor={post => post.id}
      renderItem={({ item }) => <PostCard post={item} />}
      onEndReached={onEndReached}
      onEndReachedThreshold={0.5} // trigger when 50% from the bottom
      ListFooterComponent={renderFooter}
      refreshControl={
        <RefreshControl refreshing={isLoading} onRefresh={refetch} />
      }
    />
  );
};

Why cursor-based pagination over page numbers:

Page numbers break when items are inserted or deleted between requests. A cursor (typically the last item's ID or timestamp) always points to the correct position in the list.


Q39. How do you write tests for React Native components?

What the interviewer is testing: Whether you can write meaningful tests, not just check coverage boxes.

Answer:

The testing stack for React Native: Jest (test runner) + React Native Testing Library (component testing) + MSW (mock service worker for API mocks).

Unit test — pure functions first:

tsx
// Easy to test: no UI, no hooks
const formatDuration = (seconds: number): string => {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${mins}:${secs.toString().padStart(2, '0')}`;
};

test('formats duration correctly', () => {
  expect(formatDuration(90)).toBe('1:30');
  expect(formatDuration(3600)).toBe('60:00');
  expect(formatDuration(9)).toBe('0:09');
});

Component test — RNTL:

tsx
import { render, fireEvent, screen } from '@testing-library/react-native';

const LoginForm = ({ onSubmit }: { onSubmit: (email: string, password: string) => void }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  return (
    <View>
      <TextInput
        testID="email-input"
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
      />
      <TextInput
        testID="password-input"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <Pressable testID="submit-button" onPress={() => onSubmit(email, password)}>
        <Text>Log In</Text>
      </Pressable>
    </View>
  );
};

test('calls onSubmit with email and password', () => {
  const onSubmit = jest.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  fireEvent.changeText(screen.getByTestId('email-input'), 'user@test.com');
  fireEvent.changeText(screen.getByTestId('password-input'), 'secret123');
  fireEvent.press(screen.getByTestId('submit-button'));

  expect(onSubmit).toHaveBeenCalledWith('user@test.com', 'secret123');
  expect(onSubmit).toHaveBeenCalledTimes(1);
});

Async test — with API mock:

tsx
import { setupServer } from 'msw/node';
import { rest } from 'msw';

const server = setupServer(
  rest.get('/api/user/:id', (req, res, ctx) => {
    return res(ctx.json({ id: req.params.id, name: 'Ana García' }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('shows user name after loading', async () => {
  render(<UserProfile userId="123" />);

  expect(screen.getByTestId('loading')).toBeTruthy();

  await screen.findByText('Ana García'); // waits for async render

  expect(screen.queryByTestId('loading')).toBeNull();
});

What to test vs. skip:

Test: user-facing behavior, state transitions, edge cases (empty state, error state, loading state).

Skip: implementation details (which internal function was called, exact CSS values), library internals.


Q40. Explain how you would handle authentication and session persistence.

What the interviewer is testing: Security awareness and understanding of the full auth lifecycle.

Answer:

Store tokens in Keychain/Keystore, not AsyncStorage:

AsyncStorage is unencrypted. Anyone with physical access to the device or a rooted Android can read it.

tsx
import * as Keychain from 'react-native-keychain';

// Store tokens securely
const saveTokens = async (accessToken: string, refreshToken: string) => {
  await Keychain.setGenericPassword(
    'tokens',
    JSON.stringify({ accessToken, refreshToken }),
    {
      service: 'com.myapp.auth',
      accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
    }
  );
};

// Read tokens
const getTokens = async () => {
  const credentials = await Keychain.getGenericPassword({ service: 'com.myapp.auth' });
  if (!credentials) return null;
  return JSON.parse(credentials.password);
};

// Clear on logout
const clearTokens = async () => {
  await Keychain.resetGenericPassword({ service: 'com.myapp.auth' });
};

Auto-refresh with axios interceptor:

tsx
let isRefreshing = false;
let failedQueue: Array<{ resolve: Function; reject: Function }> = [];

api.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Queue the request until refresh completes
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then(token => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return api(originalRequest);
          });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const { refreshToken } = await getTokens();
        const { data } = await api.post('/auth/refresh', { refreshToken });

        await saveTokens(data.accessToken, data.refreshToken);
        api.defaults.headers.Authorization = `Bearer ${data.accessToken}`;

        // Retry queued requests
        failedQueue.forEach(({ resolve }) => resolve(data.accessToken));
        failedQueue = [];

        return api(originalRequest);
      } catch (refreshError) {
        failedQueue.forEach(({ reject }) => reject(refreshError));
        failedQueue = [];
        await clearTokens();
        authStore.logout(); // redirect to login
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Q41. What questions do you ask when you receive a React Native bug report?

What the interviewer is testing: Your debugging process and professional communication — a soft-skills/process question.

Answer:

This question tests whether you debug systematically or randomly.

My standard sequence:

  1. 1Reproduce first, always. "Can you share a screen recording? Which device model and OS version?"
  1. 2Scope to platform. "Does this happen on iOS, Android, or both?" — platform-specific bugs point directly to the native layer.
  1. 3Scope to environment. "Does it happen in dev build, production build, or both?" — many issues only appear in production (Hermes bytecode, minification, ProGuard rules).
  1. 4Get the logs. Metro logs, device logs via adb logcat (Android) or Xcode console (iOS), or Sentry/Crashlytics crash reports.
  1. 5Check recent changes. git log --since="3 days ago" — most bugs appear near recent changes.
  1. 6Reproduce in isolation. Create the minimal reproducer. If I can't reproduce it in a fresh component, the bug is in state or environment, not the component itself.

Common React Native bug patterns:

  • Crash on Android only, not iOS → often a ProGuard/R8 rule stripping a class
  • Works in development, crashes in production → Hermes bytecode issue or missing debug flags
  • Works on one device, crashes on another → screen density or memory issue
  • Animation jank → JS thread blocking, check with Systrace
  • "Bridge is not set" or null ref crashes → lifecycle timing issue, component mounted before native module ready

Q42. How do you approach code review for React Native PRs?

What the interviewer is testing: Whether you review for correctness, maintainability, and performance — not just style.

Answer:

My review focuses on these layers, in roughly this order of importance:

1. Correctness

  • Does the logic handle edge cases? (empty arrays, null values, network errors)
  • Are there race conditions in async code?
  • Are subscriptions cleaned up in useEffect returns?

2. Performance

  • Are expensive operations inside useMemo/useCallback?
  • Are list items wrapped in React.memo?
  • Are there useNativeDriver: true on all applicable animations?

3. Security

  • Is sensitive data going to AsyncStorage instead of Keychain?
  • Are API keys in code instead of environment variables?
  • Is user input being sanitized before display?

4. Consistency

  • Does it follow the existing patterns in the codebase?
  • Are types explicit (no implicit any)?
  • Is error handling consistent with how other errors are handled?

5. Accessibility

  • Do touchable elements have accessibilityLabel?
  • Are color contrasts sufficient?
  • Does the UI work with font size set to "Large" in accessibility settings?

How I give feedback:

I distinguish blockers from suggestions. "This will crash when the array is empty" is a blocker. "I'd prefer useMemo here for clarity" is a suggestion. Mixing them makes PRs feel hostile and slows teams down.


Q43. What is the difference between `expo-updates` (OTA) and a full app store release?

What the interviewer is testing: Understanding of release channels, and the legal/safety constraints on OTA updates.

Answer:

App store release — ships a new binary (.apk, .aab, or .ipa). Required for any change to:

  • Native code (new native modules, updated native dependencies)
  • App permissions
  • SDK version upgrades
  • App icons, splash screens, or app metadata

OTA update (Expo EAS Update or CodePush) — ships a new JavaScript bundle over the air. The app downloads and applies the update without going through the app store. Allowed for:

  • Bug fixes in JavaScript logic
  • Copy changes, color tweaks, layout adjustments
  • New features that only use existing native APIs

The rule Apple and Google both enforce:

OTA updates cannot change the fundamental purpose of the app, add adult content not disclosed at review, or circumvent the payment system. Violating this is grounds for app removal.

bash
# Publish an OTA update with EAS Update
eas update --branch production --message "Fix login validation bug"

# Your app.json configures which branch to pull from
{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/your-project-id",
      "fallbackToCacheTimeout": 0,
      "checkAutomatically": "ON_LOAD"
    },
    "runtimeVersion": {
      "policy": "sdkVersion"
    }
  }
}

Runtime version pinning is critical:

OTA updates can only be applied to apps with a matching runtime version. If you ship native code changes in a new app store release, you bump the runtime version — old app versions won't receive the new bundle (which might crash without the new native code).


Q44. How would you migrate an existing app from the old architecture to the new architecture?

What the interviewer is testing: Awareness of the migration path and practical risk management.

Answer:

The new architecture (Fabric + TurboModules + JSI) is opt-in as of React Native 0.73+ and enabled by default in 0.76+.

Migration steps:

1. Audit your native dependencies:

bash
# Check which libraries are new-architecture compatible
npx react-native-new-architecture-check

# Or review manually: each library needs to either:
# a) Support TurboModules/Fabric natively
# b) Work in interop mode (the compatibility layer)

2. Enable the new architecture:

java
// android/gradle.properties
newArchEnabled=true
ruby
# ios/Podfile
ENV['RCT_NEW_ARCH_ENABLED'] = '1'

3. Test the interop layer:

Most libraries work through the backward-compatibility interop layer without changes. Libraries using direct bridge calls or raw RCTEventEmitter may need updates.

4. Migrate your own native modules:

tsx
// Before: NativeModules
import { NativeModules } from 'react-native';
const { MyModule } = NativeModules;

// After: TurboModuleRegistry with TypeScript spec
import NativeMyModule from './NativeMyModule';
// NativeMyModule.ts defines the Spec interface, Codegen does the rest

Risk management:

  • Enable on a feature branch, run your test suite
  • Test on low-end devices (the new architecture may expose timing differences)
  • Keep a rollback plan (disable the flag, re-submit to the app store) until you've monitored production for one release cycle

Q45. Walk me through how you'd debug a crash that only happens in production.

What the interviewer is testing: Real-world debugging skills, use of crash reporting tools, and systematic thinking.

Answer:

Production-only crashes are the hardest category. My process:

Step 1 — Get a symbolicated stack trace:

Unsymbolicated traces show memory addresses, not function names. Tools:

bash
# Sentry / Crashlytics: upload source maps at build time
# Sentry example:
npx sentry-cli react-native appcenter \
  --platform android \
  --bundle-id com.myapp \
  --release-name 1.2.3 \
  android/app/src/main/assets/index.android.bundle

# Manual symbolication with metro-symbolicate
cat crash.log | npx metro-symbolicate ./main.jsbundle

Step 2 — Reproduce the conditions:

Production builds differ from dev builds:

bash
# Build in release mode locally
cd android && ./gradlew assembleRelease
react-native run-android --variant=release

# Enable production JS (no dev mode)
npx react-native bundle --platform android --dev false ...

Step 3 — Common production-only causes:

| Symptom | Likely cause |

|---|---|

| undefined is not an object on app open | Race condition: component mounts before data loads; works in dev because dev is slower |

| Cannot read property X of null | Prop type error hidden by dev warnings, crashes in prod |

| Crash only on first launch | Splash screen → app transition before JS bundle loads |

| Crash on specific device model | ProGuard rule stripping required class; test with --no-shrink |

| Crash after OTA update | Runtime version mismatch: new bundle on old native code |

Step 4 — Add logging to narrow it down:

tsx
// Sentry breadcrumbs for tracking user path before crash
import * as Sentry from '@sentry/react-native';

const fetchProduct = async (id: string) => {
  Sentry.addBreadcrumb({
    category: 'product',
    message: `Fetching product ${id}`,
    level: 'info',
  });

  try {
    const product = await api.getProduct(id);
    return product;
  } catch (e) {
    Sentry.captureException(e, {
      extra: { productId: id },
    });
    throw e;
  }
};

Step 5 — Fix and verify:

After the fix, monitor Sentry for 48 hours post-deploy. Production crash rates should drop to zero for that specific crash signature.


Quick Reference: Questions Interviewers Ask Most

Core architecture (always asked):

  • Q1: React Native vs WebView
  • Q2: Old bridge vs JSI
  • Q3: The three threads
  • Q16: FlatList vs ScrollView

Performance (senior-level signal):

  • Q26: Diagnosing dropped frames
  • Q27: React.memo tradeoffs
  • Q20: Animation systems
  • Q32: Complex list optimization

State and hooks (tested at every level):

  • Q11: useState vs useReducer
  • Q12: useCallback
  • Q18: Custom hooks
  • Q19: useContext performance

Practical knowledge (shows real shipping experience):

  • Q17: Keyboard avoidance
  • Q30: Memory leaks
  • Q35: Push notifications
  • Q40: Authentication

How to Practice These Answers

Reading this article is not enough. The gap between "I understand this" and "I can explain this fluently under pressure" requires retrieval practice.

For each section:

  1. 1Close the article
  2. 2Say the answer out loud (not in your head)
  3. 3Open the article and fill in what you missed
  4. 4Repeat until you can explain it without looking

The code examples are as important as the explanations. Be ready to write them from memory on a whiteboard or shared editor — interviewers ask follow-ups that require you to modify the code you just wrote.

Good luck.

FAQ

What is the difference between React Native and a WebView-based approach like Cordova?+

React Native compiles your component tree into real native UI elements — actual UIViews on iOS and android.view.Views on Android. Cordova wraps a WebView that renders HTML/CSS. The practical result is that React Native apps look and feel native because they ARE native at the UI layer, while Cordova apps feel like websites because they are websites.

What is the React Native bridge and why is JSI better?+

The old bridge is an asynchronous message-passing layer that serializes all JS-to-native calls to JSON — a bottleneck for high-frequency operations like animations and gestures. JSI (JavaScript Interface) replaces it with a C++ layer that JavaScript can call synchronously and directly, without serialization. This enables TurboModules (lazy-loaded native modules) and Fabric (the new renderer), eliminating frame drops caused by bridge congestion.

What are the three main threads in React Native?+

1) The JavaScript Thread runs your app logic, state updates, and business logic. 2) The Main/UI Thread handles native UI rendering and user input — it must never be blocked (16ms budget at 60fps). 3) The Shadow/Layout Thread runs Yoga (Facebook's flexbox engine) to compute pixel positions. Heavy computation on the JS thread causes dropped frames; heavy operations on the UI thread freeze the app.

When should you use React.memo, useCallback, and useMemo?+

React.memo skips a component re-render when its props haven't changed (shallow comparison). useCallback returns a stable function reference across renders so memoized children don't re-render due to new function references. useMemo memoizes an expensive computed value. The key insight: React.memo only helps when its child receives stable props, so useCallback and useMemo are often used together with React.memo. Profile before optimizing — all three add overhead when misapplied.

Why is FlatList faster than ScrollView for large lists?+

ScrollView renders all children immediately, so a list of 500 items creates 500 native views in memory. FlatList uses windowing (virtualization) — it only renders items visible on screen plus a small buffer, and recycles views as you scroll. Key optimization props: getItemLayout (skips dynamic measurement for fixed-height items), initialNumToRender, maxToRenderPerBatch, and removeClippedSubviews. For best performance, use FlashList from Shopify, which recycles native view instances like Android's RecyclerView.

How do you store authentication tokens securely in React Native?+

Never use AsyncStorage for tokens — it is unencrypted and readable on rooted devices. Use react-native-keychain, which writes to iOS Keychain and Android Keystore — both are hardware-backed encrypted storage. Pair this with an axios interceptor that auto-refreshes the access token on 401 responses, uses a queue to prevent concurrent refresh calls, and calls logout/clear on refresh failure.

What causes memory leaks in React Native and how do you prevent them?+

The three most common causes: 1) Subscriptions (DeviceEventEmitter, AppState, NetInfo) added in useEffect without a cleanup function returning subscription.remove(). 2) setState called after component unmount — fix with AbortController on fetch calls. 3) Timers (setInterval, setTimeout) not cleared in useEffect cleanup. The rule: every useEffect that sets up a subscription, timer, or async operation should return a cleanup function.

What is the difference between the old architecture and the new architecture (Fabric + TurboModules)?+

Old: JS communicates with native via an async JSON bridge — high serialization cost, no direct return values, frame drops during animation. New: JSI provides direct C++ references from JS to native objects — synchronous calls, no serialization, animations run entirely on the UI thread. TurboModules are lazy-loaded via JSI instead of eagerly initialized. Fabric is the new renderer that supports concurrent features. Codegen generates C++ glue from TypeScript specs, eliminating hand-written bridge code.

How do you handle offline support in React Native?+

Three layers: 1) Detect connectivity with NetInfo (@react-native-community/netinfo). 2) Cache reads — show cached data immediately, then update from network when online. Use MMKV for fast key-value cache, SQLite/WatermelonDB for structured data. 3) Queue mutations — use React Query's optimistic updates to show changes instantly, then sync when connectivity returns. On failure, roll back the optimistic update.

How would you debug a crash that only happens in production?+

Step 1: Get a symbolicated stack trace via Sentry or Crashlytics (source maps must be uploaded at build time). Step 2: Reproduce in a local release build (not dev) — many production bugs are caused by Hermes bytecode, ProGuard stripping, or minification. Step 3: Common causes: race conditions that dev's slower startup hides, ProGuard rules removing required classes on Android, OTA bundle on old native code (runtime version mismatch). Step 4: Add Sentry breadcrumbs to trace the user path before the crash. Step 5: After fix, monitor crash rate for 48 hours post-deploy.

Artículos relacionados

How to Answer Conflict-With-a-Coworker Interview Questions

Learn how to answer conflict-with-a-coworker interview questions with real examples and proven techniques. Stand out in tech and remote job interviews.

How to Answer 'Why Do You Want to Work Here' in Interviews

Discover expert strategies for answering 'why do you want to work here,' tailored for remote tech roles and dollar opportunities. Real, practical interview tips.

Frontend Developer Interview Questions and How to Answer Them (50+)

Complete SEO article covering 54 frontend developer interview questions with detailed answers, real code snippets across HTML, CSS, JavaScript, React, TypeScript, accessibility, security, build tools, and testing.

Full-Stack Developer Interview Questions: How to Answer Like a Pro (45+)

Comprehensive full-stack developer interview guide with 46 numbered questions covering JavaScript/TypeScript, React, CSS, REST APIs, databases, Node.js, system design, security, testing, DevOps, and advanced architecture topics. Each answer includes working code examples and production-level context.

Preparate para tu entrevista real

Pegá el link de tu vacante: investigamos quién te entrevista y te ensayamos en vivo.

Empezar gratis →

¿Tenés entrevista próxima? Instalá el copiloto en vivo →

InterviewHack.ai

Preparate para la entrevista exacta: quién te entrevista, tu CV a medida y coach real.

Producto

VacantesRevisar CV (ATS) gratis¿Cómo suena tu inglés?¿Te pagan bien?Reporte de sueldos LATAMCursos gratisBlogCV a medidaPráctica habladaEs gratis

Empleos remotos

ReactPythonFull-StackLATAMArgentinaMéxicoVer todas →

Preparate

Práctica habladaFrontendBackendAI EngineerPor empresaVendete con tu CV

Empresa

Buscás talentoAcerca deContactoPrivacidadTérminos

© 2026 InterviewHack.ai · Tu CV es tuyo. Nunca se usa para entrenar nada. · Un producto de IA-PTY