Most apps have a list you can search. Let's build one in steps, from the smallest component to a list that stays fast with thousands of rows.
## Start small
A row is a Text inside a Pressable:
import { Pressable, Text } from 'react-native';
export function Row({ title, onPress }: { title: string; onPress: () => void }) {
return (
<Pressable onPress={onPress}>
<Text>{title}</Text>
</Pressable>
);
}Create the app with Expo first:
npx create-expo-app@latest searchable-list
cd searchable-list && npx expo start## Wait for the typing to stop
Filtering on every keystroke is wasteful. A generic hook hands back the value only after it has been still for a moment:
import { useEffect, useState } from 'react';
export function useDebounced<T>(value: T, delay = 250): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setSettled(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return settled;
}## The list
The screen keeps its state in a reducer, filters with useMemo, and renders with a FlatList whose rows are memoized, so typing re-renders only what changed:
import { memo, useCallback, useMemo, useReducer } from 'react';
import { FlatList, StyleSheet, TextInput, View } from 'react-native';
import { Row } from './Row';
import { useDebounced } from './useDebounced';
type Item = { id: string; title: string };
type State = { query: string; selected: Set<string> };
type Action =
| { type: 'query'; query: string }
| { type: 'toggle'; id: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'query':
return { ...state, query: action.query };
case 'toggle': {
const selected = new Set(state.selected);
selected.has(action.id) ? selected.delete(action.id) : selected.add(action.id);
return { ...state, selected };
}
}
}
const MemoRow = memo(Row);
export function SearchScreen({ items }: { items: Item[] }) {
const [state, dispatch] = useReducer(reducer, { query: '', selected: new Set<string>() });
const query = useDebounced(state.query.trim().toLowerCase());
const visible = useMemo(
() => (query ? items.filter((item) => item.title.toLowerCase().includes(query)) : items),
[items, query],
);
const renderItem = useCallback(
({ item }: { item: Item }) => (
<MemoRow title={item.title} onPress={() => dispatch({ type: 'toggle', id: item.id })} />
),
[],
);
return (
<View style={styles.screen}>
<TextInput
placeholder="Search"
value={state.query}
onChangeText={(text) => dispatch({ type: 'query', query: text })}
style={styles.search}
/>
<FlatList data={visible} keyExtractor={(item) => item.id} renderItem={renderItem} initialNumToRender={20} />
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16 },
search: { borderWidth: 1, borderColor: '#d0d7de', borderRadius: 6, padding: 12, marginBottom: 12 },
});## One setting
The keyboard should not cover the list on Android:
{
"expo": {
"name": "Searchable List",
"android": { "softwareKeyboardLayoutMode": "pan" }
}
}That's it: a hook, a reducer and a memoized row. The React Native docs on FlatList go further.