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?
answer
- context consumers ignore memo
- mounted window is many screens
- one JS thread for typing and rows
- split fast and stable contexts
- rows get only their own props
basics
~20 sTake 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 sA 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 linesimport { 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
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.
Explain why memo does not help, why FlatList's mounted window multiplies the cost, and how splitting contexts and memoizing provider values contains it.
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.
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