Skip to main content

React Native engineer coding interview questions

11 questions on coding for react native engineer candidates. Expect prompts such as “Implement a debounced search hook for a React Native search bar” and “Paginated infinite scroll list with React Query and FlatList”, each with a worked answer outline and the follow-ups interviewers push on.

Questions

Showing 1 to 11 of 11 coding questions.

As asked

Write a useDebounce hook in TypeScript that delays a value update by a configurable ms. Then show how you would use it in a search screen that fires an API call only after the user stops typing for 300ms. Also handle the case where the component unmounts before the debounce fires.

Sample answer outline

The hook uses useState and useEffect: on value change it sets a setTimeout and returns the previous debouncedValue until the timeout fires. The useEffect cleanup cancels the timeout on unmount or re-run, preventing state updates on unmounted components. In the search screen, useDebounce wraps the raw query string and the API call is triggered by the debounced value in a separate useEffect. A strong answer also mentions cancelling in-flight fetch calls with AbortController when a new debounced query arrives.

Reference implementation (typescript)

TypeScript
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = React.useState(value);
  React.useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

// Usage in SearchScreen
const debouncedQuery = useDebounce(query, 300);
React.useEffect(() => {
  if (!debouncedQuery) return;
  const controller = new AbortController();
  fetchResults(debouncedQuery, controller.signal);
  return () => controller.abort();
}, [debouncedQuery]);

Expect these follow-ups

  • How do you test this hook in Jest with fake timers?
  • How would you change this to throttle instead of debounce?
hooksdebouncesearchtypescriptreact

As asked

Implement an infinite-scrolling product list using useInfiniteQuery from React Query and a FlatList. The API returns a cursor for the next page. Show how you derive the flat item array from pages, detect the end of list, and handle loading and error states.

Sample answer outline

useInfiniteQuery takes a getNextPageParam function that extracts the cursor from the last page's response. data.pages is an array of page objects, so the flat item list is data.pages.flatMap(p => p.items). FlatList's onEndReached triggers fetchNextPage when onEndReachedThreshold is met. hasNextPage guards the fetchNextPage call. A ListFooterComponent shows a spinner when isFetchingNextPage is true and an error message when isError is true. A strong answer also mentions using queryClient.invalidateQueries for pull-to-refresh and setting staleTime to avoid refetching on tab switch.

Reference implementation (typescript)

TypeScript
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
  useInfiniteQuery({
    queryKey: ['products'],
    queryFn: ({ pageParam }) => fetchProducts(pageParam),
    getNextPageParam: last => last.nextCursor ?? undefined,
  });
const items = data?.pages.flatMap(p => p.items) ?? [];

<FlatList
  data={items}
  keyExtractor={i => i.id}
  renderItem={({ item }) => <ProductCard item={item} />}
  onEndReached={() => { if (hasNextPage) fetchNextPage(); }}
  onEndReachedThreshold={0.5}
  ListFooterComponent={isFetchingNextPage ? <Spinner /> : null}
/>

Expect these follow-ups

  • How do you prevent onEndReached from firing multiple times for a single scroll event?
  • How do you retain scroll position when navigating away and back to the list screen?
react-queryinfinite-scrollflatlistpaginationnetworking

As asked

Write and explain a React Native FlatList setup for 10,000 message rows. The list should avoid avoidable row renders and keep scroll performance stable.

Sample answer outline

The candidate should stabilise renderItem and keyExtractor, memoise row components, and provide getItemLayout when row height is fixed. Initial render and window sizes should be tuned to the screen rather than set to huge values that hide poor rendering. The answer should mention image sizing, avoiding inline closures in rows, and measuring with the performance monitor. getItemLayout is only safe when row heights are known; variable height feeds need measurement or a different strategy. A common mistake is putting all messages into parent component state and changing the array identity on every tick.

Reference implementation (tsx)

TSX
import React, { memo, useCallback } from "react";
import { FlatList, Text, View } from "react-native";

type Message = { id: string; author: string; body: string };

const ROW_HEIGHT = 72;

const MessageRow = memo(function MessageRow({ message }: { message: Message }) {
  return (
    <View style={{ height: ROW_HEIGHT, padding: 12 }}>
      <Text>{message.author}</Text>
      <Text numberOfLines={2}>{message.body}</Text>
    </View>
  );
});

export function MessageList({ messages }: { messages: Message[] }) {
  const renderItem = useCallback(
    ({ item }: { item: Message }) => <MessageRow message={item} />,
    [],
  );

  return (
    <FlatList
      data={messages}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      getItemLayout={(_, index) => ({
        length: ROW_HEIGHT,
        offset: ROW_HEIGHT * index,
        index,
      })}
      initialNumToRender={12}
      windowSize={7}
      removeClippedSubviews
    />
  );
}

Expect these follow-ups

  • What if row heights are variable because images load later?
  • How do you keep an inverted chat list anchored when new messages arrive?
  • When is FlashList a better choice?
flatlistreactperformance

As asked

Implement a basic virtualized list in React Native style using a ScrollView (not FlatList). Assume fixed item height. Given a container of 500 logical pixels, items of 50 logical pixels each, and an overscan of 2 items, write a function that calculates which item indices to render for a given scrollY. Then explain how FlatList's windowSize and maxToRenderPerBatch map to the concepts in your implementation.

Sample answer outline

The candidate defines startIndex = Math.max(0, Math.floor(scrollY / itemHeight) - overscan) and endIndex = Math.min(totalItems - 1, Math.ceil((scrollY + containerHeight) / itemHeight) + overscan). The rendered items are sliced to [startIndex, endIndex]. The container has a total height of totalItems * itemHeight (set as a fixed div), and rendered items are positioned absolutely with top = index * itemHeight. A strong answer mentions that in RN this maps to the windowSize and maxToRenderPerBatch props in VirtualizedList and that FlashList does cell recycling to avoid DOM/view creation cost.

Reference implementation (typescript)

TypeScript
function getVisibleRange(scrollY, containerHeight, itemHeight, totalItems, overscan) {
  const start = Math.max(0, Math.floor(scrollY / itemHeight) - overscan);
  const end = Math.min(
    totalItems - 1,
    Math.ceil((scrollY + containerHeight) / itemHeight) + overscan
  );
  return { start, end };
}

// Example: scrollY=200, container=500, itemHeight=50, total=1000, overscan=2
// visible rows 4..10 + overscan => start=2, end=12

Expect these follow-ups

  • How does your calculation change for variable-height items?
  • What causes blank cells and how does overscan reduce them?
virtualizationlistsalgorithmsflatlistperformance

As asked

In a React Native app you are building a custom canvas-based photo editor that loads decoded image bitmaps into a JS-side cache for manipulation. Implement an in-memory LRU cache in TypeScript that holds at most N Uint8Array image blobs, evicting the least recently used when full. Your get and set must both be O(1). After showing the implementation, explain how this relates to how react-native-fast-image manages its own in-process memory cache and why you would reach for that instead of a custom solution in most RN apps.

Sample answer outline

The classic solution uses a doubly-linked list for order tracking and a Map (hash map) for O(1) lookup. On get, move the accessed node to the head. On set, insert at head; if over capacity, delete the tail. A Map preserves insertion order in JS so you can use Map with deletion and re-insertion to implement the ordering without a manual linked list, which the candidate might prefer for clarity. A strong answer mentions that in a real RN app they would use fast-image's built-in cache rather than a custom solution, and that this pattern is relevant for caching decoded bitmaps in a custom canvas renderer.

Reference implementation (typescript)

TypeScript
class LRUCache<K, V> {
  private cap: number;
  private map: Map<K, V>;
  constructor(capacity: number) {
    this.cap = capacity;
    this.map = new Map();
  }
  get(key: K): V | undefined {
    if (!this.map.has(key)) return undefined;
    const val = this.map.get(key)!;
    this.map.delete(key);
    this.map.set(key, val); // move to end (most recent)
    return val;
  }
  set(key: K, value: V): void {
    this.map.delete(key);
    this.map.set(key, value);
    if (this.map.size > this.cap) {
      this.map.delete(this.map.keys().next().value!);
    }
  }
}

Expect these follow-ups

  • How does your implementation handle concurrent async writes?
  • How would you add a TTL (time-to-live) eviction on top of LRU?
data-structureslru-cachehashmapcachingtypescript

As asked

Using react-native-gesture-handler and react-native-reanimated, implement a card that the user can swipe horizontally. When released past 40% of screen width, it animates off screen and calls onDismiss. Below that threshold it snaps back. Show the key hooks and animated style.

Sample answer outline

The solution uses useSharedValue for translateX, useAnimatedStyle to derive the card's transform, and PanGestureHandler (or Gesture.Pan() in RNGH v2) to update translateX in a worklet. On gesture end, if Math.abs(translateX.value) > SCREEN_WIDTH * 0.4, withSpring or withTiming animates to +/-SCREEN_WIDTH and calls runOnJS(onDismiss)(); otherwise it snaps back to 0 with withSpring. A strong answer runs all animation logic in worklets (no JS thread involvement) and notes that this gives 60fps even when the JS thread is busy.

Reference implementation (typescript)

TypeScript
const translateX = useSharedValue(0);
const gesture = Gesture.Pan()
  .onUpdate(e => { translateX.value = e.translationX; })
  .onEnd(e => {
    const shouldDismiss = Math.abs(e.translationX) > SCREEN_WIDTH * 0.4;
    if (shouldDismiss) {
      translateX.value = withTiming(
        e.translationX > 0 ? SCREEN_WIDTH : -SCREEN_WIDTH,
        {}, () => runOnJS(onDismiss)()
      );
    } else {
      translateX.value = withSpring(0);
    }
  });
const style = useAnimatedStyle(() => ({
  transform: [{ translateX: translateX.value }]
}));

Expect these follow-ups

  • How do you add a rotation effect that increases as the card moves further from center?
  • How would you handle preventing accidental dismissal on a slow swipe?
reanimatedgesture-handleranimationworkletsui-thread

As asked

A React Native screen receives a filter object as a prop that is recreated on every parent render. Putting it directly in a useEffect dependency array causes the effect (which fires a native module call) to run on every render, flooding the native layer. Implement a useDeepMemo hook that returns a stable object reference when the value has not changed. Then explain why fixing this at the source (in the parent) is usually preferable, and when useDeepMemo is still the right tool in an RN codebase.

Sample answer outline

The hook uses useRef to store the last value and a deep-equality function (fast-deep-equal or a manual recursive check). If the new value deeply equals the previous ref, it returns the ref (stable reference). Otherwise it updates the ref and returns the new value. A strong answer notes that JSON.stringify is a fragile equality check (key order, undefined, Date objects), so fast-deep-equal or lodash.isEqual is safer. They should also mention that the root cause is an upstream component not memoizing the object, and useMemo at the source is a cleaner fix.

Reference implementation (typescript)

TypeScript
import equal from 'fast-deep-equal';
function useDeepMemo<T>(value: T): T {
  const ref = React.useRef<T>(value);
  if (!equal(ref.current, value)) {
    ref.current = value;
  }
  return ref.current;
}

// Usage
const stableFilter = useDeepMemo(filter);
useEffect(() => { fetchData(stableFilter); }, [stableFilter]);

Expect these follow-ups

  • When is useRef the right tool vs useMemo for stabilizing a reference?
  • How would you write a useDeepEffect that handles the deep comparison internally?
hooksmemoizationuserefdeep-equalityreact

As asked

You have a component that calls three setState calls in a native event callback (not a React synthetic event). In React 17 each call causes a separate render. How do you batch them in React 17, and how does React 18 change the default behavior? Write a code example showing both approaches.

Sample answer outline

In React 17, React batches setState calls inside React event handlers but not inside native callbacks (like a timer or a native module callback). The fix is to wrap all three calls in unstable_batchedUpdates from react-native. In React 18, automatic batching applies to all state updates everywhere, so the explicit wrap is no longer needed. A strong answer shows the unstable_batchedUpdates import from react-native (not react-dom) and notes that this is especially relevant in RN when a native module calls back into JS.

Reference implementation (typescript)

TypeScript
import { unstable_batchedUpdates } from 'react-native';

// React 17: manual batching in native callback
nativeModule.onData(data => {
  unstable_batchedUpdates(() => {
    setItems(data.items);
    setTotal(data.total);
    setLoading(false);
  });
});

// React 18: automatic batching, no wrapper needed
nativeModule.onData(data => {
  setItems(data.items);
  setTotal(data.total);
  setLoading(false); // batched automatically
});

Expect these follow-ups

  • How do you opt out of automatic batching in React 18 for a specific update?
  • How does this batching behavior interact with Concurrent Mode priorities?
reactbatchingstate-updatesreact-18rendering

As asked

In a React Native app, multiple screens fire concurrent API calls on mount. When the access token expires, all of them return 401 simultaneously. You are using Axios with a shared instance configured for your RN app. Implement a response interceptor that calls the refresh endpoint exactly once, queues the other failed requests, and replays them all with the new token. Also explain what happens on iOS and Android if the refresh fails and the user must be redirected to the login screen from inside the interceptor callback.

Sample answer outline

The interceptor uses a boolean isRefreshing flag and a failedQueue array of resolve/reject pairs. When the first 401 hits, isRefreshing is set to true and the refresh call starts. Subsequent 401 requests are pushed onto failedQueue as Promises. When refresh succeeds, processQueue iterates failedQueue and resolves each with the new token, then retries the original request. On refresh failure, processQueue rejects all queued requests and clears the session. A strong answer handles the edge case where the refresh token itself returns a 401, triggering logout.

Reference implementation (typescript)

TypeScript
let isRefreshing = false;
let failedQueue: Array<{resolve:(t:string)=>void; reject:(e:unknown)=>void}> = [];

axios.interceptors.response.use(undefined, async err => {
  const orig = err.config;
  if (err.response?.status === 401 && !orig._retry) {
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then(token => { orig.headers.Authorization = `Bearer ${token}`; return axios(orig); });
    }
    orig._retry = true;
    isRefreshing = true;
    try {
      const token = await refreshAccessToken();
      failedQueue.forEach(p => p.resolve(token));
      orig.headers.Authorization = `Bearer ${token}`;
      return axios(orig);
    } catch(e) {
      failedQueue.forEach(p => p.reject(e));
      logout();
      throw e;
    } finally { isRefreshing = false; failedQueue = []; }
  }
  throw err;
});

Expect these follow-ups

  • How do you handle the case where the refresh endpoint itself is rate-limited?
  • How do you write a Jest test that verifies only one refresh call is made when three concurrent requests fail?
axiosauthtoken-refreshinterceptorconcurrency

As asked

Implement a TypeScript class OfflineQueue that stores mutations (as JSON-serializable objects) in MMKV, processes them in order on reconnect, retries failed ones with exponential backoff up to 3 attempts, and drops them after 3 failures. Sketch the key methods.

Sample answer outline

The queue stores an array of objects with fields: id, payload, attempts, nextRetryAt. enqueue serializes and appends to MMKV. processQueue iterates items where nextRetryAt <= Date.now(), calls the network function, on success removes from queue, on failure increments attempts and sets nextRetryAt = now + 2^attempts * BASE_DELAY_MS, drops after 3 attempts. The queue is triggered by a NetInfo subscription on reconnect. A strong answer notes idempotency keys on each mutation to prevent duplicate side effects on retry, and that the queue should run in a background task on reconnect rather than blocking the UI.

Reference implementation (typescript)

TypeScript
interface QueueItem {
  id: string;
  payload: unknown;
  attempts: number;
  nextRetryAt: number;
}

class OfflineQueue {
  private KEY = 'offline_queue';
  enqueue(payload: unknown) {
    const items = this.load();
    items.push({ id: uuid(), payload, attempts: 0, nextRetryAt: 0 });
    this.save(items);
  }
  async processQueue(send: (p: unknown) => Promise<void>) {
    const now = Date.now();
    const items = this.load().filter(i => i.nextRetryAt <= now);
    for (const item of items) {
      try {
        await send(item.payload);
        this.remove(item.id);
      } catch {
        item.attempts++;
        if (item.attempts >= 3) { this.remove(item.id); continue; }
        item.nextRetryAt = now + Math.pow(2, item.attempts) * 1000;
        this.update(item);
      }
    }
  }
}

Expect these follow-ups

  • How do you ensure two concurrent calls to processQueue do not send the same item twice?
  • How would you surface failed-after-3-attempts items to the user?
offlinequeueretryexponential-backoffmmkv

As asked

Your app has 50,000 product names stored in SQLite and needs instant autocomplete with no network calls. You decide to build a trie in memory on app load. Implement the insert and search methods in TypeScript. How do you limit results to the top 10 and how do you handle memory on low-end devices?

Sample answer outline

Each trie node has a children map (letter to node) and an isEnd flag (optionally storing the full word or metadata). Insert traverses/creates nodes character by character. Search traverses to the prefix node then does DFS to collect all words below it, stopping at 10 results. For memory on low-end devices, the candidate should consider building the trie lazily (only for the first character typed), using a compressed trie (Patricia trie), or delegating to an FTS5 SQLite index instead, which is more memory-efficient for 50K+ terms.

Reference implementation (typescript)

TypeScript
class TrieNode {
  children: Map<string, TrieNode> = new Map();
  isEnd = false;
  word = '';
}
class Trie {
  root = new TrieNode();
  insert(word: string) {
    let node = this.root;
    for (const ch of word.toLowerCase()) {
      if (!node.children.has(ch)) node.children.set(ch, new TrieNode());
      node = node.children.get(ch)!;
    }
    node.isEnd = true;
    node.word = word;
  }
  search(prefix: string, limit = 10): string[] {
    let node = this.root;
    for (const ch of prefix.toLowerCase()) {
      if (!node.children.has(ch)) return [];
      node = node.children.get(ch)!;
    }
    const results: string[] = [];
    const dfs = (n: TrieNode) => {
      if (results.length >= limit) return;
      if (n.isEnd) results.push(n.word);
      for (const child of n.children.values()) dfs(child);
    };
    dfs(node);
    return results;
  }
}

Expect these follow-ups

  • How do you add frequency-based ranking so more popular products appear first?
  • What is SQLite FTS5 and when would it beat a hand-written trie for this use case?
trieautocompletedata-structuresofflinesqlite