Skip to content

Commit

Permalink
Merge pull request #43 from lifeomic/react-query-v5
Browse files Browse the repository at this point in the history
feat: add suspense query hooks
  • Loading branch information
jkdowdle authored Feb 20, 2024
2 parents 349a52c + 400c84f commit 50f0e5e
Show file tree
Hide file tree
Showing 7 changed files with 754 additions and 31 deletions.
150 changes: 135 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@ const hooks = createAPIHooks<APIEndpoints>({ // <-- Specify your custom type her

export const {
useAPIQuery,
useAPIMutation
useSuspenseAPIQuery,
useInfiniteAPIQuery,
useSuspenseInfiniteAPIQuery,
useAPIMutation,
useCombinedAPIQueries,
useAPICache
useSuspenseCombinedAPIQueries,
useAPICache,
} = hooks;
```

Expand Down Expand Up @@ -190,6 +194,29 @@ const hooks = createAPIHooks<APIEndpoints>({
});
```
### `useSuspenseAPIQuery`
Type-safe wrapper around `useSuspenseQuery` from `react-query`. Be sure to use within a `<React.Suspense />` boundary.
```typescript
const query = useSuspenseAPIQuery(
// First, specify the route.
'GET /messages',
// Then, specify the payload.
{ filter: 'some-filter' },
);
```
The return value of this hook is identical to the behavior of the `react-query` `useSuspenseQuery` hook's return value.
```typescript
const query = useQuery('GET /messages', { filter: 'some-filter' });

query.data; // Message[]
```
Queries are cached using a combination of `route name + payload`. So, in the example above, the query key looks roughly like `['GET /messages', { filter: 'some-filter' }]`.
### `useAPIQuery`
Type-safe wrapper around `useQuery` from `react-query`.
Expand Down Expand Up @@ -220,20 +247,19 @@ if (query.isError) {
query.data; // Message[]
```
Queries are cached using a combination of `route name + payload`. So, in the example above, the query key looks roughly like `['GET /messages', { filter: 'some-filter' }]`.
### `useSuspenseInfiniteAPIQuery`
### `useInfiniteAPIQuery`
Type-safe wrapper around [`useInfiniteQuery`](https://tanstack.com/query/latest/docs/react/reference/useInfiniteQuery) from `react-query` which has a similar api as `useQuery` with a few key differences.
Type-safe wrapper around [`useSuspenseInfiniteQuery`](https://tanstack.com/query/latest/docs/react/reference/useSuspenseInfiniteQuery) from `react-query`
```tsx
const query = useInfiniteAPIQuery(
const query = useSuspenseInfiniteAPIQuery(
'GET /list',
{
// after is the token name in query string for the next page to return.
after: undefined,
},
{
initialPageParam: {},
// passes the pagination token from request body to query string "after"
getNextPageParam: (lastPage) => ({ after: lastPage.next }),
getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }),
Expand Down Expand Up @@ -263,15 +289,46 @@ is required over query string. It may need another queryFn if the pagination tok
}
```
An alternative to using the `getNextPageParam` or `getPreviousPageParam` callback options, the query methods also accept a `pageParam` input.
### `useInfiniteAPIQuery`
```typescript
const lastPage = query?.data?.pages[query.data.pages.length - 1];
query.fetchNextPage({
pageParam: {
after: lastPage?.next,
Type-safe wrapper around [`useInfiniteQuery`](https://tanstack.com/query/latest/docs/react/reference/useInfiniteQuery) from `react-query` which has a similar api as `useQuery` with a few key differences.
```tsx
const query = useInfiniteAPIQuery(
'GET /list',
{
// after is the token name in query string for the next page to return.
after: undefined,
},
});
{
initialPageParam: {},
// passes the pagination token from request body to query string "after"
getNextPageParam: (lastPage) => ({ after: lastPage.next }),
getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }),
},
);

...

<button
onClick={() => {
void query.fetchNextPage();

// Or fetch previous page
// void query.fetchPreviousPage();
}}
/>;
```
The return value of this hook is identical to the behavior of the `react-query` `useInfiniteQuery` hook's return value where `data` holds an array of pages.
When returning `undefined` from `getNextPageParam` it will set `query.hasNextPage` to false, otherwise it will merge the next api request payload with the returned object, likewise for `getPreviousPageParam` and `query.hasPreviousPage`. This is useful to pass pagination token from previous page since the current implementation provides a default `queryFn` assumes such token
is required over query string. It may need another queryFn if the pagination token is managed via headers.
```tsx
{
query.data.pages.flatMap((page) => page.items.map((item) => ...));
}
```
### `useAPIMutation`
Expand All @@ -297,10 +354,73 @@ return (
);
```
### `useCombinedAPIQueries`
### `useSuspenseCombinedAPIQueries`
A helper for combining multiple parallel queries into a single `react-query`-like hook.
Queries performed using this hook are cached independently, just as if they had been performed individually using `useSuspenseAPIQuery`.
```typescript
const query = useSuspenseCombinedAPIQueries(
['GET /messages', { filter: 'some-filter' }],
['GET /messages/:id', { id: 'some-message-id' }],
);

// Here all queries are complete - pending and error states are handled by suspense and error boundaries

query.data; // [Message[], Message]

const [list, message] = query.data;

list; // Message[]
message; // Message
```
#### `isFetching`
Indicates whether _at least one_ query is in the "fetching" state.
#### `isRefetching`
Indicates whether _at least one_ query is in the "refetching" state.
#### `refetchAll()`
A helper function for triggering a refetch of every independent query in the combination.
```typescript
const query = useSuspenseCombinedAPIQueries(
['GET /messages', { filter: 'some-filter' }],
['GET /messages/:id', { id: 'some-message-id' }],
);

// This:
query.refetchAll();

// Is equivalent to:
for (const individualQuery of query.queries) {
void individualQuery.refetch();
}
```
#### `queries`
Provides access to the individual underlying queries.
```typescript
const query = useSuspenseCombinedAPIQueries(
['GET /messages', { filter: 'some-filter' }],
['GET /messages/:id', { id: 'some-message-id' }],
);

query.queries[0].data; // Messages[]
query.queries[1].data; // Message
```
### `useCombinedAPIQueries`
A helper for combining multiple parallel queries into a single `react-query`-like hook. A non-suspense version of `useSuspenseCombinedAPIQueries`.
Queries performed using this hook are cached independently, just as if they had been performed individually using `useAPIQuery`.
```typescript
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"prettier": "^2.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.12",
"semantic-release": "^19.0.5",
"ts-jest": "^29.0.3",
"typescript": "^4.8.4",
Expand Down
70 changes: 57 additions & 13 deletions src/combination.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
DefinedQueryObserverResult,
QueryObserverResult,
UseSuspenseQueryResult,
} from '@tanstack/react-query';

type CombinedQueriesBaseResult = {
Expand Down Expand Up @@ -30,10 +31,10 @@ export type CombinedQueriesDefinedResult<
};
};

export type CombinedQueriesLoadingResult<
export type CombinedQueriesPendingResult<
Queries extends QueryObserverResult[],
> = {
status: 'loading';
status: 'pending';
isPending: true;
isError: false;
data: undefined;
Expand All @@ -53,13 +54,27 @@ export type CombinedQueriesResult<Queries extends QueryObserverResult[]> =
CombinedQueriesBaseResult &
(
| CombinedQueriesDefinedResult<Queries>
| CombinedQueriesLoadingResult<Queries>
| CombinedQueriesPendingResult<Queries>
| CombinedQueriesErrorResult<Queries>
);

export const combineQueries = <Queries extends QueryObserverResult[]>(
export type SuspenseCombinedQueriesResult<
Queries extends QueryObserverResult[],
> = CombinedQueriesBaseResult &
Pick<
UseSuspenseQueryResult<Queries>,
'data' | 'status' | 'isPending' | 'isError'
> & {
queries: {
[Index in keyof Queries]: DefinedQueryObserverResult<
DataOfQuery<Queries[Index]>
>;
};
};

const getBase = <Queries extends QueryObserverResult[]>(
queries: [...Queries],
): CombinedQueriesResult<Queries> => {
): CombinedQueriesBaseResult => {
const base = {
isFetching: queries.some((query) => query.isFetching),
isRefetching: queries.some((query) => query.isRefetching),
Expand All @@ -70,24 +85,32 @@ export const combineQueries = <Queries extends QueryObserverResult[]>(
},
};

if (queries.some((query) => query.status === 'error')) {
return base;
};

export const combineQueries = <Queries extends QueryObserverResult[]>(
queries: [...Queries],
): CombinedQueriesResult<Queries> => {
const base = getBase(queries);

if (queries.some((query) => query.status === 'pending')) {
return {
...base,
status: 'error',
isPending: false,
status: 'pending',
isPending: true,
data: undefined,
isError: true,
isError: false,
queries,
};
}

if (queries.some((query) => query.status === 'pending')) {
if (queries.some((query) => query.status === 'error')) {
return {
...base,
status: 'loading',
isPending: true,
status: 'error',
isPending: false,
data: undefined,
isError: false,
isError: true,
queries,
};
}
Expand All @@ -101,3 +124,24 @@ export const combineQueries = <Queries extends QueryObserverResult[]>(
queries: queries as any,
};
};

export const suspenseCombineQueries = <Queries extends QueryObserverResult[]>(
queries: [...Queries],
): SuspenseCombinedQueriesResult<Queries> => {
const base = getBase(queries);

// Loading and Error states will be handled by suspense and error
// boundaries so unlike the non-suspense version we only need to
// account for the DefinedQueryObserverResult state
return {
...base,
status: 'success',
isPending: false,
data: queries.map((query) => query.data) as any,
isError: false,
// Data is typed as unknown[] because it can't infer it
// correctly - here we cast it as any, in types we give
// it the correct type we expect.
queries: queries as any,
};
};
Loading

0 comments on commit 50f0e5e

Please sign in to comment.