React Native Land_

// building better mobile apps

# A searchable list in React Native

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:

Row.tsxtsx
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:

useDebounced.tsts
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:

SearchScreen.tsxtsx
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:

app.jsonjson
{
  "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.