src/hooks/useAutoComplete/index.ts (125 lines of code) (raw):

import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDebounce } from 'use-debounce'; import { apiHttp } from '@/constants'; import { AsyncStatus } from '@/types'; // // Typedef // export type AutoCompleteItem = { value: string; label: string }; export type AutoCompleteSettings<T> = { url: string; params?: Record<string, string>; // Parse incoming data to autocomplete items; parser?: (item: T) => AutoCompleteItem; // Custom function to find results from dataset finder?: (item: AutoCompleteItem, input: string) => boolean; // preFetch = true sets mode so we use cache and send only single query. This might be slower initially but after that request autocomplete works really fast. preFetch?: boolean; // Flag if we should send request with empty input searchEmpty?: boolean; enabled?: boolean; }; export type AutoCompleteParameters<T> = { input: string; } & AutoCompleteSettings<T>; export type AutoCompleteResult = { data: AutoCompleteItem[]; status: AsyncStatus; timestamp: number }; // // Datastore // const DataStore: Record<string, AutoCompleteResult> = {}; // // Hook // // Let's refetch every 20 seconds for now const TIME_TO_REFETCH = 20000; const DEFAULT_PARAMS = { _limit: '5', }; function useAutoComplete<T>({ preFetch, url: rawUrl, params = {}, input, parser, finder, searchEmpty = false, enabled = true, }: AutoCompleteParameters<T>): { result: AutoCompleteResult; reset: () => void; refetch: () => void } { const qparams = new URLSearchParams({ ...DEFAULT_PARAMS, ...params }).toString(); const requestUrl = apiHttp(`${rawUrl}${qparams ? '?' + qparams : ''}`); const [refetcher, setRefetcher] = useState(0); const [url] = useDebounce(requestUrl, preFetch ? 0 : 300); const [result, setResult] = useState<AutoCompleteResult>( DataStore[url] ? DataStore[url] : { status: 'NotAsked', data: [], timestamp: 0 }, ); const updateResult = useCallback(() => { const newResults = DataStore[url].data.filter((item) => finder ? finder(item, input) : item.value.toLocaleLowerCase().includes(input.toLowerCase()), ); setResult({ status: DataStore[url]?.status || 'NotAsked', data: newResults, timestamp: DataStore[url]?.timestamp || 0, }); }, [finder, input, url]); // Initialise caching useEffect(() => { if (!DataStore[url] && preFetch) { DataStore[url] = { status: 'NotAsked', data: [], timestamp: 0 }; } }, [url, preFetch]); // Update results when input changes on prefetch useEffect(() => { if (input && preFetch) { updateResult(); } }, [preFetch, input, updateResult]); const abortCtrl = useMemo(() => new AbortController(), []); useEffect(() => { if (DataStore[url] && DataStore[url].status === 'Loading') { abortCtrl.abort(); } }, [requestUrl, abortCtrl, url]); const reset = useCallback(() => { setResult({ status: 'NotAsked', data: [], timestamp: 0 }); }, []); const refetch = useCallback(() => { setRefetcher((num) => num + 1); }, []); useEffect(() => { if ((!searchEmpty && !input) || !enabled) return; const parseResult = parser || ((item: T) => ({ label: item, value: item })); if (preFetch) { if (DataStore[url]?.status !== 'NotAsked' && Date.now() - DataStore[url]?.timestamp < TIME_TO_REFETCH) { updateResult(); } else { DataStore[url] = { status: 'Loading', data: [], timestamp: Date.now() }; // fetch fetch(url, { signal: abortCtrl.signal }) .then((resp) => resp.json()) .then((response) => { if (Array.isArray(response) || Array.isArray(response.data)) { DataStore[url] = { status: 'Ok', timestamp: Date.now(), data: (Array.isArray(response) ? response : response.data).map(parseResult), }; } else { DataStore[url] = { status: 'Error', data: [], timestamp: Date.now() }; } updateResult(); }) .catch(() => { DataStore[url] = { status: 'Error', data: [], timestamp: Date.now() }; }); } } else { fetch(url, { signal: abortCtrl.signal }) .then((resp) => resp.json()) .then((response) => { if (response.status === 200 && Array.isArray(response.data)) { const data: AutoCompleteItem[] = response.data.map(parseResult); setResult({ status: 'Ok', data: finder ? data.filter((item) => finder(item, input)) : data, timestamp: Date.now(), }); } else { setResult({ status: 'Error', data: [], timestamp: Date.now() }); } }) .catch(() => { setResult({ status: 'Error', data: [], timestamp: Date.now() }); }); } }, [url, preFetch, searchEmpty, refetcher, abortCtrl, input, enabled, finder, parser, updateResult]); return { result, reset, refetch, }; } export default useAutoComplete;