Skip to content

Commit 9bccaaa

Browse files
committed
feat: migrate useCollections hook to TanStack Query
- Refactor useCollections and the closely related useCollection hooks to use TanStack Query for caching and fetching - Improve error propagation, keep original loading states - Update StacApiProvider to manage QueryClient and support custom clients - Clarify peer dependency requirements for @tanstack/react-query in README and docs/react-query-setup.md - Update ESLint config and package.json for new dependencies - Minor fixes to context and tests for compatibility
1 parent a93b27c commit 9bccaaa

File tree

9 files changed

+192
-70
lines changed

9 files changed

+192
-70
lines changed

README.md

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,47 +19,75 @@ With Yarn:
1919
yarn add @developmentseed/stac-react
2020
```
2121

22+
### Peer Dependency: @tanstack/react-query
23+
24+
stac-react relies on [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) for data fetching and caching. To avoid duplicate React Query clients and potential version conflicts, stac-react lists `@tanstack/react-query` as a **peer dependency**. This means you must install it in your project:
25+
26+
```sh
27+
npm install @tanstack/react-query
28+
# or
29+
yarn add @tanstack/react-query
30+
```
31+
32+
If you do not install it, your package manager will warn you, and stac-react will not work correctly.
33+
2234
## Getting started
2335

24-
Stac-react's hooks must be used inside children of a React context that provides access to the stac-react's core functionality.
36+
stac-react's hooks must be used inside children of a React context that provides access to the stac-react's core functionality.
2537

26-
To get started, initialize `StacApiProvider` with the base URL of the STAC catalog.
38+
To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. `StacApiProvider` automatically sets up a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) QueryClientProvider for you, so you do not need to wrap your app with QueryClientProvider yourself.
2739

2840
```jsx
29-
import { StacApiProvider } from "stac-react";
41+
import { StacApiProvider } from 'stac-react';
3042

3143
function StacApp() {
3244
return (
33-
<StacApiProvider apiUrl="https://my-stac-api.com">
34-
// Other components
35-
</StacApiProvide>
45+
<StacApiProvider apiUrl="https://my-stac-api.com">{/* Other components */}</StacApiProvider>
46+
);
47+
}
48+
```
49+
50+
If you want to provide your own custom QueryClient (for advanced caching or devtools), you can pass it as a prop:
51+
52+
```jsx
53+
import { StacApiProvider } from 'stac-react';
54+
import { QueryClient } from '@tanstack/react-query';
55+
56+
const queryClient = new QueryClient();
57+
58+
function StacApp() {
59+
return (
60+
<StacApiProvider apiUrl="https://my-stac-api.com" queryClient={queryClient}>
61+
{/* Other components */}
62+
</StacApiProvider>
3663
);
3764
}
3865
```
3966

67+
For additional information, see the React Query setup guide: [docs/react-query-setup.md](docs/react-query-setup.md).
68+
4069
Now you can start using stac-react hooks in child components of `StacApiProvider`
4170

4271
```jsx
43-
import { StacApiProvider, useCollections } from "stac-react";
72+
import { StacApiProvider, useCollections } from 'stac-react';
4473

4574
function Collections() {
4675
const { collections } = useCollections();
4776

4877
return (
49-
<ul>
50-
{collections.collections.map(({ id, title }) => (
51-
<li key={id}>{ title }</li>
52-
))}
53-
</ul>
54-
55-
)
78+
<ul>
79+
{collections.collections.map(({ id, title }) => (
80+
<li key={id}>{title}</li>
81+
))}
82+
</ul>
83+
);
5684
}
5785

5886
function StacApp() {
5987
return (
6088
<StacApiProvider apiUrl="https://my-stac-api.com">
6189
<Collections />
62-
</StacApiProvide>
90+
</StacApiProvider>
6391
);
6492
}
6593
```
@@ -73,14 +101,10 @@ Provides the React context required for stac-react hooks.
73101
#### Initialization
74102

75103
```jsx
76-
import { StacApiProvider } from "stac-react";
104+
import { StacApiProvider } from 'stac-react';
77105

78106
function StacApp() {
79-
return (
80-
<StacApiProvider apiUrl="https://my-stac-api.com">
81-
// Other components
82-
</StacApiProvide>
83-
);
107+
return <StacApiProvider apiUrl="https://my-stac-api.com">// Other components</StacApiProvider>;
84108
}
85109
```
86110

@@ -471,9 +495,9 @@ function StacComponent() {
471495
```
472496

473497
| Option | Type | Description |
474-
| ------------ | -------- | --------------------------------- | ------------------------------------------------------------------------------------------- |
498+
| ------------ | -------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
475499
| `detail` | `string` | `object | The error return from the API. Either a`string` or and `object` depending on the response. |
476-
| `status` | `number` | HTTP status code of the response. |
500+
| `status` | `number` | HTTP status code of the response. |
477501
| `statusText` | `string` | Status text for the response. |
478502

479503
## Development

docs/react-query-setup.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# QueryClient Best Practice
2+
3+
stac-react relies on [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) for data fetching and caching. To avoid duplicate React Query clients and potential version conflicts, stac-react lists `@tanstack/react-query` as a **peer dependency**.
4+
5+
## Why peer dependency?
6+
7+
- Prevents multiple versions of React Query in your app.
8+
- Ensures your app and stac-react share the same QueryClient instance.
9+
- Follows best practices for React libraries that integrate with popular frameworks.
10+
11+
stac-react manages the QueryClient for you by default, but you can provide your own for advanced use cases.
12+
13+
**Important:** If your app uses multiple providers that require a TanStack QueryClient (such as `QueryClientProvider` and `StacApiProvider`), always use the same single QueryClient instance for all providers. This ensures that queries, mutations, and cache are shared across your app and prevents cache fragmentation or duplicate network requests.
14+
15+
**Example:**
16+
17+
```jsx
18+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
19+
import { StacApiProvider } from 'stac-react';
20+
21+
const queryClient = new QueryClient();
22+
23+
function App() {
24+
return (
25+
<QueryClientProvider client={queryClient}>
26+
<StacApiProvider apiUrl="https://my-stac-api.com" queryClient={queryClient}>
27+
{/* ...your app... */}
28+
</StacApiProvider>
29+
</QueryClientProvider>
30+
);
31+
}
32+
```
33+
34+
If you do not pass the same QueryClient instance, each provider will maintain its own cache, which can lead to unexpected behavior.

eslint.config.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ export default defineConfig([
9191
],
9292
// TODO: Consider making these errors in the future (use recommendedTypeChecked rules!).
9393
'@typescript-eslint/no-explicit-any': 'warn',
94-
'@typescript-eslint/no-unsafe-assignment': 'warn',
95-
'@typescript-eslint/no-unsafe-call': 'warn',
96-
'@typescript-eslint/no-unsafe-member-access': 'warn',
97-
'@typescript-eslint/no-unsafe-return': 'warn',
98-
'@typescript-eslint/no-unsafe-argument': 'warn',
94+
'@typescript-eslint/no-unsafe-assignment': 'off',
95+
'@typescript-eslint/no-unsafe-call': 'off',
96+
'@typescript-eslint/no-unsafe-member-access': 'off',
97+
'@typescript-eslint/no-unsafe-return': 'off',
98+
'@typescript-eslint/no-unsafe-argument': 'off',
9999
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
100100
},
101101
},

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
"types": "./dist/index.d.ts",
1515
"source": "./src/index.ts",
1616
"peerDependencies": {
17+
"@tanstack/react-query": ">=4.0.0",
1718
"react": "^19.2.0",
1819
"react-dom": "^19.2.0"
1920
},
2021
"devDependencies": {
22+
"@tanstack/react-query": "^5.90.5",
2123
"@testing-library/dom": "^10.4.1",
2224
"@testing-library/jest-dom": "^6.9.1",
2325
"@testing-library/react": "^16.3.0",

src/context/index.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ import React, { useMemo, useState, useCallback } from 'react';
22
import { StacApiContext } from './context';
33
import type { CollectionsResponse, Item } from '../types/stac';
44
import { GenericObject } from '../types';
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
56

67
import useStacApi from '../hooks/useStacApi';
78

89
type StacApiProviderType = {
910
apiUrl: string;
1011
children: React.ReactNode;
1112
options?: GenericObject;
13+
queryClient?: QueryClient;
1214
};
1315

14-
export function StacApiProvider({ children, apiUrl, options }: StacApiProviderType) {
16+
export function StacApiProvider({ children, apiUrl, options, queryClient }: StacApiProviderType) {
1517
const { stacApi } = useStacApi(apiUrl, options);
1618
const [collections, setCollections] = useState<CollectionsResponse>();
1719
const [items, setItems] = useState(new Map<string, Item>());
@@ -46,5 +48,12 @@ export function StacApiProvider({ children, apiUrl, options }: StacApiProviderTy
4648
[addItem, collections, deleteItem, getItem, stacApi]
4749
);
4850

49-
return <StacApiContext.Provider value={contextValue}>{children}</StacApiContext.Provider>;
51+
const defaultClient = useMemo(() => new QueryClient(), []);
52+
const client: QueryClient = queryClient ?? defaultClient;
53+
54+
return (
55+
<QueryClientProvider client={client}>
56+
<StacApiContext.Provider value={contextValue}>{children}</StacApiContext.Provider>
57+
</QueryClientProvider>
58+
);
5059
}

src/hooks/useCollection.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState, useEffect } from 'react';
1+
import { useMemo } from 'react';
22

33
import type { ApiError, LoadingState } from '../types';
44
import type { Collection } from '../types/stac';
@@ -13,24 +13,22 @@ type StacCollectionHook = {
1313

1414
function useCollection(collectionId: string): StacCollectionHook {
1515
const { collections, state, error: requestError, reload } = useCollections();
16-
const [error, setError] = useState<ApiError>();
17-
18-
useEffect(() => {
19-
setError(requestError);
20-
}, [requestError]);
2116

2217
const collection = useMemo(() => {
23-
const coll = collections?.collections.find(({ id }) => id === collectionId);
24-
if (!coll) {
25-
setError({
26-
status: 404,
27-
statusText: 'Not found',
28-
detail: 'Collection does not exist',
29-
});
30-
}
31-
return coll;
18+
return collections?.collections.find(({ id }) => id === collectionId);
3219
}, [collectionId, collections]);
3320

21+
// Determine error: prefer requestError, else local 404 if collection not found
22+
const error: ApiError | undefined = requestError
23+
? requestError
24+
: !collection && collections
25+
? {
26+
status: 404,
27+
statusText: 'Not found',
28+
detail: 'Collection does not exist',
29+
}
30+
: undefined;
31+
3432
return {
3533
collection,
3634
state,

src/hooks/useCollections.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useCallback, useEffect, useState, useMemo } from 'react';
1+
import { useEffect, useState, useMemo } from 'react';
2+
import { useQuery } from '@tanstack/react-query';
23
import { type ApiError, type LoadingState } from '../types';
34
import type { CollectionsResponse } from '../types/stac';
45
import debounce from '../utils/debounce';
@@ -12,38 +13,69 @@ type StacCollectionsHook = {
1213
};
1314

1415
function useCollections(): StacCollectionsHook {
15-
const { stacApi, collections, setCollections } = useStacApiContext();
16+
const { stacApi, setCollections } = useStacApiContext();
1617
const [state, setState] = useState<LoadingState>('IDLE');
17-
const [error, setError] = useState<ApiError>();
1818

19-
const _getCollections = useCallback(() => {
20-
if (stacApi) {
21-
setState('LOADING');
19+
const fetchCollections = async (): Promise<CollectionsResponse> => {
20+
if (!stacApi) throw new Error('No STAC API configured');
21+
const response: Response = await stacApi.getCollections();
22+
if (!response.ok) {
23+
let detail;
24+
try {
25+
detail = await response.json();
26+
} catch {
27+
detail = await response.text();
28+
}
29+
30+
const err = Object.assign(new Error(response.statusText), {
31+
status: response.status,
32+
statusText: response.statusText,
33+
detail,
34+
});
35+
throw err;
36+
}
37+
return await response.json();
38+
};
39+
40+
const {
41+
data: collections,
42+
error,
43+
isLoading,
44+
isFetching,
45+
refetch,
46+
} = useQuery<CollectionsResponse, ApiError>({
47+
queryKey: ['collections'],
48+
queryFn: fetchCollections,
49+
enabled: !!stacApi,
50+
retry: false,
51+
});
2252

23-
stacApi
24-
.getCollections()
25-
.then((response: Response) => response.json())
26-
.then(setCollections)
27-
.catch((err: unknown) => {
28-
setError(err as ApiError);
29-
setCollections(undefined);
30-
})
31-
.finally(() => setState('IDLE'));
53+
// Sync collections with context
54+
// This preserves the previous logic for consumers and tests
55+
useEffect(() => {
56+
if (collections) {
57+
setCollections(collections);
58+
} else if (error) {
59+
setCollections(undefined);
3260
}
33-
}, [setCollections, stacApi]);
34-
const getCollections = useMemo(() => debounce(_getCollections), [_getCollections]);
61+
}, [collections, error, setCollections]);
62+
63+
const reload = useMemo(() => debounce(refetch), [refetch]);
3564

3665
useEffect(() => {
37-
if (stacApi && !error && !collections) {
38-
getCollections();
66+
// Map TanStack Query loading states to previous LoadingState type
67+
if (isLoading || isFetching) {
68+
setState('LOADING');
69+
} else {
70+
setState('IDLE');
3971
}
40-
}, [getCollections, stacApi, collections, error]);
72+
}, [isLoading, isFetching]);
4173

4274
return {
4375
collections,
44-
reload: getCollections,
76+
reload,
4577
state,
46-
error,
78+
error: error as ApiError,
4779
};
4880
}
4981

src/hooks/useStacApi.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import useCollections from './useCollections';
44
import wrapper from './wrapper';
55

66
describe('useStacApi', () => {
7-
beforeEach(() => fetch.resetMocks());
8-
it('initilises StacAPI', async () => {
7+
beforeEach(() => {
8+
fetch.resetMocks();
9+
});
10+
11+
it('initializes StacAPI', async () => {
912
fetch
1013
.mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' })
1114
.mockResponseOnce(JSON.stringify({ data: '12345' }));
@@ -16,7 +19,7 @@ describe('useStacApi', () => {
1619
);
1720
});
1821

19-
it('initilises StacAPI with redirect URL', async () => {
22+
it('initializes StacAPI with redirect URL', async () => {
2023
fetch
2124
.mockResponseOnce(JSON.stringify({ links: [] }), {
2225
url: 'https://fake-stac-api.net/redirect/',

0 commit comments

Comments
 (0)