Keep Typing While Search Loads: Finish the React Native Search Flow
A search request should not remove the field the user is typing into. Keep the input mounted above the results area, update its value immediately, and let the results show loading, success, or failure independently.
Keep the input immediate, and let only the results wait.
This is the final lesson in the Search module. Day 0 introduced debounce, and Day 1 guarded stale suggestions at submit. Here we finish the remaining flow: loading, minimum length, trimming, clear, empty results, errors, retry, and caching. Day 3 moves to Lists.
The video uses an illustrated phone with sample data and synthetic narration. I reviewed the integration example against project patterns and current documentation, but did not execute it on a device. It requires your API adapter and an existing QueryClientProvider. The video is an illustration rather than a recording of a client app.
Keep the input outside the loading branch
An early if (loading) return <Spinner /> replaces the entire screen, including the input. Instead, render TextInput unconditionally and put loading/error/result branches below it. Bind its value to the immediate state. The debounced term belongs to the request alone.
Keeping the input mounted does not mean preventing every React render. It means preserving the field rather than removing it when loading changes. The native TextInput value and focus methods are documented in React Native’s TextInput reference.
This addresses one layout mistake. Keyboard behavior, expensive JavaScript work and platform differences also cause typing lag, and each needs its own diagnosis. Focus is requested through the ref; do not promise identical keyboard behavior across Android and iOS without testing on the target versions.
First load and updates need different treatment
Show a body loader when no response is available. If a previous successful response exists, keep its rows visible while another valid query loads, label the term those rows belong to, and show an update indicator. The screen should be able to say “Showing results for shoes; updating for boots.”
The example uses TanStack Query v5’s placeholderData callback to retain the previous answer only when its language and category scope match the current screen. This is an adaptation of the body/footer loading pattern, not a claim that the cited source preserves rows across every term change. See the official previous-data guide.
Visible old rows are still old rows. Keep Day 1’s term-match guard before using a suggestion to redirect, and preserve product IDs and routing validation for item taps.
Trim the request term, keep the keystrokes
Use query.trim() for the request term. Keep the original query in the input so the user can type spaces naturally.
This example requires two characters after trimming. One reviewed source uses two, another uses three. Neither number is a universal rule. A catalog with one-character product codes may need explicit submit or a lower threshold.
Two checks control fetching: the immediate trimmed term is valid, and it has settled into the debounced term. Clearing or deleting below the threshold disables fetching immediately. The debounce effect cancels its pending timer on change and unmount.
Disabling a query does not erase cached data and does not necessarily cancel work already in flight. The UI separately hides results whenever the immediate term is invalid. A late response can populate its own cache entry, but cannot repopulate this cleared view. The adapter should consume the supplied abort signal to support cancellation when the query cancels a request.
Say what actually happened
| Situation | UI |
|---|---|
| No valid query | Type at least 2 characters |
| No answer yet, request active | Loader in the results area |
| Successful response, zero items | No matches for the response’s term |
| Initial request failed | Could not load results + Retry |
| Refresh failed and data remains | Preserve the answer and show a refresh error |
| Waiting for connectivity | A waiting state rather than a no-matches claim |
The API adapter must reject network failures and unsuccessful HTTP responses. Do not use .catch(() => []), which turns failure into apparent success with no matches. Validate the response shape in your adapter as well.
Retry the same inputs
The query key owns the request term, language and category. refetch() retries that query. Disable Retry during fetching, and guard the handler because an explicit refetch can bypass enabled.
Also suppress Retry while the immediate text differs from the debounced term. Otherwise a user could type a new word and accidentally retry the old one. Automatic retries are disabled in this example solely to make the manual interaction clear. Choose a suitable retry policy for your actual API.
TanStack documents these distinctions in disabling queries.
Reuse a correctly scoped answer
The sample key is ['search', term, lang, category]. Include every input that affects your server response, including additional filters or account scope when relevant. Two requests that share a key must refer to the same data.
staleTime: 60_000 means the answer is treated as fresh for one minute. Returning to the same key within that window can avoid a request if the answer is still cached. gcTime is separate: it controls removal of inactive cache entries. In this sample it is five minutes. Keep the same QueryClient across navigation; rebuilding it on every screen discards the benefit.
A language change selects a different key. If that key has no cached answer, it fetches. When data becomes stale, expiration alone does not start a timer-driven request: events such as mounting or an explicit refetch can trigger a refresh. Freshness is not a promise that server data cannot change. Invalidate affected queries after relevant changes.
A hand-written timestamp plus TTL can be valid. The reviewed manual five-minute cache covers trending data. It does not cache every typed search word. Here we adapt that freshness concept to keyed search results. Library caching reduces bookkeeping; it does not make all manual caches incorrect.
Complete integration example
The example uses React Native components and TanStack Query v5. Reviewed source versions include NiCHE RN 0.76.1, riya RN 0.79.6, and Gayar RN 0.71.19. I list them as provenance. They are not a compatibility test. No emulator/API level is claimed. Strings are English for teaching clarity; translate them through your application’s localization layer.
// Integration example for React Native + TanStack Query v5.
// Requires a QueryClientProvider above this screen and a search API adapter.
// Reviewed teaching code; not executed on a device.
import React, {useEffect, useRef, useState} from 'react';
import {ActivityIndicator, Button, FlatList, Text, TextInput, View} from 'react-native';
import {useQuery} from '@tanstack/react-query';
type Product = {id: string; name: string};
type SearchData = {term: string; lang: string; category: string; items: Product[]};
type SearchArgs = {term: string; lang: string; category: string; signal: AbortSignal};
type Props = {lang: string; category: string; search: (args: SearchArgs) => Promise<Product[]>};
function useDebouncedTerm(term: string, delay: number) {
const [debounced, setDebounced] = useState(term);
useEffect(() => {
// Immediately settle an invalid term; cleanup cancels pending valid terms.
if (term.length < 2) {setDebounced(term); return;}
const timer = setTimeout(() => setDebounced(term), delay);
return () => clearTimeout(timer);
}, [term, delay]);
return debounced;
}
export function SearchScreen({lang, category, search}: Props) {
const [query, setQuery] = useState('');
const input = useRef<TextInput>(null);
const term = query.trim();
const ready = term.length >= 2;
const debouncedTerm = useDebouncedTerm(term, 300);
const settled = term === debouncedTerm;
const result = useQuery({
queryKey: ['search', debouncedTerm, lang, category] as const,
queryFn: async ({queryKey: [, requestedTerm, requestedLang, requestedCategory], signal}): Promise<SearchData> => ({
term: requestedTerm,
lang: requestedLang,
category: requestedCategory,
// Adapter must reject failures, validate response shape, and pass signal to fetch.
items: await search({term: requestedTerm, lang: requestedLang, category: requestedCategory, signal}),
}),
enabled: ready && settled,
placeholderData: previous =>
previous?.lang === lang && previous.category === category ? previous : undefined,
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: false, // Show manual retry clearly in this teaching example.
});
// disabled does not erase cached data; gate the visible rows independently.
const data = ready ? result.data : undefined;
const waiting = ready && (!settled || result.isFetching);
const retry = () => {
// refetch can bypass enabled, so guard this handler too.
if (ready && settled && !result.isFetching) void result.refetch();
};
const error = ready && settled && result.isError ? (
<View accessibilityLiveRegion="polite">
<Text>{data ? 'Could not refresh. Showing the previous answer.' : 'Could not load results.'}</Text>
<Button title="Retry" onPress={retry} disabled={!settled || result.isFetching} />
</View>
) : null;
const clear = () => {setQuery(''); input.current?.focus();};
// TextInput never lives inside the results loading/error condition.
return (
<View style={{flex: 1}}>
<TextInput ref={input} value={query} onChangeText={setQuery}
placeholder="Search products" accessibilityLabel="Search products" />
<Button title="Clear search" onPress={clear} />
{!ready ? <Text>Type at least 2 characters.</Text> : (
<View style={{flex: 1}}>
{error}
{data ? (
<>
<Text>Showing results for {data.term}{waiting ? `; updating for ${term}` : ''}</Text>
<FlatList data={data.items} keyExtractor={item => item.id}
renderItem={({item}) => <Text>{item.name}</Text>}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={<Text>No matches for {data.term}.</Text>}
ListFooterComponent={waiting ? <ActivityIndicator accessibilityLabel="Updating results" /> : null} />
</>
) : waiting ? <ActivityIndicator accessibilityLabel="Loading results" />
: !error ? <Text>Waiting to connect…</Text> : null}
</View>
)}
</View>
);
}
Check the behavior in your app
- Add 1,500 ms of mock API latency. Type during a request: the input must remain present and editable.
- Try
" s ": the request term becomes"s", so this example sends no request. Try" sh ": search for"sh"after the pause. - Search once, then change the term: retain and label the previous answer while updating.
- Clear before the debounce fires, and clear again during an in-flight request: the visible body must reset immediately. Wait for late responses and confirm it stays reset.
- Return a successful empty array, then simulate a rejected request. The messages must differ.
- Retry after failure: verify identical term/language/category in the request log. Repeated taps during fetching must not create additional manual attempts.
- Type a new term after an error but before debounce settles: there must be no active retry of the old term.
- Navigate away and back within one minute with the same QueryClient: a fresh cached answer should be reused. Change language or category: verify a different key.
- Let data become stale, then trigger a refetch. If that refresh fails, keep any available answer and show the error honestly.
- Run the focus/clear behavior on your target iOS and Android versions, including a dismissed keyboard and Arabic input.
These are verification steps for your integration. I did not run them here.
Source evidence and adaptations
The source review confirmed an input outside the loading body in NiCHE, pinned input and skeleton body in Podium, a two-character threshold and clear/refocus in riya, and a three-character threshold and footer loader in Gayar. Git blame attributes the reviewed NiCHE input and riya threshold lines to Ibrahim Fathi.
Gayar clears products before starting a new term: retaining rows across terms is an explicit improvement in this teaching example. Podium’s products screen supplies the loading/error/retry/empty branching pattern, while riya’s ListErrorView documents why failed requests must not appear empty. Podium’s search hook supplies query-key, eligibility and freshness patterns; this example explicitly adds language to its key. Private project paths and source excerpts are not required to use the example.
Search is complete
Together, the three lessons cover debounce, stale-suggestion safety, and the remaining search lifecycle. The next lesson is “Why My Home List Rendered Everything at Launch,” starting the Lists module. Its link will be added when published.
Comments