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:
// 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 ThreadNew 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++ callWhy 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:
// 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.
# Check if Hermes is enabled in your app
npx react-native info
# In your Metro bundle, you'll see .hbc (Hermes Bytecode) instead of .jsbundleEnable Hermes (React Native 0.70+ it's the default):
// 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.
// 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.
// 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:
// This can't be a StyleSheet — it depends on runtime state
<View style={[styles.base, { opacity: animatedValue }]} />Best practice:
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:
// 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:
// 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:
- 1Re-renders the component tree in JavaScript (fast, in-memory)
- 2Runs the reconciler (Fiber in modern React) to diff the old and new virtual trees
- 3Computes the minimal set of changes needed
- 4Sends only those changes across the bridge/JSI to the native thread
- 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.
// 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:
// 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:
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:
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// 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:
- 1Resolution: Finds all files starting from your entry point (
index.js), following allimportandrequirestatements. - 2Transformation: Runs each file through Babel transformers (converts JSX, TypeScript, and modern JS to JavaScript the engine understands).
- 3Serialization: Combines all transformed modules into a single bundle (or split bundles for lazy loading).
Development vs. production mode:
# 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.bundlemetro.config.js — common customizations:
// 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:
<TouchableOpacity
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
>
<Text>Press me</Text>
</TouchableOpacity>Pressable — the modern replacement:
<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
pressedstate is passed as a render prop — you can style any child - Supports
onPressIn,onPressOut,onLongPresswith timing control - Supports
hitSlopfor expanding the tap area without changing layout
<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:
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:
// 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:
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:
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:
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:
// 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:
// 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:
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:
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
useMemoprevents the work *inside* the component, whileuseCallbackprevents 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:
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:
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:
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:
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:
// 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.
// 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:
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:
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:
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
const Form = () => (
<KeyboardAwareScrollView>
<TextInput ... />
<TextInput ... />
<TextInput ... />
<Button ... />
</KeyboardAwareScrollView>
);Listening to keyboard events directly:
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:
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:
- 1Single responsibility — one hook does one thing
- 2Name describes the value, not the mechanism —
useCurrentUser, notuseUserFetch - 3Return an object, not a tuple, when returning 3+ values — named properties are clearer than positional
- 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:
// 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:
// 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:
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:
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:
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:
// 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:
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:
// 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:
// 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:
// 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:
// 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.
// 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:
// 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:
// 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:
// 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:
// 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:
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:
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:
// 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:
- 1JS thread overloaded: Heavy computation, large state updates, synchronous storage reads
- 2Bridge congestion: Too many native calls per frame (old architecture)
- 3Slow native operations on the UI thread: Complex shadow calculations, large image decoding
- 4Unnecessary re-renders: Components re-rendering when their data didn't change
Diagnosis tools:
# 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.systraceCommon fixes:
// 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:
// 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 changesWhen it adds overhead without benefit:
// 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 overheadThe comparison function for complex props:
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:
- 1The component renders meaningfully often
- 2It re-renders with the same props most of the time
- 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:
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:
// 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:
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:
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:
// 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:
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:
// 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:
// 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:
// 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:
// 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:
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
<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
scrollToIndexandscrollToOffsetwithout rendering intermediate items - Faster initial layout calculation
- Required for accurate scroll restoration
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:
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:
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:
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:
<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:
// 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:
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):
// 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();
}
}// 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
}
@endUsing the native module from JavaScript:
import { NativeModules } from 'react-native';
const { ToastModule } = NativeModules;
ToastModule.show('Hello from native', 0);The modern approach — TurboModules with Codegen:
// 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:
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:
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:
// 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:
// 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):
// 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:
# Create a new Expo app
npx create-expo-app MyApp
# No native directories — Expo handles them
MyApp/
app.json
App.tsx
package.jsonExpo 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:
# Build for app stores in the cloud — no local Xcode/Android Studio required
eas build --platform allTradeoffs:
| | 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:
// 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:
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:
<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:
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:
// 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:
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:
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.
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:
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:
- 1Reproduce first, always. "Can you share a screen recording? Which device model and OS version?"
- 2Scope to platform. "Does this happen on iOS, Android, or both?" — platform-specific bugs point directly to the native layer.
- 3Scope to environment. "Does it happen in dev build, production build, or both?" — many issues only appear in production (Hermes bytecode, minification, ProGuard rules).
- 4Get the logs. Metro logs, device logs via
adb logcat(Android) or Xcode console (iOS), or Sentry/Crashlytics crash reports.
- 5Check recent changes.
git log --since="3 days ago"— most bugs appear near recent changes.
- 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
useEffectreturns?
2. Performance
- Are expensive operations inside
useMemo/useCallback? - Are list items wrapped in
React.memo? - Are there
useNativeDriver: trueon 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.
# 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:
# 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:
// android/gradle.properties
newArchEnabled=true# 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:
// 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 restRisk 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:
# 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.jsbundleStep 2 — Reproduce the conditions:
Production builds differ from dev builds:
# 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:
// 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:
- 1Close the article
- 2Say the answer out loud (not in your head)
- 3Open the article and fill in what you missed
- 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.