Skip to content

Commit 346ef24

Browse files
phryneasmsutkowski
andauthored
split query hooks, add sub-selector (#106)
Co-authored-by: Matt Sutkowski <[email protected]>
1 parent 3e29c4b commit 346ef24

14 files changed

+581
-74
lines changed

examples/react/src/features/posts/PostsManager.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useState } from 'react';
1+
import React, { useCallback, useState } from 'react';
2+
import { useEffect } from 'react';
23
import { useDispatch, useSelector } from 'react-redux';
34
import { Route, Switch, useHistory } from 'react-router-dom';
45
import {
@@ -7,6 +8,7 @@ import {
78
useGetPostsQuery,
89
useLoginMutation,
910
useGetErrorProneQuery,
11+
postApi,
1012
} from '../../app/services/posts';
1113
import { selectIsAuthenticated, logout } from '../auth/authSlice';
1214
import { PostDetail } from './PostDetail';
@@ -71,6 +73,51 @@ const PostList = () => {
7173
);
7274
};
7375

76+
const PostFromUseQueryStateSubSelector = () => {
77+
const [count, setCount] = useState(0);
78+
const id = 5;
79+
const idAsString = String(id);
80+
81+
const post = postApi.endpoints.getPosts.useQueryState(undefined, {
82+
subSelector: ({ data }) => data?.find((entry) => entry.id === id),
83+
});
84+
85+
useEffect(() => {
86+
setCount((prev) => prev + 1);
87+
}, [post]);
88+
89+
return (
90+
<div>
91+
<h3>
92+
This won't render a post until `id: {idAsString}` exists! <small>(render count: {count})</small>
93+
</h3>
94+
{post ? <div>{JSON.stringify(post)}</div> : <div>waiting to see id: {idAsString}</div>}
95+
</div>
96+
);
97+
};
98+
99+
const PostFromUseQuerySelectorSubSelector = () => {
100+
const [count, setCount] = useState(0);
101+
const id = 6;
102+
const idAsString = String(id);
103+
const { refetch, post } = postApi.useGetPostsQuery(undefined, {
104+
subSelector: ({ data }) => ({ post: data?.find((entry) => entry.id === id) }),
105+
});
106+
107+
useEffect(() => {
108+
setCount((prev) => prev + 1);
109+
}, [post]);
110+
111+
return (
112+
<div>
113+
<h3>
114+
This won't render a post until `id: {idAsString}` exists! <small>(render count: {count})</small>
115+
</h3>
116+
{post ? <div>{JSON.stringify(post)}</div> : <div>waiting to see id: {idAsString}</div>}
117+
</div>
118+
);
119+
};
120+
74121
export const PostsManager = () => {
75122
const [login] = useLoginMutation();
76123
const [initRetries, setInitRetries] = useState(false);
@@ -102,6 +149,14 @@ export const PostsManager = () => {
102149
<Switch>
103150
<Route path="/posts/:id" component={PostDetail} />
104151
</Switch>
152+
153+
<div style={{ marginTop: 0, paddingTop: 20, borderTop: '1px solid #eee' }}>
154+
If you look, these components will only rerender when the the selector criteria is met, or when the actual
155+
underlying data changes. Try adding a few new posts to see this behavior.
156+
<PostFromUseQueryStateSubSelector />
157+
<hr />
158+
<PostFromUseQuerySelectorSubSelector />
159+
</div>
105160
</div>
106161
</div>
107162
</div>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
"cross-fetch": "^3.0.6",
107107
"eslint-plugin-prettier": "^3.1.4",
108108
"husky": "^4.3.0",
109-
"msw": "^0.22.3",
109+
"msw": "^0.24.2",
110110
"node-fetch": "^2.6.1",
111111
"prettier": "^2.2.0",
112112
"react": "^16.14.0 || 17.0.0",

src/buildHooks.ts

Lines changed: 118 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,33 @@
1-
import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit';
1+
import { AnyAction, createSelector, ThunkDispatch } from '@reduxjs/toolkit';
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3-
import { useDispatch, useSelector, batch } from 'react-redux';
3+
import { useDispatch, useSelector, batch, shallowEqual } from 'react-redux';
44
import {
55
MutationSubState,
66
QueryStatus,
77
QuerySubState,
88
RequestStatusFlags,
99
SubscriptionOptions,
1010
QueryKeys,
11+
RootState,
1112
} from './apiState';
12-
import {
13-
EndpointDefinitions,
14-
MutationDefinition,
15-
QueryDefinition,
16-
QueryArgFrom,
17-
ResultTypeFrom,
18-
} from './endpointDefinitions';
19-
import { skipSelector } from './buildSelectors';
13+
import { EndpointDefinitions, MutationDefinition, QueryDefinition, QueryArgFrom } from './endpointDefinitions';
14+
import { QueryResultSelectorResult, skipSelector } from './buildSelectors';
2015
import { QueryActionCreatorResult, MutationActionCreatorResult } from './buildActionMaps';
2116
import { useShallowStableValue } from './utils';
2217
import { Api, ApiEndpointMutation, ApiEndpointQuery } from './apiTypes';
23-
import { Id, Override } from './tsHelpers';
18+
import { Id, NoInfer, Override } from './tsHelpers';
2419

25-
interface QueryHookOptions extends SubscriptionOptions {
26-
skip?: boolean;
27-
refetchOnMountOrArgChange?: boolean | number;
20+
interface QueryHooks<Definition extends QueryDefinition<any, any, any, any, any>> {
21+
useQuery: UseQuery<Definition>;
22+
useQuerySubscription: UseQuerySubscription<Definition>;
23+
useQueryState: UseQueryState<Definition>;
2824
}
2925

3026
declare module './apiTypes' {
3127
export interface ApiEndpointQuery<
3228
Definition extends QueryDefinition<any, any, any, any, any>,
3329
Definitions extends EndpointDefinitions
34-
> {
35-
useQuery: QueryHook<Definition>;
36-
}
30+
> extends QueryHooks<Definition> {}
3731

3832
export interface ApiEndpointMutation<
3933
Definition extends MutationDefinition<any, any, any, any, any>,
@@ -43,12 +37,45 @@ declare module './apiTypes' {
4337
}
4438
}
4539

46-
export type QueryHook<D extends QueryDefinition<any, any, any, any>> = (
40+
export type UseQuery<D extends QueryDefinition<any, any, any, any>> = <R = UseQueryStateDefaultResult<D>>(
4741
arg: QueryArgFrom<D>,
48-
options?: QueryHookOptions
49-
) => QueryHookResult<D>;
42+
options?: UseQuerySubscriptionOptions & UseQueryStateOptions<D, R>
43+
) => UseQueryStateResult<D, R> & ReturnType<UseQuerySubscription<D>>;
5044

51-
type BaseQueryHookResult<D extends QueryDefinition<any, any, any, any>> = QuerySubState<D> & {
45+
interface UseQuerySubscriptionOptions extends SubscriptionOptions {
46+
skip?: boolean;
47+
refetchOnMountOrArgChange?: boolean | number;
48+
}
49+
50+
export type UseQuerySubscription<D extends QueryDefinition<any, any, any, any>> = (
51+
arg: QueryArgFrom<D>,
52+
options?: UseQuerySubscriptionOptions
53+
) => Pick<QueryActionCreatorResult<D>, 'refetch'>;
54+
55+
export type QueryStateSelector<R, D extends QueryDefinition<any, any, any, any>> = (
56+
state: QueryResultSelectorResult<D>,
57+
lastResult: R | undefined,
58+
defaultQueryStateSelector: DefaultQueryStateSelector<D>
59+
) => R;
60+
61+
export type DefaultQueryStateSelector<D extends QueryDefinition<any, any, any, any>> = (
62+
state: QueryResultSelectorResult<D>,
63+
lastResult: Pick<UseQueryStateDefaultResult<D>, 'data'>
64+
) => UseQueryStateDefaultResult<D>;
65+
66+
export type UseQueryState<D extends QueryDefinition<any, any, any, any>> = <R = UseQueryStateDefaultResult<D>>(
67+
arg: QueryArgFrom<D>,
68+
options?: UseQueryStateOptions<D, R>
69+
) => UseQueryStateResult<D, R>;
70+
71+
export type UseQueryStateOptions<D extends QueryDefinition<any, any, any, any>, R> = {
72+
skip?: boolean;
73+
subSelector?: QueryStateSelector<R, D>;
74+
};
75+
76+
export type UseQueryStateResult<_ extends QueryDefinition<any, any, any, any>, R> = NoInfer<R>;
77+
78+
type UseQueryStateBaseResult<D extends QueryDefinition<any, any, any, any>> = QuerySubState<D> & {
5279
/**
5380
* Query has not started yet.
5481
*/
@@ -69,17 +96,17 @@ type BaseQueryHookResult<D extends QueryDefinition<any, any, any, any>> = QueryS
6996
* Query is currently in "error" state.
7097
*/
7198
isError: false;
72-
} & Pick<QueryActionCreatorResult<D>, 'refetch'>;
99+
};
73100

74-
type QueryHookResult<D extends QueryDefinition<any, any, any, any>> = Id<
75-
| Override<Extract<BaseQueryHookResult<D>, { status: QueryStatus.uninitialized }>, { isUninitialized: true }>
101+
type UseQueryStateDefaultResult<D extends QueryDefinition<any, any, any, any>> = Id<
102+
| Override<Extract<UseQueryStateBaseResult<D>, { status: QueryStatus.uninitialized }>, { isUninitialized: true }>
76103
| Override<
77-
BaseQueryHookResult<D>,
104+
UseQueryStateBaseResult<D>,
78105
| { isLoading: true; isFetching: boolean; data: undefined }
79106
| ({ isSuccess: true; isFetching: boolean; error: undefined } & Required<
80-
Pick<BaseQueryHookResult<D>, 'data' | 'fulfilledTimeStamp'>
107+
Pick<UseQueryStateBaseResult<D>, 'data' | 'fulfilledTimeStamp'>
81108
>)
82-
| ({ isError: true } & Required<Pick<BaseQueryHookResult<D>, 'error'>>)
109+
| ({ isError: true } & Required<Pick<UseQueryStateBaseResult<D>, 'error'>>)
83110
>
84111
>;
85112

@@ -96,12 +123,26 @@ export type PrefetchOptions =
96123
ifOlderThan?: false | number;
97124
};
98125

126+
const defaultQueryStateSelector: DefaultQueryStateSelector<any> = (currentState, lastResult) => {
127+
// data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args
128+
const data = (currentState.isSuccess ? currentState.data : lastResult?.data) ?? currentState.data;
129+
130+
// isFetching = true any time a request is in flight
131+
const isFetching = currentState.isLoading;
132+
// isLoading = true only when loading while no data is present yet (initial load with no data in the cache)
133+
const isLoading = !data && isFetching;
134+
// isSuccess = true when data is present
135+
const isSuccess = currentState.isSuccess || (isFetching && !!data);
136+
137+
return { ...currentState, data, isFetching, isLoading, isSuccess } as UseQueryStateDefaultResult<any>;
138+
};
139+
99140
export function buildHooks<Definitions extends EndpointDefinitions>({
100141
api,
101142
}: {
102143
api: Api<any, Definitions, any, string>;
103144
}) {
104-
return { buildQueryHook, buildMutationHook, usePrefetch };
145+
return { buildQueryHooks, buildMutationHook, usePrefetch };
105146

106147
function usePrefetch<EndpointName extends QueryKeys<Definitions>>(
107148
endpointName: EndpointName,
@@ -117,24 +158,20 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
117158
);
118159
}
119160

120-
function buildQueryHook(name: string): QueryHook<any> {
121-
return (
161+
function buildQueryHooks(name: string): QueryHooks<any> {
162+
const useQuerySubscription: UseQuerySubscription<any> = (
122163
arg: any,
123164
{ refetchOnReconnect, refetchOnFocus, refetchOnMountOrArgChange, skip = false, pollingInterval = 0 } = {}
124165
) => {
125-
const { select, initiate } = api.endpoints[name] as ApiEndpointQuery<
166+
const { initiate } = api.endpoints[name] as ApiEndpointQuery<
126167
QueryDefinition<any, any, any, any, any>,
127168
Definitions
128169
>;
129170
const dispatch = useDispatch<ThunkDispatch<any, any, AnyAction>>();
130171
const stableArg = useShallowStableValue(arg);
131172

132-
const lastData = useRef<ResultTypeFrom<Definitions[string]> | undefined>();
133173
const promiseRef = useRef<QueryActionCreatorResult<any>>();
134174

135-
const querySelector = useMemo(() => select(skip ? skipSelector : stableArg), [select, skip, stableArg]);
136-
const currentState = useSelector(querySelector);
137-
138175
useEffect(() => {
139176
if (skip) {
140177
return;
@@ -169,33 +206,55 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
169206
return () => void promiseRef.current?.unsubscribe();
170207
}, []);
171208

209+
return useMemo(
210+
() => ({
211+
refetch: () => void promiseRef.current?.refetch(),
212+
}),
213+
[]
214+
);
215+
};
216+
217+
const useQueryState: UseQueryState<any> = (
218+
arg: any,
219+
{ skip = false, subSelector = defaultQueryStateSelector as QueryStateSelector<any, any> } = {}
220+
) => {
221+
const { select } = api.endpoints[name] as ApiEndpointQuery<QueryDefinition<any, any, any, any, any>, Definitions>;
222+
const stableArg = useShallowStableValue(arg);
223+
224+
const lastValue = useRef<any>();
225+
226+
const querySelector = useMemo(
227+
() =>
228+
createSelector(
229+
[select(skip ? skipSelector : stableArg), (_: any, lastResult: any) => lastResult],
230+
(subState, lastResult) => subSelector(subState, lastResult, defaultQueryStateSelector)
231+
),
232+
[select, skip, stableArg, subSelector]
233+
);
234+
235+
const currentState = useSelector(
236+
(state: RootState<Definitions, any, any>) => querySelector(state, lastValue.current),
237+
shallowEqual
238+
);
239+
172240
useEffect(() => {
173-
if (currentState.status === QueryStatus.fulfilled) {
174-
lastData.current = currentState.data;
175-
}
241+
lastValue.current = currentState;
176242
}, [currentState]);
177243

178-
const refetch = useCallback(() => void promiseRef.current?.refetch(), []);
179-
180-
// data is the last known good request result
181-
const data = currentState.status === 'fulfilled' ? currentState.data : lastData.current;
182-
183-
const isPending = currentState.status === QueryStatus.pending;
184-
// isLoading = true only when loading while no data is present yet (initial load)
185-
const isLoading: any = !lastData.current && isPending;
186-
// isFetching = true any time a request is in flight
187-
const isFetching: any = isPending;
188-
// isSuccess = true when data is present
189-
const isSuccess: any = currentState.status === 'fulfilled' || (isPending && !!data);
190-
191-
return useMemo(() => ({ ...currentState, data, isFetching, isLoading, isSuccess, refetch }), [
192-
currentState,
193-
data,
194-
isFetching,
195-
isLoading,
196-
isSuccess,
197-
refetch,
198-
]);
244+
return currentState;
245+
};
246+
247+
return {
248+
useQueryState,
249+
useQuerySubscription,
250+
useQuery(arg, options) {
251+
const querySubscriptionResults = useQuerySubscription(arg, options);
252+
const queryStateResults = useQueryState(arg, options);
253+
return useMemo(() => ({ ...queryStateResults, ...querySubscriptionResults }), [
254+
queryStateResults,
255+
querySubscriptionResults,
256+
]);
257+
},
199258
};
200259
}
201260

src/buildSelectors.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ declare module './apiTypes' {
4343

4444
type QueryResultSelector<Definition extends QueryDefinition<any, any, any, any>, RootState> = (
4545
queryArg: QueryArgFrom<Definition> | typeof skipSelector
46-
) => (state: RootState) => QuerySubState<Definition> & RequestStatusFlags;
46+
) => (state: RootState) => QueryResultSelectorResult<Definition>;
47+
48+
export type QueryResultSelectorResult<
49+
Definition extends QueryDefinition<any, any, any, any>
50+
> = QuerySubState<Definition> & RequestStatusFlags;
4751

4852
type MutationResultSelector<Definition extends MutationDefinition<any, any, any, any>, RootState> = (
4953
requestId: string | typeof skipSelector

src/buildSlice.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { MutationThunkArg, QueryThunkArg, ThunkResult } from './buildThunks
1818
import { AssertEntityTypes, calculateProvidedBy, EndpointDefinitions } from './endpointDefinitions';
1919
import { applyPatches, Patch } from 'immer';
2020
import { onFocus, onFocusLost, onOffline, onOnline } from './setupListeners';
21-
import { isDocumentVisible, isOnline } from './utils';
21+
import { isDocumentVisible, isOnline, copyWithStructuralSharing } from './utils';
2222

2323
function updateQuerySubstateIfExists(
2424
state: QueryState<any>,
@@ -96,7 +96,7 @@ export function buildSlice({
9696
updateQuerySubstateIfExists(draft, meta.arg.queryCacheKey, (substate) => {
9797
if (substate.requestId !== meta.requestId) return;
9898
substate.status = QueryStatus.fulfilled;
99-
substate.data = payload.result;
99+
substate.data = copyWithStructuralSharing(substate.data, payload.result);
100100
substate.error = undefined;
101101
substate.fulfilledTimeStamp = payload.fulfilledTimeStamp;
102102
});

0 commit comments

Comments
 (0)