Skip to content

Commit 4d0f701

Browse files
committed
feat: migrate useStacSearch hook to TanStack Query
- Refactor useStacSearch to use TanStack Query for fetching and caching - Replace manual state management with useQuery for search and pagination - Add FetchRequest type to handle both search POST and pagination GET requests - Improve pagination handling to properly extract and manage next/prev links - Clear pagination links only when API changes, not on every new search - Add queryClient.invalidateQueries on API instance change for cache cleanup - Fix type casting in useItem reload function for consistency - Update tests to wrap pagination link assertions in waitFor to prevent race conditions - Maintain backwards compatibility with existing loading states and API
1 parent 9999717 commit 4d0f701

File tree

3 files changed

+128
-76
lines changed

3 files changed

+128
-76
lines changed

src/hooks/useItem.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function useItem(url: string): ItemHook {
6060
item,
6161
state,
6262
error: error as ApiError,
63-
reload: refetch,
63+
reload: refetch as () => void,
6464
};
6565
}
6666

src/hooks/useStacSearch.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,8 @@ describe('useStacSearch — API supports POST', () => {
343343
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
344344
// Wait for results to be set in state
345345
await waitFor(() => expect(result.current.results).toEqual(response));
346-
expect(result.current.nextPage).toBeDefined();
346+
// Wait for pagination links to be extracted from results
347+
await waitFor(() => expect(result.current.nextPage).toBeDefined());
347348
// Trigger nextPage and validate
348349
fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
349350
act(() => result.current.nextPage && result.current.nextPage());
@@ -390,7 +391,8 @@ describe('useStacSearch — API supports POST', () => {
390391
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
391392
// Wait for results to be set in state
392393
await waitFor(() => expect(result.current.results).toEqual(response));
393-
expect(result.current.previousPage).toBeDefined();
394+
// Wait for pagination links to be extracted from results
395+
await waitFor(() => expect(result.current.previousPage).toBeDefined());
394396
// Trigger previousPage and validate
395397
fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
396398
act(() => result.current.previousPage && result.current.previousPage());
@@ -437,7 +439,8 @@ describe('useStacSearch — API supports POST', () => {
437439
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
438440
// Wait for results to be set in state
439441
await waitFor(() => expect(result.current.results).toEqual(response));
440-
expect(result.current.previousPage).toBeDefined();
442+
// Wait for pagination links to be extracted from results
443+
await waitFor(() => expect(result.current.previousPage).toBeDefined());
441444
// Trigger previousPage and validate
442445
fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
443446
act(() => result.current.previousPage && result.current.previousPage());
@@ -485,7 +488,8 @@ describe('useStacSearch — API supports POST', () => {
485488
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
486489
// Wait for results to be set in state
487490
await waitFor(() => expect(result.current.results).toEqual(response));
488-
expect(result.current.previousPage).toBeDefined();
491+
// Wait for pagination links to be extracted from results
492+
await waitFor(() => expect(result.current.previousPage).toBeDefined());
489493
// Trigger previousPage and validate merged body
490494
fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
491495
act(() => result.current.previousPage && result.current.previousPage());
@@ -538,7 +542,8 @@ describe('useStacSearch — API supports POST', () => {
538542
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
539543
// Wait for results to be set in state
540544
await waitFor(() => expect(result.current.results).toEqual(response));
541-
expect(result.current.previousPage).toBeDefined();
545+
// Wait for pagination links to be extracted from results
546+
await waitFor(() => expect(result.current.previousPage).toBeDefined());
542547
// Trigger previousPage and validate header
543548
fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
544549
act(() => result.current.previousPage && result.current.previousPage());
@@ -579,7 +584,8 @@ describe('useStacSearch — API supports POST', () => {
579584
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
580585
// Wait for results to be set in state
581586
await waitFor(() => expect(result.current.results).toEqual(response));
582-
expect(result.current.nextPage).toBeDefined();
587+
// Wait for pagination links to be extracted from results
588+
await waitFor(() => expect(result.current.nextPage).toBeDefined());
583589
// Trigger nextPage and validate GET request
584590
fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
585591
act(() => result.current.nextPage && result.current.nextPage());
@@ -620,7 +626,8 @@ describe('useStacSearch — API supports POST', () => {
620626
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
621627
// Wait for results to be set in state
622628
await waitFor(() => expect(result.current.results).toEqual(response));
623-
expect(result.current.previousPage).toBeDefined();
629+
// Wait for pagination links to be extracted from results
630+
await waitFor(() => expect(result.current.previousPage).toBeDefined());
624631
// Trigger previousPage and validate GET request
625632
fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
626633
act(() => result.current.previousPage && result.current.previousPage());

src/hooks/useStacSearch.ts

Lines changed: 113 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useState, useMemo, useEffect } from 'react';
2+
import { useQuery, useQueryClient } from '@tanstack/react-query';
23
import debounce from '../utils/debounce';
34
import type { ApiError, LoadingState } from '../types';
45
import type {
@@ -7,7 +8,6 @@ import type {
78
CollectionIdList,
89
SearchPayload,
910
SearchResponse,
10-
LinkBody,
1111
Sortby,
1212
} from '../types/stac';
1313
import { useStacApiContext } from '../context/useStacApiContext';
@@ -37,40 +37,62 @@ type StacSearchHook = {
3737
previousPage: PaginationHandler | undefined;
3838
};
3939

40+
type FetchRequest =
41+
| {
42+
type: 'search';
43+
payload: SearchPayload;
44+
headers?: Record<string, string>;
45+
}
46+
| {
47+
type: 'get';
48+
url: string;
49+
};
50+
4051
function useStacSearch(): StacSearchHook {
4152
const { stacApi } = useStacApiContext();
42-
const [results, setResults] = useState<SearchResponse>();
53+
const queryClient = useQueryClient();
54+
55+
// Search parameters state
4356
const [ids, setIds] = useState<string[]>();
4457
const [bbox, setBbox] = useState<Bbox>();
4558
const [collections, setCollections] = useState<CollectionIdList>();
4659
const [dateRangeFrom, setDateRangeFrom] = useState<string>('');
4760
const [dateRangeTo, setDateRangeTo] = useState<string>('');
4861
const [limit, setLimit] = useState<number>(25);
4962
const [sortby, setSortby] = useState<Sortby[]>();
50-
const [state, setState] = useState<LoadingState>('IDLE');
51-
const [error, setError] = useState<ApiError>();
63+
64+
// Track the current request (search or pagination) for React Query
65+
const [currentRequest, setCurrentRequest] = useState<FetchRequest | null>(null);
5266

5367
const [nextPageConfig, setNextPageConfig] = useState<Link>();
5468
const [previousPageConfig, setPreviousPageConfig] = useState<Link>();
5569

5670
const reset = () => {
57-
setResults(undefined);
5871
setBbox(undefined);
5972
setCollections(undefined);
6073
setIds(undefined);
6174
setDateRangeFrom('');
6275
setDateRangeTo('');
6376
setSortby(undefined);
6477
setLimit(25);
78+
setCurrentRequest(null);
79+
setNextPageConfig(undefined);
80+
setPreviousPageConfig(undefined);
6581
};
6682

6783
/**
6884
* Reset state when stacApi changes
6985
*/
70-
useEffect(reset, [stacApi]);
86+
useEffect(() => {
87+
if (stacApi) {
88+
reset();
89+
// Invalidate all search queries when API changes
90+
void queryClient.invalidateQueries({ queryKey: ['stacSearch'] });
91+
}
92+
}, [stacApi, queryClient]);
7193

7294
/**
73-
* Extracts the pagination config from the the links array of the items response
95+
* Extracts the pagination config from the links array of the items response
7496
*/
7597
const setPaginationConfig = useCallback((links: Link[]) => {
7698
setNextPageConfig(links.find(({ rel }) => rel === 'next'));
@@ -93,85 +115,108 @@ function useStacSearch(): StacSearchHook {
93115
);
94116

95117
/**
96-
* Resets the state and processes the results from the provided request
118+
* Fetch function for searches using TanStack Query
97119
*/
98-
const processRequest = useCallback(
99-
(request: Promise<Response>) => {
100-
setResults(undefined);
101-
setState('LOADING');
102-
setError(undefined);
103-
setNextPageConfig(undefined);
104-
setPreviousPageConfig(undefined);
105-
106-
request
107-
.then((response) => response.json())
108-
.then((data) => {
109-
setResults(data);
110-
if (data.links) {
111-
setPaginationConfig(data.links);
112-
}
113-
})
114-
.catch((err) => setError(err))
115-
.finally(() => setState('IDLE'));
116-
},
117-
[setPaginationConfig]
118-
);
120+
const fetchRequest = async (request: FetchRequest): Promise<SearchResponse> => {
121+
if (!stacApi) throw new Error('No STAC API configured');
122+
123+
const response =
124+
request.type === 'search'
125+
? await stacApi.search(request.payload, request.headers)
126+
: await stacApi.get(request.url);
127+
128+
if (!response.ok) {
129+
let detail;
130+
try {
131+
detail = await response.json();
132+
} catch {
133+
detail = await response.text();
134+
}
135+
const err = Object.assign(new Error(response.statusText), {
136+
status: response.status,
137+
statusText: response.statusText,
138+
detail,
139+
});
140+
throw err;
141+
}
142+
return await response.json();
143+
};
119144

120145
/**
121-
* Executes a POST request against the `search` endpoint using the provided payload and headers
146+
* useQuery for search and pagination with caching
122147
*/
123-
const executeSearch = useCallback(
124-
(payload: SearchPayload, headers = {}) =>
125-
stacApi && processRequest(stacApi.search(payload, headers)),
126-
[stacApi, processRequest]
127-
);
148+
const {
149+
data: results,
150+
error,
151+
isLoading,
152+
isFetching,
153+
} = useQuery<SearchResponse, ApiError>({
154+
queryKey: ['stacSearch', currentRequest],
155+
queryFn: () => fetchRequest(currentRequest!),
156+
enabled: currentRequest !== null,
157+
retry: false,
158+
});
128159

129160
/**
130-
* Execute a GET request against the provided URL
161+
* Extract pagination links from results
131162
*/
132-
const getItems = useCallback(
133-
(url: string) => stacApi && processRequest(stacApi.get(url)),
134-
[stacApi, processRequest]
135-
);
163+
useEffect(() => {
164+
// Only update pagination links when we have actual results with links
165+
// Don't clear them when results becomes undefined (during new requests)
166+
if (results?.links) {
167+
setPaginationConfig(results.links);
168+
}
169+
}, [results, setPaginationConfig]);
136170

137171
/**
138-
* Retrieves a page from a paginated item set using the provided link config.
139-
* Executes a POST request against the `search` endpoint if pagination uses POST
140-
* or retrieves the page items using GET against the link href
172+
* Convert a pagination Link to a FetchRequest
141173
*/
142-
const flipPage = useCallback(
143-
(link?: Link) => {
144-
if (link) {
145-
let payload = link.body as LinkBody;
146-
if (payload) {
147-
if (payload.merge) {
148-
payload = {
149-
...payload,
150-
...getSearchPayload(),
151-
};
152-
}
153-
executeSearch(payload, link.headers);
154-
} else {
155-
getItems(link.href);
156-
}
174+
const linkToRequest = useCallback(
175+
(link: Link): FetchRequest => {
176+
if (link.body) {
177+
const payload = link.body.merge ? { ...link.body, ...getSearchPayload() } : link.body;
178+
return {
179+
type: 'search',
180+
payload,
181+
headers: link.headers,
182+
};
157183
}
184+
return {
185+
type: 'get',
186+
url: link.href,
187+
};
158188
},
159-
[executeSearch, getItems, getSearchPayload]
189+
[getSearchPayload]
160190
);
161191

162-
const nextPageFn = useCallback(() => flipPage(nextPageConfig), [flipPage, nextPageConfig]);
163-
164-
const previousPageFn = useCallback(
165-
() => flipPage(previousPageConfig),
166-
[flipPage, previousPageConfig]
167-
);
192+
/**
193+
* Pagination handlers
194+
*/
195+
const nextPageFn = useCallback(() => {
196+
if (nextPageConfig) {
197+
setCurrentRequest(linkToRequest(nextPageConfig));
198+
}
199+
}, [nextPageConfig, linkToRequest]);
200+
201+
const previousPageFn = useCallback(() => {
202+
if (previousPageConfig) {
203+
setCurrentRequest(linkToRequest(previousPageConfig));
204+
}
205+
}, [previousPageConfig, linkToRequest]);
168206

207+
/**
208+
* Submit handler for new searches
209+
*/
169210
const _submit = useCallback(() => {
170211
const payload = getSearchPayload();
171-
executeSearch(payload);
172-
}, [executeSearch, getSearchPayload]);
212+
setCurrentRequest({ type: 'search', payload });
213+
}, [getSearchPayload]);
214+
173215
const submit = useMemo(() => debounce(_submit), [_submit]);
174216

217+
// Sync loading state for backwards compatibility
218+
const state: LoadingState = isLoading || isFetching ? 'LOADING' : 'IDLE';
219+
175220
return {
176221
submit,
177222
ids,
@@ -186,7 +231,7 @@ function useStacSearch(): StacSearchHook {
186231
setDateRangeTo,
187232
results,
188233
state,
189-
error,
234+
error: error ?? undefined,
190235
sortby,
191236
setSortby,
192237
limit,

0 commit comments

Comments
 (0)