Merge pull request #3787 from inaturalist/mob-1341-search-state-hook

MOB-1341: extract search-field state into useSearchField hook
This commit is contained in:
Seth Peterson
2026-07-01 16:07:47 -05:00
committed by GitHub
2 changed files with 90 additions and 38 deletions

View File

@@ -23,7 +23,7 @@ import React, { useCallback, useRef, useState } from "react";
import type { ListRenderItem, TextInput as RNTextInput } from "react-native";
import { FlatList, Keyboard } from "react-native";
import useCurrentUser from "sharedHooks/useCurrentUser";
import useDebouncedValue from "sharedHooks/useDebouncedValue";
import useSearchField from "sharedHooks/useSearchField";
import useTranslation from "sharedHooks/useTranslation";
import type { UniversalSearchResultItem } from "sharedHooks/useUniversalSearch";
import useUniversalSearch from "sharedHooks/useUniversalSearch";
@@ -60,52 +60,33 @@ const UniversalSearch = ( ) => {
const commonNameIsPrimary = currentUser?.prefers_common_names !== false
&& currentUser?.prefers_scientific_name_first !== true;
const [subjectText, setSubjectText] = useState( "" );
const [locationText, setLocationText] = useState( "" );
const [filledFromSelection, setFilledFromSelection] = useState( false );
// The debounced value that actually drives the autocomplete query. Cleared on
// selection so a chosen suggestion doesn't re-trigger a result list.
const {
debouncedValue: debouncedQuery,
debounce: debounceQuery,
setImmediately: setQueryImmediately,
} = useDebouncedValue( "" );
text: subjectText,
debouncedQuery: subjectQuery,
hasQuery: subjectHasQuery,
onChangeText: onChangeSubjectText,
handleFocus: focusSubjectField,
commit: commitSubject,
clear: clearSubject,
} = useSearchField( );
const [locationText, setLocationText] = useState( "" );
const locationInputRef = useRef<RNTextInput>( null );
const { results, isLoading, refetch } = useUniversalSearch( debouncedQuery );
const { results, isLoading, refetch } = useUniversalSearch( subjectQuery );
const bothFilled = subjectText.length > 0 && locationText.length > 0;
const hasQuery = debouncedQuery.trim( ).length > 0;
const handleSubjectTextChange = useCallback( ( text: string ) => {
setSubjectText( text );
setFilledFromSelection( false );
debounceQuery( text );
}, [debounceQuery] );
const handleSubjectFocus = useCallback( ( ) => {
if ( !filledFromSelection ) { return; }
setSubjectText( "" );
setFilledFromSelection( false );
setQueryImmediately( "" );
}, [filledFromSelection, setQueryImmediately] );
const handleSelect = useCallback( ( subject: ExploreV2Subject ) => {
setSubjectText( subjectToText( subject, commonNameIsPrimary ) );
setFilledFromSelection( true );
setQueryImmediately( "" );
commitSubject( subjectToText( subject, commonNameIsPrimary ) );
dispatch( { type: EXPLORE_V2_ACTION.SET_SUBJECT, subject } );
locationInputRef.current?.focus( );
}, [commonNameIsPrimary, dispatch, setQueryImmediately] );
}, [commitSubject, commonNameIsPrimary, dispatch] );
const handleReset = useCallback( ( ) => {
setSubjectText( "" );
clearSubject( );
setLocationText( "" );
setFilledFromSelection( false );
setQueryImmediately( "" );
}, [setQueryImmediately] );
}, [clearSubject] );
const handleSearch = useCallback( ( ) => {
// TODO MOB-1338 follow-up: run the search (default to all organisms /
@@ -139,8 +120,8 @@ const UniversalSearch = ( ) => {
autoFocus
className="flex-1 ml-2 text-md font-Lato-Regular"
numberOfLines={1}
onChangeText={handleSubjectTextChange}
onFocus={handleSubjectFocus}
onChangeText={onChangeSubjectText}
onFocus={focusSubjectField}
placeholder={t( "Search-for-species-user-or-project" )}
placeholderTextColor={colors.mediumGray}
testID="UniversalSearch.subjectInput"
@@ -188,7 +169,7 @@ const UniversalSearch = ( ) => {
<View className="flex-1">
{/* Only surface results for an active query */}
<FlatList
data={hasQuery
data={subjectHasQuery
? results
: []}
keyboardShouldPersistTaps="handled"
@@ -197,7 +178,7 @@ const UniversalSearch = ( ) => {
ListEmptyComponent={(
<EmptySearchResults
isLoading={isLoading}
searchQuery={debouncedQuery}
searchQuery={subjectQuery}
refetch={refetch}
/>
)}

View File

@@ -0,0 +1,71 @@
import { useCallback, useState } from "react";
import useDebouncedValue from "sharedHooks/useDebouncedValue";
export interface SearchField {
// The controlled input value.
text: string;
// The debounced value that drives the autocomplete query. Cleared on selection
// so a chosen suggestion doesn't re-trigger a result list.
debouncedQuery: string;
// Whether there's a non-empty query to surface results for.
hasQuery: boolean;
// Wire to the input's onChangeText.
onChangeText: ( text: string ) => void;
// Wire to the input's onFocus: clears a previously-committed value so the user
// gets a fresh search when they tap back in.
handleFocus: ( ) => void;
// Fill the field from a chosen suggestion (without re-triggering a query).
commit: ( text: string ) => void;
// Clear the field entirely (e.g. on reset).
clear: ( ) => void;
}
// The shared state machine behind a debounced autocomplete search input: text +
// debounced query + "fill on select, clear on re-focus". The Universal Search
// subject field is an instance of this.
const useSearchField = ( ): SearchField => {
const [text, setText] = useState( "" );
const [filledFromSelection, setFilledFromSelection] = useState( false );
const {
debouncedValue: debouncedQuery,
debounce,
setImmediately,
} = useDebouncedValue( "" );
const onChangeText = useCallback( ( next: string ) => {
setText( next );
setFilledFromSelection( false );
debounce( next );
}, [debounce] );
const handleFocus = useCallback( ( ) => {
if ( !filledFromSelection ) { return; }
setText( "" );
setFilledFromSelection( false );
setImmediately( "" );
}, [filledFromSelection, setImmediately] );
const commit = useCallback( ( next: string ) => {
setText( next );
setFilledFromSelection( true );
setImmediately( "" );
}, [setImmediately] );
const clear = useCallback( ( ) => {
setText( "" );
setFilledFromSelection( false );
setImmediately( "" );
}, [setImmediately] );
return {
text,
debouncedQuery,
hasQuery: debouncedQuery.trim( ).length > 0,
onChangeText,
handleFocus,
commit,
clear,
};
};
export default useSearchField;