skip to content

A React Native timeline re-renders every mounted post on each keystroke in its search box because the query sits in a context every row reads; how would you fix it?

level: seniorimportance: should knowfreq 35%

answer

  1. context consumers ignore memo
  2. mounted window is many screens
  3. one JS thread for typing and rows
  4. split fast and stable contexts
  5. rows get only their own props

basics

~20 s

Take the query out of the context the rows read: keep it in the search header, filter the data above the list, and put only rarely changing values such as action handlers in a memoized row context.

solid answer

~50 s

A context consumer re-renders whenever the provider's value changes, and `memo` on the row does not stop that. In a `FlatList` the damage is multiplied by the **mounted window** - by default about ten screens above and below the visible one - and all of it runs on the single **JS thread** that is also handling the keystroke, so typing lags on low-end Android. Fix the shape of the data flow: keep `query` in the screen or search header, derive the filtered `data` there, and pass rows only their own item. Put stable things such as `like(id)` in a separate context whose value is memoized, so it never changes on a keystroke. If filtering itself is heavy, React 19's `useDeferredValue` keeps the input urgent while the list catches up. Confirm with a render count or profile before and after.

code

tsx · 45 lines
tsx
import { createContext, memo, useContext, useDeferredValue, useMemo, useState } from 'react';
import { FlatList, StyleSheet, Text, TextInput, View } from 'react-native';

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

// Changes rarely, so rows can read it without fan-out on keystrokes.
const TimelineActions = createContext<{ like: (id: string) => void } | null>(null);

const PostRow = memo(function PostRow({ post }: { post: Post }) {
  const actions = useContext(TimelineActions);
  return (
    <View style={styles.row}>
      <Text style={styles.author} onPress={() => actions?.like(post.id)}>
        {post.author}
      </Text>
      <Text numberOfLines={3}>{post.body}</Text>
    </View>
  );
});

const renderItem = ({ item }: { item: Post }) => <PostRow post={item} />;
const keyExtractor = (post: Post) => post.id;

export function TimelineScreen({ posts, like }: { posts: Post[]; like: (id: string) => void }) {
  const [query, setQuery] = useState(''); // owned here, not in a row-level context
  const deferredQuery = useDeferredValue(query);
  const visible = useMemo(() => {
    const q = deferredQuery.toLowerCase();
    return posts.filter((p) => p.body.toLowerCase().includes(q));
  }, [posts, deferredQuery]);
  const actions = useMemo(() => ({ like }), [like]);

  return (
    <TimelineActions.Provider value={actions}>
      <TextInput value={query} onChangeText={setQuery} placeholder="Search posts" style={styles.search} />
      <FlatList data={visible} renderItem={renderItem} keyExtractor={keyExtractor} />
    </TimelineActions.Provider>
  );
}

const styles = StyleSheet.create({
  search: { margin: 12, padding: 8, borderWidth: 1, borderColor: '#ccc' },
  row: { paddingHorizontal: 16, paddingVertical: 12 },
  author: { fontWeight: '600', marginBottom: 4 },
});

go deeper

for a junior

Recall that every component reading a context re-renders when its value changes, and that putting fast-changing state in a shared context spreads the cost widely.

for a middle

Explain why memo does not help, why FlatList's mounted window multiplies the cost, and how splitting contexts and memoizing provider values contains it.

for a senior

Show a data-flow redesign for the timeline: query owned by the screen, derived data, narrow row props, deferred filtering, verified with render counts on a release build.

for a principal

Discuss team conventions for shared state on list-heavy screens: when context is acceptable, when a store with selectors is warranted, and how to review for fan-out.

## The symptom A timeline screen has a search box above a `FlatList` of posts. To let rows highlight matches, someone put `query` and `setQuery` into a `SearchContext` provided at the screen root, and every `PostRow` calls `useContext(SearchContext)`. Now each keystroke: 1. updates `query` in the provider; 2. gives the context a **new value**; 3. re-renders **every mounted row** that reads the context - including memoized ones; 4. re-runs the filter - all before the JS thread is free for the next keystroke. On a fast phone this is a small delay; on a low-end Android phone typing feels sticky. ## Why React Native makes it worse The underlying rule is React's: a component that reads a context re-renders when the provider's value changes, and `memo` compares **props**, so it cannot skip that render. Two React Native specifics turn it into a hot spot: - **The mounted window is large.** `FlatList` keeps rows mounted well beyond the screen; with the default `windowSize` of 21 that is about ten viewports above and ten below. After scrolling a long timeline, a context change can reach many screens' worth of rows. - **One JS thread does everything.** The keystroke's state update, every row render and the filtering all run on the same **JS thread**, which has about 16.7 ms per frame at 60 Hz. Hundreds of row renders per keystroke miss that budget easily. ## Fixing the data flow The goal is that a keystroke touches only what genuinely depends on the query. | Value | Changes | Where it should live | |---|---|---| | `query` | every keystroke | state in the screen or the search header | | filtered `data` | when the query settles | derived above the `FlatList` | | `like(id)`, theme, current user | rarely | a separate context with a memoized value | | per-row highlight | only for matching rows | a small prop computed for that row | Concretely: - **Move `query` out of the row context.** The screen owns it, filters `posts` and passes the result as `data`. - **Split contexts by change rate.** A stable `TimelineActions` context holds handlers; its value comes from `useMemo`, so its identity is stable across keystrokes. - **Memoize provider values.** An inline `value={{ like }}` is a new object on every provider render and re-renders every consumer even when nothing changed. - **Give rows narrow props.** A row that needs to highlight gets `highlight` only if it matches, so rows whose output is unchanged keep identical props. - **Defer the heavy part.** `useDeferredValue(query)` in React 19 lets the `TextInput` update urgently while filtering and list rendering follow; a short debounce is an alternative when filtering is costly. ## Confirming the fix Measure rather than assume: 1. Count row renders per keystroke before and after (a temporary counter, or a profile). 2. Type quickly on a **release build** on a low-end device. 3. Check that typing stays responsive while the list updates behind it. 4. Check that rows still update when they should, such as a highlight appearing on a match. ## Pitfalls - **Wrapping rows in `memo` and expecting it to block context updates.** It does not. - **Storing the query in a ref** to avoid re-renders. Consumers are never notified, so highlights go stale. - **Splitting contexts but recreating the stable value inline**, which brings the fan-out back. - **Filtering inside each row**, which multiplies the work by the mounted row count. - **Blaming the `TextInput`**. The input is fine; the JS thread is busy with everything else a keystroke triggers. A strong answer names the mechanism (context bypasses memo), the React Native multiplier (a large mounted window on one JS thread), and a data-flow fix rather than a sprinkling of `memo`.

  • Why does memo on PostRow not stop the re-renders in the original design?
    `memo` skips a render when the props are unchanged, but a component that reads a context is re-rendered whenever that context's value changes, independently of its props. The rows read `SearchContext`, so every keystroke reaches every mounted row.
  • Rows must highlight the matched term. How do you keep that without re-rendering every row on each keystroke?
    Compute highlights above the list and pass a small prop only to rows that match, such as the term or match ranges. Rows whose highlight did not change keep the same props and skip rendering; only matching rows update, after the deferred query settles.
  • Why does the mounted window size matter for context fan-out in a FlatList?
    Only mounted rows can re-render, and FlatList keeps far more than the visible rows mounted - about ten viewports on each side with the default windowSize of 21. The larger the mounted set, the more rows a single context change reaches.

A context is a building-wide intercom: every flat with a speaker hears every announcement, however polite the residents are about ignoring visitors at the door (memo). Announce the search on the intercom and every flat stops what it is doing; tell only the front desk instead.

saying these in an interview costs you the question

  • Wrapping rows in memo stops context-driven re-renders
  • Only rows visible on screen react to a context change
  • Keeping the query in a ref is a safe way to avoid re-renders
  • An inline provider value is fine if its fields are unchanged
  • Typing lags because TextInput is slow on Android