mediumReact Native#126

User Directory with FlatList

Prompt

Build a user directory screen with a FlatList. Requirements:

  1. Show a list of 20 users (name, email, avatar)
  2. Search bar filters by name (case-insensitive)
  3. Loading state while "fetching"
  4. Show "X results of Y users" header

Solution

export default function UserDirectory() {
  const [search, setSearch] = useState('')
  const [loading, setLoading] = useState(true)
  useEffect(() => {
    const t = setTimeout(() => setLoading(false), 1000)
    return () => clearTimeout(t)
  }, [])
  const filtered = MOCK_USERS.filter(u => u.name.toLowerCase().includes(search.toLowerCase()))
  if (loading) return <Text>Loading users...</Text>
  return (
    <View style={{ flex: 1, padding: 16 }}>
      <TextInput placeholder="Search..." value={search} onChangeText={setSearch} style={{ borderWidth: 1, padding: 8, borderRadius: 4, marginBottom: 12 }} />
      <Text style={{ marginBottom: 8, color: '#666' }}>{filtered.length} of {MOCK_USERS.length} users</Text>
      <FlatList data={filtered} keyExtractor={u => String(u.id)} renderItem={({ item }) => (
        <View style={{ padding: 12, borderBottomWidth: 1, borderColor: '#eee' }}>
          <Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
          <Text style={{ color: '#666' }}>{item.email}</Text>
        </View>
      )} />
    </View>
  )
}
Mentor's take

This is the canonical "fetch + filter + list" screen, and the shipping concern it exercises is state shape: the only client state is the search string and the loading flag. The filtered list is derived in render — MOCK_USERS.filter(...) — never stored. One source of truth means there is nothing to keep in sync.

Mechanics worth defending:

  • Controlled TextInput (value + onChangeText): the search string lives in React state, so the header count, the filter, and the input can never disagree.
  • FlatList, not map-in-ScrollView: FlatList virtualizes — it mounts only a window of rows around the viewport and replaces the rest with correctly-sized blank space. keyExtractor returning the stable id is the identity contract that lets that window slide without remounting rows.
  • Loading as an early return keeps the happy-path JSX flat instead of nesting ternaries.
  • The effect returns clearTimeout — every subscription or timer an effect creates, its cleanup destroys.

Trade-off: filtering on every render is fine for 20 items; past a few hundred you wrap it in useMemo keyed on search — you memoize the derivation, you still don't store it.

Red flag: mirroring the filtered array into state with a useEffect that watches search. That's two renders per keystroke, a stale-sync bug waiting to happen, and the classic junior tell of "state for things that are computable."

Say it: "Search text is the only state — the filtered list is derived in render, and I'd memoize it before I'd ever store it."