Skip to content

Commit 64a3f94

Browse files
Katarina Antonkaciakmaciak
authored andcommitted
feat(useInfiniteSubscription): add useInfiniteSubscription hook
resolve #55
1 parent 1e83144 commit 64a3f94

File tree

3 files changed

+236
-2
lines changed

3 files changed

+236
-2
lines changed

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export { useSubscription } from './use-subscription';
22
export type { UseSubscriptionOptions } from './use-subscription';
3+
export { useInfiniteSubscription } from './use-infinite-subscription';
4+
export type { UseInfiniteSubscriptionOptions } from './use-infinite-subscription';
35

46
export { eventSource$, fromEventSource } from './helpers/event-source';
57
export type { EventSourceOptions } from './helpers/event-source';

src/use-infinite-subscription.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { useEffect } from 'react';
2+
import { useInfiniteQuery, useQueryClient } from 'react-query';
3+
import type {
4+
QueryKey,
5+
UseInfiniteQueryResult,
6+
PlaceholderDataFunction,
7+
QueryFunctionContext,
8+
InfiniteData,
9+
GetPreviousPageParamFunction,
10+
GetNextPageParamFunction,
11+
} from 'react-query';
12+
import type {
13+
RetryDelayValue,
14+
RetryValue,
15+
} from 'react-query/types/core/retryer';
16+
import { Observable } from 'rxjs';
17+
18+
import { useObservableQueryFn } from './use-observable-query-fn';
19+
import { cleanupSubscription } from './subscription-storage';
20+
21+
export interface UseInfiniteSubscriptionOptions<
22+
TSubscriptionFnData = unknown,
23+
TError = Error,
24+
TData = TSubscriptionFnData,
25+
TSubscriptionKey extends QueryKey = QueryKey
26+
> {
27+
/**
28+
* This function can be set to automatically get the previous cursor for infinite queries.
29+
* The result will also be used to determine the value of `hasPreviousPage`.
30+
*/
31+
getPreviousPageParam?: GetPreviousPageParamFunction<TSubscriptionFnData>;
32+
/**
33+
* This function can be set to automatically get the next cursor for infinite queries.
34+
* The result will also be used to determine the value of `hasNextPage`.
35+
*/
36+
getNextPageParam?: GetNextPageParamFunction<TSubscriptionFnData>;
37+
/**
38+
* Set this to `false` to disable automatic resubscribing when the subscription mounts or changes subscription keys.
39+
* To refetch the subscription, use the `refetch` method returned from the `useSubscription` instance.
40+
* Defaults to `true`.
41+
*/
42+
enabled?: boolean;
43+
/**
44+
* If `false`, failed subscriptions will not retry by default.
45+
* If `true`, failed subscriptions will retry infinitely.
46+
* If set to an integer number, e.g. 3, failed subscriptions will retry until the failed subscription count meets that number.
47+
* If set to a function `(failureCount: number, error: TError) => boolean` failed subscriptions will retry until the function returns false.
48+
*/
49+
retry?: RetryValue<TError>;
50+
/**
51+
* If number, applies delay before next attempt in milliseconds.
52+
* If function, it receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds.
53+
* @see https://react-query.tanstack.com/reference/useQuery
54+
*/
55+
retryDelay?: RetryDelayValue<TError>;
56+
/**
57+
* If set to `false`, the subscription will not be retried on mount if it contains an error.
58+
* Defaults to `true`.
59+
*/
60+
retryOnMount?: boolean;
61+
/**
62+
* This callback will fire if the subscription encounters an error and will be passed the error.
63+
*/
64+
onError?: (error: TError) => void;
65+
/**
66+
* This option can be used to transform or select a part of the data returned by the query function.
67+
*/
68+
select?: (data: InfiniteData<TSubscriptionFnData>) => InfiniteData<TData>;
69+
/**
70+
* If set, this value will be used as the placeholder data for this particular query observer while the subscription is still in the `loading` data and no initialData has been provided.
71+
*/
72+
placeholderData?:
73+
| InfiniteData<TSubscriptionFnData>
74+
| PlaceholderDataFunction<InfiniteData<TSubscriptionFnData>>;
75+
/**
76+
* This function will fire any time the subscription successfully fetches new data or the cache is updated via setQueryData.
77+
*/
78+
onData?: (data: InfiniteData<TData>) => void;
79+
}
80+
81+
export type UseSubscriptionResult<
82+
TData = unknown,
83+
TError = unknown
84+
> = UseInfiniteQueryResult<TData, TError>;
85+
86+
// eslint-disable-next-line @typescript-eslint/ban-types
87+
function inOperator<K extends string, T extends object>(
88+
k: K,
89+
o: T
90+
): o is T & Record<K, unknown> {
91+
return k in o;
92+
}
93+
94+
function isInfiniteData(value: unknown): value is InfiniteData<unknown> {
95+
return (
96+
value &&
97+
typeof value === 'object' &&
98+
inOperator('pages', value) &&
99+
Array.isArray(value.pages) &&
100+
inOperator('pageParams', value) &&
101+
Array.isArray(value.pageParams)
102+
);
103+
}
104+
105+
/**
106+
* React hook based on React Query for managing, caching and syncing observables
107+
* in React with infinite pagination.
108+
*
109+
* @example
110+
* ```tsx
111+
* function ExampleInfiniteSubscription() {
112+
* const {
113+
* data,
114+
* isError,
115+
* error,
116+
* isFetchingNextPage,
117+
* hasNextPage,
118+
* fetchNextPage,
119+
* } = useInfiniteSubscription(
120+
* 'test-key',
121+
* () => stream$,
122+
* {
123+
* getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
124+
* // other options
125+
* }
126+
* );
127+
*
128+
* if (isError) {
129+
* return (
130+
* <div role="alert">
131+
* {error?.message || 'Unknown error'}
132+
* </div>
133+
* );
134+
* }
135+
* return <>
136+
* {data.pages.map((page) => (
137+
* <div key={page.key}>{JSON.stringify(page)}</div>
138+
* ))}
139+
* {isFetchingNextPage && <>Loading...</>}
140+
* {hasNextPage && (
141+
* <button onClick={fetchNextPage}>Load more</button>
142+
* )}
143+
* </>;
144+
* }
145+
* ```
146+
*/
147+
export function useInfiniteSubscription<
148+
TSubscriptionFnData = unknown,
149+
TError = Error,
150+
TData = TSubscriptionFnData,
151+
TSubscriptionKey extends QueryKey = QueryKey
152+
>(
153+
subscriptionKey: TSubscriptionKey,
154+
subscriptionFn: (
155+
context: QueryFunctionContext<TSubscriptionKey>
156+
) => Observable<TSubscriptionFnData>,
157+
options: UseInfiniteSubscriptionOptions<
158+
TSubscriptionFnData,
159+
TError,
160+
TData,
161+
TSubscriptionKey
162+
> = {}
163+
): UseSubscriptionResult<TData, TError> {
164+
const { queryFn, clearErrors } = useObservableQueryFn(
165+
subscriptionFn,
166+
(data, previousData, pageParam): InfiniteData<TSubscriptionFnData> => {
167+
if (!isInfiniteData(previousData)) {
168+
return {
169+
pages: [data],
170+
pageParams: [undefined],
171+
};
172+
}
173+
const pageIndex = previousData.pageParams.findIndex(
174+
(cursor) => pageParam === cursor
175+
);
176+
return {
177+
pages: [
178+
...(previousData.pages.slice(0, pageIndex) as TSubscriptionFnData[]),
179+
data,
180+
...(previousData.pages.slice(pageIndex + 1) as TSubscriptionFnData[]),
181+
],
182+
pageParams: previousData.pageParams,
183+
};
184+
}
185+
);
186+
187+
const queryClient = useQueryClient();
188+
189+
const queryResult = useInfiniteQuery<
190+
TSubscriptionFnData,
191+
TError,
192+
TData,
193+
TSubscriptionKey
194+
>(subscriptionKey, queryFn, {
195+
retry: false,
196+
...options,
197+
staleTime: Infinity,
198+
refetchInterval: undefined,
199+
refetchOnMount: true,
200+
refetchOnWindowFocus: false,
201+
refetchOnReconnect: false,
202+
onSuccess: options.onData,
203+
onError: (error: TError) => {
204+
clearErrors();
205+
options.onError && options.onError(error);
206+
},
207+
});
208+
209+
useEffect(() => {
210+
return function cleanup() {
211+
// Fixes unsubscribe
212+
// We cannot assume that this fn runs for this component.
213+
// It might be a different observer associated to the same query key.
214+
// https://github.com/tannerlinsley/react-query/blob/16b7d290c70639b627d9ada32951d211eac3adc3/src/core/query.ts#L376
215+
216+
const activeObserversCount = queryClient
217+
.getQueryCache()
218+
.find(subscriptionKey)
219+
?.getObserversCount();
220+
221+
if (activeObserversCount === 0) {
222+
cleanupSubscription(queryClient, subscriptionKey);
223+
}
224+
};
225+
}, [queryClient, subscriptionKey]);
226+
227+
return queryResult;
228+
}

src/use-observable-query-fn.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ export function useObservableQueryFn<
2626
subscriptionFn: (
2727
context: QueryFunctionContext<TSubscriptionKey>
2828
) => Observable<TSubscriptionFnData>,
29-
dataUpdater: (data: TSubscriptionFnData, previousData: unknown) => TCacheData
29+
dataUpdater: (
30+
data: TSubscriptionFnData,
31+
previousData: unknown,
32+
pageParam: unknown | undefined
33+
) => TCacheData
3034
): UseObservableQueryFnResult<TSubscriptionFnData, TSubscriptionKey> {
3135
const queryClient = useQueryClient();
3236

@@ -65,7 +69,7 @@ export function useObservableQueryFn<
6569
skip(1),
6670
tap((data) => {
6771
queryClient.setQueryData(queryKey, (previousData) =>
68-
dataUpdater(data, previousData)
72+
dataUpdater(data, previousData, pageParam)
6973
);
7074
}),
7175
catchError((error) => {

0 commit comments

Comments
 (0)