Three approaches
| Approach | Look | Control | Best for |
|---|---|---|---|
| headerSearchBarOptions | True platform search | Low | List screens inside a native stack |
| Custom TextInput | Whatever you design | Total | Search inside a screen, filter chips |
| Paper Searchbar | Material 3 | Medium | Apps already on react-native-paper |
The split is simple. If search applies to the whole screen and the screen is a list, use the header option — it is the control iOS users already know, and the large-title collapse animation is genuinely hard to fake. If search is one element within a screen, sitting beside filter chips or a segmented control, build it from a TextInput so it lays out with everything else.
The native one, in Expo Router
Expo Router’s Stack is the native stack, so the search bar it exposes is the real platform control rather than a JavaScript reimplementation:
import { useMemo, useState } from 'react';
import { FlatList, Text } from 'react-native';
import { Stack } from 'expo-router';
export default function RecipesScreen() {
const [query, setQuery] = useState('');
const recipes = useRecipes();
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return recipes;
return recipes.filter(
(r) => r.title.toLowerCase().includes(q) || r.tags.some((t) => t.includes(q))
);
}, [recipes, query]);
return (
<>
<Stack.Screen
options={{
title: 'Recipes',
headerLargeTitle: true,
headerSearchBarOptions: {
placeholder: 'Search recipes',
hideWhenScrolling: false,
// native event, not a plain string
onChangeText: (e) => setQuery(e.nativeEvent.text),
onCancelButtonPress: () => setQuery(''),
},
}}
/>
<FlatList
data={results}
keyExtractor={(r) => r.id}
renderItem={({ item }) => <RecipeRow recipe={item} />}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
ListEmptyComponent={<Text style={styles.empty}>No recipes match “{query}”.</Text>}
/>
</>
);
}Two props do quiet but important work. contentInsetAdjustmentBehavior="automatic" is what lets the large title and search bar collapse correctly as the list scrolls — without it the header sits in a half-collapsed state that looks subtly broken on iOS. And onChangeText here receives a native event, so reading e.nativeEvent.text rather than a string argument is the difference between working code and a query that is permanently undefined.
The cost is control: colours, corner radius, and placement come from the platform. That is a feature on a standard list screen and a blocker inside a designed layout, which is the next section.
The custom one
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import Svg, { Circle, Line } from 'react-native-svg';
export default function SearchField({ value, onChange, placeholder = 'Search' }) {
return (
<View style={styles.wrap}>
<Svg width={16} height={16} viewBox="0 0 24 24" style={styles.icon}>
<Circle cx="11" cy="11" r="7" stroke="#5a5a5a" strokeWidth={2} fill="none" />
<Line x1="16.5" y1="16.5" x2="21" y2="21" stroke="#5a5a5a" strokeWidth={2} strokeLinecap="round" />
</Svg>
<TextInput
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor="#5a5a5a"
style={styles.input}
returnKeyType="search"
autoCorrect={false}
autoCapitalize="none"
clearButtonMode="while-editing" // iOS only
accessibilityLabel={placeholder}
/>
{/* Android has no clearButtonMode — draw our own */}
{value.length > 0 && (
<Pressable onPress={() => onChange('')} hitSlop={12} accessibilityLabel="Clear search">
<Text style={styles.clear}>✕</Text>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
height: 44,
paddingHorizontal: 14,
borderRadius: 12,
backgroundColor: '#141414',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
},
icon: { opacity: 0.9 },
input: { flex: 1, color: '#fff', fontSize: 15, padding: 0 },
clear: { color: '#5a5a5a', fontSize: 14 },
});autoCorrect={false} and autoCapitalize="none" matter more than they look: autocorrect on a search field will happily rewrite a product name into a real word and leave the user staring at an empty result set. padding: 0 on the input is the fix for Android adding invisible vertical padding that pushes the text off-centre inside your 44pt row.
Local filtering vs server search
These are different problems and the common mistake is applying the fix for one to the other.
Local: do not debounce. Filtering a few hundred objects is sub-millisecond work, and a 300ms delay on a local list just makes typing feel broken. What you do need is useMemo, so FlatList gets the same array reference back when nothing changed — the inline data={items.filter(...)}version rebuilds every row on every keystroke, which is the real cause of nearly every “search is laggy” report. The FlatList guide covers the memoisation rules in full.
Server:debounce and cancel. Without cancellation, a slow response for “ch” can land after the fast response for “chicken” and overwrite it — a race that is invisible on a fast connection and constant on a train:
import { useEffect, useState } from 'react';
export function useSearch(query, delay = 300) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const q = query.trim();
if (!q) { setResults([]); setLoading(false); return; }
const controller = new AbortController();
setLoading(true);
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
setResults(await res.json());
} catch (err) {
if (err.name !== 'AbortError') setResults([]);
} finally {
setLoading(false);
}
}, delay);
// runs on every keystroke: kills the pending timer AND the in-flight request
return () => { clearTimeout(timer); controller.abort(); };
}, [query, delay]);
return { results, loading };
}The cleanup function is the whole thing. It cancels both the timer that has not fired and the request that has, so only the latest query can ever set state. If you use React Query or SWR, keying the query on the search string gives you the same guarantee plus caching for free.
The empty state is part of the feature
A search bar has three states, and most implementations ship one. Before typing, show recent searches or popular categories rather than a blank screen. While loading, keep the previous results visible and show a subtle indicator instead of flashing a spinner over everything. With no matches, say what was searched for and offer a way out — clear the query, or drop the active filters. “No results” alone leaves the user with nothing to tap.
If you want that whole screen rather than the pieces, describe it — “a recipes list with a search bar that filters by title and tag, recent searches before typing, and an empty state with a clear button” — and ShipNative builds it as real React Native running on your phone, all three states included. Export it and keep going by hand whenever you want.