Complete interview preparation β architecture, performance, state management, and real-world design problems with detailed explanations.
How you structure your React Native app is the most foundational system design question.
Large RN apps should follow a feature-based (vertical slice) architecture, not a layer-based one. Group by feature, not by type. Each feature is self-contained β it owns its components, hooks, store slices, types, and tests.
src/ βββ app/ # App entry, providers, global setup β βββ App.tsx β βββ providers/ # Redux, Theme, Navigation providers β βββ store/ # Root store configuration β βββ features/ # β Feature-based vertical slices β βββ auth/ β β βββ components/ β β βββ screens/ β β βββ hooks/ β β βββ store/ # authSlice.ts β β βββ api/ # authApi.ts (RTK Query / axios) β β βββ types/ β β βββ index.ts # Public API of the feature β βββ feed/ β βββ profile/ β βββ chat/ β βββ shared/ # Truly shared, no feature dependency β βββ components/ # Button, Modal, Avatarβ¦ β βββ hooks/ # useDebounce, useThemeβ¦ β βββ utils/ # formatDate, validatorsβ¦ β βββ constants/ β βββ theme/ β βββ navigation/ # All navigation config β βββ RootNavigator.tsx β βββ AuthNavigator.tsx β βββ MainTabNavigator.tsx β βββ services/ # External: analytics, storage, push βββ analytics.ts βββ storage.ts βββ notifications.ts
shared/ but
never from each other directly β use events or the root store.
index.ts that defines its
public API. Other code only imports from that barrel.tsconfig.json path aliases like
@features/auth to avoid ../../../ hell.
Mention scalability boundaries β when a new dev joins, they should be able to work on the "payments" feature without understanding the entire codebase. Feature isolation achieves that.
| Aspect | Old (Bridge) | New (JSI) |
|---|---|---|
| Communication | Async JSON serialization | Sync C++ direct calls |
| Memory | Copied across threads | Shared memory possible |
| Startup | All modules loaded | Lazy-loaded TurboModules |
| Animations | Can drop frames | Native thread animations |
| TypeScript types | Manual / codegen | Auto-generated from C++ spec |
| Factor | React Native | Flutter | Native |
|---|---|---|---|
| Team expertise | JS/React devs | Dart devs | Swift/Kotlin devs |
| Code sharing | ~70-90% | ~80-95% | 0% |
| Native look/feel | Yes (native components) | Custom (canvas-drawn) | Perfect |
| Performance ceiling | High (JSI) | Very high (Skia) | Maximum |
| Ecosystem | Huge (npm) | Growing | Platform-specific |
| Web sharing | React Native Web | Flutter Web (beta) | No |
| Hot reload | Yes | Yes | Limited |
One of the most common system design topics β choosing and architecting state correctly.
Before picking a library, classify your state into 4 categories:
| Type | Examples | Solution |
|---|---|---|
| Server State | Feed posts, user profiles, comments | React Query / RTK Query |
| Global UI State | Auth session, theme, notifications | Redux Toolkit / Zustand |
| Local UI State | Modal open, input value, tab index | useState / useReducer |
| URL/Navigation State | Current screen, deep link params | React Navigation |
// 1. Server state β React Query handles caching, refetch, pagination const { data: feed, isLoading } = useQuery({ queryKey: ['feed', userId], queryFn: () => fetchFeed(userId), staleTime: 60_000, // 1 minute fresh cacheTime: 5 * 60_000, // 5 minutes in cache }); // 2. Global UI state β Zustand (lightweight, no boilerplate) const useAuthStore = create((set) => ({ user: null, token: null, login: (user, token) => set({ user, token }), logout: () => set({ user: null, token: null }), })); // 3. Local state β just useState, no global store needed const [isLiked, setIsLiked] = useState(false);
createEntityAdapter to
normalize data β a post should live in one place even if referenced from feed, profile, and
search.redux-persist or Zustand's
persist middleware for auth tokens and offline support.
Interviewers love when you distinguish server state vs. client state. Many candidates put everything in Redux β showing you understand this distinction shows senior thinking.
| Library | Best For | Avoid When | Bundle Size |
|---|---|---|---|
| Context API | Theme, locale, auth (low-frequency updates) | High-frequency updates (causes full re-renders) | 0kb (built-in) |
| Zustand | Medium apps, simple global state, fast setup | Very complex state with many transitions | ~1.5kb |
| Redux Toolkit | Enterprise apps, complex state logic, time-travel debugging | Small/MVP apps β overkill | ~12kb |
| Jotai/Recoil | Atomic state, fine-grained subscriptions | Teams unfamiliar with atom model | ~3kb |
// β Bad: ALL consumers re-render when ANY value changes const AppContext = createContext(); function AppProvider({ children }) { const [user, setUser] = useState(null); const [theme, setTheme] = useState('dark'); const [cart, setCart] = useState([]); // Every Cart update re-renders ALL context consumers return <AppContext.Provider value={{ user, theme, cart }}> {children} </AppContext.Provider>; } // β Good: Split contexts or use Zustand selectors const useCartStore = create((set) => ({ cart: [] })); const CartBadge = () => { // Only re-renders when cart.length changes const count = useCartStore(state => state.cart.length); return <Text>{count}</Text>; };
Performance is the #1 technical differentiator between junior and senior RN engineers.
Before optimizing, profile with Flipper's React DevTools or the RN Performance Monitor to identify what is slow β JS thread, UI thread, or renders.
keyExtractor={(item) => item.id}
React.memo and pass a memoized renderItem with
useCallback.
// β Optimized FlatList pattern const PostItem = React.memo(({ item, onLike }) => ( <View> <Text>{item.title}</Text> <TouchableOpacity onPress={() => onLike(item.id)}> <Text>Like</Text> </TouchableOpacity> </View> )); function Feed() { const handleLike = useCallback((id) => { // stable reference, won't cause re-renders likePost(id); }, []); const renderItem = useCallback(({ item }) => ( <PostItem item={item} onLike={handleLike} /> ), [handleLike]); return <FlashList data={posts} renderItem={renderItem} estimatedItemSize={200} keyExtractor={(item) => item.id} getItemLayout={(_, index) => ({ length: 200, offset: 200 * index, index })} />; }
Standard JS animations run on the JS thread. If the JS thread is busy (re-renders, API calls), animation frames get dropped. Solution: move animations to the UI thread.
| Library | Runs On | Use Case |
|---|---|---|
| Reanimated 3 (worklets) | UI Thread (JS engine on UI) | Complex gestures, physics, shared values |
| Animated API (useNativeDriver) | UI Thread | Simple transforms, opacity, translate |
| Animated API (no native driver) | JS Thread β οΈ | Layout animations, color (limited) |
| Lottie | Native thread | After Effects JSON animations |
| CSS transitions (web) | GPU | Web target only |
// β Reanimated 3 β runs 100% on UI thread import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, runOnJS } from 'react-native-reanimated'; function LikeButton() { const scale = useSharedValue(1); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); const handlePress = () => { scale.value = withSpring(1.3, {}, () => { scale.value = withSpring(1); // bounce back runOnJS(onLike)(); // call JS callback from UI thread }); }; return <Animated.View style={animatedStyle}> <TouchableOpacity onPress={handlePress}> <Text>β€οΈ</Text> </TouchableOpacity> </Animated.View>; }
useNativeDriver: false unless animating layout
properties. Always use useNativeDriver: true for transform/opacity to keep
animations on the UI thread.
inlineRequires in Metro config β
modules only load when first accessed.react-native-bootsplash to hide white flash
during JS loading.import { debounce } from 'lodash-es' not import _ from 'lodash'.
npx react-native bundle-visualizer β find
what's eating your bundle.date-fns over moment.js
(250kb β 20kb tree-shaken).// metro.config.js β inline requires for lazy loading module.exports = { transformer: { getTransformOptions: async () => ({ transform: { inlineRequires: true, // modules load on first use }, }), }, };
| Cause | Fix |
|---|---|
| New object reference on each render | useMemo for objects, useCallback for functions |
| Component not memoized | React.memo(Component) |
| Subscribing to entire store | Use selectors: useSelector(state => state.user.name) |
| Context value changes on every render | Memoize context value with useMemo |
// β Creates new array every render, kills memo <PostList filters={['trending', 'new']} /> // β Stable reference const FILTERS = ['trending', 'new']; // outside component <PostList filters={FILTERS} /> // β useMemo for expensive computed values const sortedPosts = useMemo( () => [...posts].sort((a, b) => b.likes - a.likes), [posts] // only re-sort when posts array changes ); // β Correct React.memo with custom comparator const Avatar = React.memo(({ user }) => ( <Image source={{ uri: user.avatar }} /> ), (prev, next) => prev.user.id === next.user.id);
why-did-you-render library to print console
warnings whenever a component re-renders with the same props β invaluable for finding
unnecessary renders.
Designing a robust, resilient API client layer that handles errors, retries, and caching.
// api/client.ts β Central API client import axios from 'axios'; const apiClient = axios.create({ baseURL: getApiBaseUrl(), timeout: 15000, headers: { 'Content-Type': 'application/json' }, }); // ββ Request interceptor: inject auth token ββ apiClient.interceptors.request.use(async (config) => { const token = await getAccessToken(); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // ββ Response interceptor: handle 401 token refresh ββ let isRefreshing = false; let failedQueue = []; apiClient.interceptors.response.use( (response) => response, async (error) => { const original = error.config; if (error.response?.status === 401 && !original._retry) { if (isRefreshing) { // Queue requests while refreshing return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then(token => { original.headers.Authorization = `Bearer ${token}`; return apiClient(original); }); } original._retry = true; isRefreshing = true; try { const newToken = await refreshAccessToken(); processQueue(null, newToken); // retry queued requests original.headers.Authorization = `Bearer ${newToken}`; return apiClient(original); } catch (refreshError) { processQueue(refreshError, null); logout(); // force re-login return Promise.reject(refreshError); } finally { isRefreshing = false; } } return Promise.reject(error); } );
// Exponential backoff retry import axiosRetry from 'axios-retry'; axiosRetry(apiClient, { retries: 3, retryDelay: axiosRetry.exponentialDelay, // 1s, 2s, 4s retryCondition: (error) => axiosRetry.isNetworkError(error) || error.response?.status >= 500, // retry server errors only });
| Method | Pros | Cons | Use When |
|---|---|---|---|
| Offset (?page=2) | Simple, easy to jump to page N | Drift when items insert/delete | Static data, admin panels |
| Cursor (after: id) | Consistent, no drift | Can't jump to arbitrary page | Feeds, social content |
// React Query infinite scroll β clean pattern const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['feed'], queryFn: ({ pageParam = null }) => fetchPosts({ cursor: pageParam, limit: 20 }), getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }); // Flatten pages for FlatList const posts = useMemo( () => data?.pages.flatMap(page => page.posts) ?? [], [data] ); return <FlashList data={posts} renderItem={renderItem} estimatedItemSize={200} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }} onEndReachedThreshold={0.3} // trigger 30% before end ListFooterComponent={() => isFetchingNextPage ? <ActivityIndicator /> : null } />;
Mobile apps must handle poor/no connectivity gracefully.
// WatermelonDB β designed for offline-first RN apps // Lazy-loads, Observable queries, sync protocol built-in import { synchronize } from '@nozbe/watermelondb/sync'; async function syncWithServer() { await synchronize({ database, pullChanges: async ({ lastPulledAt }) => { const response = await api.get(`/sync?since=${lastPulledAt}`); return response.data; // { changes: {...}, timestamp: ... } }, pushChanges: async ({ changes }) => { await api.post('/sync', { changes }); }, }); }
import NetInfo from '@react-native-community/netinfo'; function useNetworkSync() { useEffect(() => { const unsubscribe = NetInfo.addEventListener(state => { if (state.isConnected && state.isInternetReachable) { syncWithServer(); // trigger sync when back online } }); return unsubscribe; }, []); }
Mobile security is different from web β local storage is exposed, certificates can be intercepted.
| Storage Method | Security Level | Use For |
|---|---|---|
| AsyncStorage | β Plain text on disk | Non-sensitive user preferences only |
| MMKV | β οΈ Encrypted option available | Fast non-sensitive data |
| Keychain (iOS) / Keystore (Android) | β OS-level encryption | Auth tokens, passwords, API keys |
| Expo SecureStore | β Wraps Keychain/Keystore | Expo apps β tokens |
| In-memory only | β Cleared on app close | Highly sensitive temp data |
// β Correct: react-native-keychain for tokens import * as Keychain from 'react-native-keychain'; async function saveToken(token) { await Keychain.setGenericPassword('auth', token, { accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE, }); } async function getToken() { const credentials = await Keychain.getGenericPassword(); return credentials ? credentials.password : null; }
react-native-ssl-pinning.
react-native-jail-monkey to
detect compromised devices.react-native-biometrics for Face ID /
Fingerprint.console.log in
production builds.A testing pyramid tailored for React Native apps.
// Integration test with RNTL β test behavior, not implementation import { render, fireEvent, waitFor } from '@testing-library/react-native'; describe('LoginScreen', () => { it('shows error when login fails', async () => { mockApi.post('/auth/login').replyOnce(401); const { getByPlaceholderText, getByText } = render(<LoginScreen />); fireEvent.changeText(getByPlaceholderText('Email'), 'bad@email.com'); fireEvent.changeText(getByPlaceholderText('Password'), 'wrongpass'); fireEvent.press(getByText('Login')); await waitFor(() => { expect(getByText('Invalid credentials')).toBeTruthy(); }); }); });
renderHook), Redux
reducers/selectorsThe big system design questions β design an entire feature end to end.
inverted={true} on FlatList β new
messages at bottom, list grows up naturally.// Message with optimistic update pattern function useSendMessage(chatId) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (content) => api.sendMessage(chatId, content), // Optimistic update β show message immediately onMutate: async (content) => { const tempMessage = { id: uuid(), // client-generated ID content, chatId, status: 'pending', createdAt: new Date(), }; queryClient.setQueryData(['messages', chatId], (old) => ({ ...old, messages: [...old.messages, tempMessage] })); return { tempMessage }; }, // Replace temp message with server-confirmed message onSuccess: (serverMessage, _, { tempMessage }) => { queryClient.setQueryData(['messages', chatId], (old) => ({ ...old, messages: old.messages.map(m => m.id === tempMessage.id ? serverMessage : m ) })); }, onError: (_, __, { tempMessage }) => { // Mark as failed β show retry button queryClient.setQueryData(['messages', chatId], (old) => ({ ...old, messages: old.messages.map(m => m.id === tempMessage.id ? { ...m, status: 'failed' } : m ) })); } }); }
// PagerView or FlashList (vertical) β one item = full screen import PagerView from 'react-native-pager-view'; function ReelsFeed() { const [activeIndex, setActiveIndex] = useState(0); return <PagerView style={styles.pager} orientation="vertical" onPageSelected={(e) => setActiveIndex(e.nativeEvent.position)} > {reels.map((reel, index) => ( <ReelItem key={reel.id} reel={reel} // Only active Β±1 items render video player isActive={index === activeIndex} shouldPreload={Math.abs(index - activeIndex) === 1} /> ))} </PagerView>; } const ReelItem = React.memo(({ reel, isActive, shouldPreload }) => { return <View style={styles.fullScreen}> {(isActive || shouldPreload) && ( <Video source={{ uri: reel.videoUrl }} paused={!isActive} // pause when not visible repeat={isActive} // loop only active video resizeMode="cover" onBuffer={() => showLoadingIndicator()} /> )} </View>; });
Video.prefetch(url).
// 1. Design Tokens β single source of truth const tokens = { colors: { primary: '#818cf8', surface: '#1e1e2e', text: { primary: '#e2e8f0', secondary: '#94a3b8' }, semantic: { error: '#f87171', success: '#34d399' }, }, spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }, typography: { sizes: { xs: 11, sm: 13, md: 15, lg: 18, xl: 24 }, weights: { regular: '400', semibold: '600', bold: '700' }, }, radii: { sm: 6, md: 10, lg: 16, full: 9999 }, }; // 2. Themed components using tokens function Button({ variant = 'primary', size = 'md', label, onPress }) { const theme = useTheme(); return <TouchableOpacity onPress={onPress} style={[styles.base, styles[variant], styles[`size_${size}`]]} accessibilityRole="button" accessibilityLabel={label} > <Text style={styles.label}>{label}</Text> </TouchableOpacity>; } // 3. Dark mode β swap token values, not component styles const darkTheme = { ...tokens, colors: { ...tokens.colors, surface: '#0f0f1a' } }; const lightTheme = { ...tokens, colors: { ...tokens.colors, surface: '#ffffff' } };
@storybook/react-native to develop and
document components in isolation. It's the gold standard for component libraries and shows great
engineering maturity in interviews.
// react-native-firebase/messaging β comprehensive solution import messaging from '@react-native-firebase/messaging'; function setupNotifications() { // 1. Foreground notifications messaging().onMessage(async (remoteMessage) => { showInAppNotification(remoteMessage); }); // 2. App opened from background tap messaging().onNotificationOpenedApp((remoteMessage) => { navigateToScreen(remoteMessage.data); }); // 3. App opened from killed state messaging().getInitialNotification().then((remoteMessage) => { if (remoteMessage) navigateToScreen(remoteMessage.data); }); // 4. Background handler (runs even when app is killed) messaging().setBackgroundMessageHandler(async (remoteMessage) => { updateBadgeCount(remoteMessage); }); }
| What to Measure | Tool | Key Metrics |
|---|---|---|
| Crashes & errors | Sentry / Firebase Crashlytics | Crash-free rate, error frequency |
| Performance | Sentry Performance / Datadog | App start time, slow renders, ANRs |
| Analytics | Firebase Analytics / Mixpanel | Screen time, funnel completion |
| Network | Sentry / Charles Proxy (dev) | API latency, error rates, payload size |
| JS bundle | Source maps + Sentry | Readable stack traces in production |
// Sentry performance tracing example import * as Sentry from '@sentry/react-native'; // Measure a critical user flow const transaction = Sentry.startTransaction({ name: 'checkout.complete', op: 'user-action' }); try { const span = transaction.startChild({ op: 'api.call', description: 'POST /orders' }); const result = await createOrder(cart); span.finish(); } finally { transaction.finish(); }
Mentioning production observability shows senior maturity. Most candidates only talk about dev tools β talking about Sentry, crash rates, and real-user monitoring separates you from the pack.
π± React Native Frontend System Design Guide
20 questions covering Architecture Β· State Β· Performance Β· Navigation Β· Network Β· Offline Β· Security Β· Testing