Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 9 min read

React Native Search Bar: Native Header or Custom

Most React Native search bars are a styled TextInput with a magnifying glass next to it — which is fine, and also throws away a real platform component you already have. The native stack navigator can render the actual iOS search bar, collapse-on-scroll included, from one options object. This guide covers that, the custom version for when you need it, and the part everyone gets wrong regardless of which they choose: filtering the list without dropping frames.

Three approaches

ApproachLookControlBest for
headerSearchBarOptionsTrue platform searchLowList screens inside a native stack
Custom TextInputWhatever you designTotalSearch inside a screen, filter chips
Paper SearchbarMaterial 3MediumApps 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.

Frequently Asked Questions

Is there a built-in search bar in React Native?

Not as a standalone component, but the native stack navigator exposes the real platform one. Set headerSearchBarOptions on a screen and you get UISearchController on iOS and the Material search view on Android, including the large-title collapse behaviour that is very hard to reproduce by hand.

How do I add a search bar to an Expo Router screen?

Render a Stack.Screen with options containing headerSearchBarOptions — placeholder, onChangeText, and optionally hideWhenScrolling. Because it is native, the callbacks receive a native event, so read the text from event.nativeEvent.text rather than expecting a plain string.

Should I debounce search in React Native?

Debounce network requests, not local filtering. For a client-side list of a few hundred items, a useMemo filter is fast enough to run on every keystroke and debouncing only makes typing feel laggy. For a server search, debounce around 300ms and cancel the previous request, or slow responses will arrive out of order and overwrite newer results.

Why does my list flicker or lag when I type in the search bar?

Almost always because the filtered array is rebuilt inline in render, so FlatList sees a new data reference on every keystroke and re-renders every visible row. Wrap the filter in useMemo keyed on the query and the source data, memoise the row component, and pass a stable keyExtractor.

How do I keep the keyboard from covering search results?

Set keyboardShouldPersistTaps="handled" on the list so a tap on a result registers instead of only dismissing the keyboard, and keyboardDismissMode="on-drag" so scrolling the results puts the keyboard away. Those two props fix the majority of reports that search results are untappable.

Can an AI app builder generate a search screen for me?

Yes. Describe it — "a recipes list with a search bar that filters by title and tag, and an empty state when nothing matches" — and ShipNative generates the screen, the filtering, and the empty state as real React Native running on your device.

→

React Native FlatList

Search is a filtering problem — this is how the list underneath behaves.

Read guide →
→

KeyboardAvoidingView

The keyboard covering your results is a solved, and fiddly, problem.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.