Skip to content

Commit 0672c4e

Browse files
committed
perf: Improve performance of react hook
1 parent 249dff9 commit 0672c4e

File tree

9 files changed

+101
-110
lines changed

9 files changed

+101
-110
lines changed

src/inflight-manager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,16 @@ const inFlight: Map<string, InFlightItem> = new Map();
4141
* @param {number} dedupeTime - Deduplication time in milliseconds.
4242
* @param {boolean} isCancellable - If true, then the previous request with same configuration should be aborted.
4343
* @param {boolean} isTimeoutEnabled - Whether timeout is enabled.
44-
* @returns {Promise<AbortController>} - A promise that resolves to an AbortController.
44+
* @returns {AbortController} - A promise that resolves to an AbortController.
4545
*/
46-
export async function markInFlight(
46+
export function markInFlight(
4747
key: string | null,
4848
url: string,
4949
timeout: number | undefined,
5050
dedupeTime: number,
5151
isCancellable: boolean,
5252
isTimeoutEnabled: boolean,
53-
): Promise<AbortController> {
53+
): AbortController {
5454
if (!key) {
5555
return new AbortController();
5656
}

src/react/index.ts

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,11 @@ export function useFetcher<
115115
);
116116
const dedupeTime = config.dedupeTime ?? DEFAULT_DEDUPE_TIME_MS;
117117
const cacheTime = config.cacheTime || INFINITE_CACHE_TIME;
118+
const shouldTriggerOnMount = config.immediate ?? true;
118119

119120
const currentValuesRef = useRef(DEFAULT_REF);
120121
currentValuesRef.current = [url, config, cacheKey];
121122

122-
const shouldTriggerOnMount = useMemo(
123-
() => (config.immediate === undefined ? true : config.immediate),
124-
[config.immediate],
125-
);
126-
127123
// Attempt to get the cached response immediately and if not available, return null
128124
const getSnapshot = useCallback(() => {
129125
const cached = getCache<ResponseData, RequestBody, QueryParams, PathParams>(
@@ -132,8 +128,8 @@ export function useFetcher<
132128

133129
// Only throw for Suspense if we're in 'reject' mode and have no data
134130
if (
135-
cacheKey &&
136131
config.strategy === 'reject' &&
132+
cacheKey &&
137133
(!cached || (!cached.data && !cached.error))
138134
) {
139135
const pendingPromise = getInFlightPromise(cacheKey, dedupeTime);
@@ -143,7 +139,15 @@ export function useFetcher<
143139
}
144140
}
145141

146-
return cached;
142+
return (
143+
cached ??
144+
(DEFAULT_RESULT as unknown as FetchResponse<
145+
ResponseData,
146+
RequestBody,
147+
QueryParams,
148+
PathParams
149+
>)
150+
);
147151
}, [cacheKey]);
148152

149153
// Subscribe to cache updates for the specific cache key
@@ -179,19 +183,9 @@ export function useFetcher<
179183
[cacheKey, shouldTriggerOnMount, url, dedupeTime, cacheTime],
180184
);
181185

182-
const state =
183-
useSyncExternalStore<FetchResponse<
184-
ResponseData,
185-
RequestBody,
186-
QueryParams,
187-
PathParams
188-
> | null>(doSubscribe, getSnapshot, getSnapshot) ||
189-
(DEFAULT_RESULT as unknown as FetchResponse<
190-
ResponseData,
191-
RequestBody,
192-
QueryParams,
193-
PathParams
194-
>);
186+
const state = useSyncExternalStore<
187+
FetchResponse<ResponseData, RequestBody, QueryParams, PathParams>
188+
>(doSubscribe, getSnapshot, getSnapshot);
195189

196190
const refetch = useCallback(
197191
async (forceRefresh = true) => {
@@ -245,15 +239,16 @@ export function useFetcher<
245239
[cacheKey],
246240
);
247241

248-
const isUnresolved = !state.data && !state.error;
249-
const isFetching = state.isFetching || false;
242+
const data = state.data;
243+
const isUnresolved = !data && !state.error;
244+
const isFetching = state.isFetching;
250245
const isLoading =
251246
!!url && (isFetching || (isUnresolved && shouldTriggerOnMount));
252247

253248
// Consumers always destructure the return value and use the fields directly, so
254249
// memoizing the object doesn't change rerender behavior nor improve any performance here
255250
return {
256-
data: state.data,
251+
data,
257252
error: state.error,
258253
config: state.config,
259254
headers: state.headers,

src/request-handler.ts

Lines changed: 64 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,32 @@ import { withPolling } from './polling-handler';
3232
import { notifySubscribers } from './pubsub-manager';
3333
import { addRevalidator } from './revalidator-manager';
3434

35+
const inFlightResponse = {
36+
isFetching: true,
37+
data: null,
38+
error: null,
39+
};
40+
3541
/**
36-
* Request function to make HTTP requests with the provided URL and configuration.
42+
* Sends an HTTP request to the specified URL using the provided configuration and returns a typed response.
3743
*
38-
* @param {string} url - Request URL
39-
* @param {RequestConfig} reqConfig - Request config passed when making the request
40-
* @throws {ResponseError} If the request fails or is cancelled
41-
* @returns {Promise<FetchResponse<ResponseData, RequestBody, QueryParams, PathParams>>} Response Data
44+
* @typeParam ResponseData - The expected shape of the response data. Defaults to `DefaultResponse`.
45+
* @typeParam RequestBody - The type of the request payload/body. Defaults to `DefaultPayload`.
46+
* @typeParam QueryParams - The type of the query parameters. Defaults to `DefaultParams`.
47+
* @typeParam PathParams - The type of the path parameters. Defaults to `DefaultUrlParams`.
48+
*
49+
* @param url - The endpoint URL to which the request will be sent.
50+
* @param config - Optional configuration object for the request, including headers, method, body, query, and path parameters.
51+
*
52+
* @returns A promise that resolves to a `FetchResponse` containing the typed response data and request metadata.
53+
*
54+
* @example
55+
* ```typescript
56+
* const { data } = await fetchf<UserData>('/api/user', { method: 'GET' });
57+
* console.log(data);
58+
* ```
4259
*/
43-
export async function request<
60+
export async function fetchf<
4461
ResponseData = DefaultResponse,
4562
RequestBody = DefaultPayload,
4663
QueryParams = DefaultParams,
@@ -55,7 +72,9 @@ export async function request<
5572
> | null = null,
5673
): Promise<FetchResponse<ResponseData, RequestBody, QueryParams, PathParams>> {
5774
const sanitizedReqConfig = reqConfig ? sanitizeObject(reqConfig) : {};
58-
const mergedConfig = mergeConfigs(defaultConfig, sanitizedReqConfig);
75+
const mergedConfig = reqConfig
76+
? mergeConfigs(defaultConfig, sanitizedReqConfig)
77+
: { ...defaultConfig };
5978
const fetcherConfig = buildConfig(url, mergedConfig);
6079

6180
let response: FetchResponse<
@@ -68,26 +87,28 @@ export async function request<
6887
const {
6988
timeout,
7089
cancellable,
90+
cacheKey,
7191
dedupeTime,
7292
cacheTime,
73-
cacheKey,
7493
revalidateOnFocus,
7594
revalidateOnReconnect,
7695
pollingInterval = 0,
7796
} = mergedConfig;
7897

79-
let _cacheKey: string | null = null;
80-
81-
// Generate cache key if required
82-
if (
98+
const needsCacheKey = !!(
8399
cacheKey ||
84-
cacheTime ||
100+
timeout ||
85101
dedupeTime ||
102+
cacheTime ||
86103
cancellable ||
87-
timeout ||
88104
revalidateOnFocus ||
89105
revalidateOnReconnect
90-
) {
106+
);
107+
108+
let _cacheKey: string | null = null;
109+
110+
// Generate cache key if required
111+
if (needsCacheKey) {
91112
_cacheKey = generateCacheKey(fetcherConfig);
92113
}
93114

@@ -132,7 +153,16 @@ export async function request<
132153
>;
133154

134155
// The actual request logic as a function (one poll attempt, with retries)
135-
const doRequestOnce = async () => {
156+
const doRequestOnce = async (isStaleRevalidation = false) => {
157+
// If cache key is specified, we will handle optimistic updates
158+
// and mark the request as in-flight, so to catch "fetching" state.
159+
// This is useful for Optimistic UI updates (e.g., showing loading spinners).
160+
if (_cacheKey && !isStaleRevalidation) {
161+
setCache(_cacheKey, inFlightResponse);
162+
163+
notifySubscribers(_cacheKey, inFlightResponse);
164+
}
165+
136166
let attempt = 0;
137167
let waitTime: number = delay || 0;
138168
const _retries = retries > 0 ? retries : 0;
@@ -142,7 +172,7 @@ export async function request<
142172
const url = fetcherConfig.url as string;
143173

144174
// Add the request to the queue. Make sure to handle deduplication, cancellation, timeouts in accordance to retry settings
145-
const controller = await markInFlight(
175+
const controller = markInFlight(
146176
_cacheKey,
147177
url,
148178
timeout,
@@ -397,32 +427,18 @@ export async function request<
397427
);
398428
};
399429

400-
// If cache key is specified, wrap the request with in-flight management
401-
const doRequestWithInFlight = _cacheKey
402-
? async () => {
403-
// Optimistic Updates: Reflect that a fetch is happening, so to catch "fetching" state. This can help e.g. with UI updates (e.g., showing loading spinners).
404-
const inFlightResponse = {
405-
isFetching: true,
406-
data: null,
407-
error: null,
408-
headers: null,
409-
};
410-
setCache(_cacheKey, inFlightResponse);
411-
412-
notifySubscribers(_cacheKey, inFlightResponse);
413-
414-
return doRequestOnce();
415-
}
416-
: doRequestOnce;
417-
418430
// If polling is enabled, use withPolling to handle the request
419-
const doRequestPromise = withPolling(
420-
doRequestWithInFlight,
421-
pollingInterval,
422-
mergedConfig.shouldStopPolling,
423-
mergedConfig.maxPollingAttempts,
424-
mergedConfig.pollingDelay,
425-
);
431+
const doRequestPromise = pollingInterval
432+
? withPolling<
433+
FetchResponse<ResponseData, RequestBody, QueryParams, PathParams>
434+
>(
435+
doRequestOnce,
436+
pollingInterval,
437+
mergedConfig.shouldStopPolling,
438+
mergedConfig.maxPollingAttempts,
439+
mergedConfig.pollingDelay,
440+
)
441+
: doRequestOnce();
426442

427443
// If deduplication is enabled, store the in-flight promise immediately
428444
if (_cacheKey) {
@@ -432,7 +448,7 @@ export async function request<
432448

433449
addRevalidator(
434450
_cacheKey,
435-
doRequestWithInFlight,
451+
doRequestOnce,
436452
undefined,
437453
mergedConfig.staleTime,
438454
doRequestOnce,
@@ -450,44 +466,23 @@ export async function request<
450466
* @param {ResponseError} error Error instance
451467
* @returns {boolean} True if request is aborted
452468
*/
453-
const isRequestCancelled = (error: ResponseError): boolean => {
469+
function isRequestCancelled(error: ResponseError): boolean {
454470
return error.name === ABORT_ERROR || error.name === CANCELLED_ERROR;
455-
};
471+
}
456472

457473
/**
458474
* Logs messages or errors using the configured logger's `warn` method.
459475
*
460476
* @param {RequestConfig} reqConfig - Request config passed when making the request
461477
* @param {...(string | ResponseError<any>)} args - Messages or errors to log.
462478
*/
463-
const logger = (
479+
function logger(
464480
reqConfig: RequestConfig,
465481
...args: (string | ResponseError)[]
466-
): void => {
482+
): void {
467483
const logger = reqConfig.logger;
468484

469485
if (logger && logger.warn) {
470486
logger.warn(...args);
471487
}
472-
};
473-
474-
/**
475-
* Sends an HTTP request to the specified URL using the provided configuration and returns a typed response.
476-
*
477-
* @typeParam ResponseData - The expected shape of the response data. Defaults to `DefaultResponse`.
478-
* @typeParam RequestBody - The type of the request payload/body. Defaults to `DefaultPayload`.
479-
* @typeParam QueryParams - The type of the query parameters. Defaults to `DefaultParams`.
480-
* @typeParam PathParams - The type of the path parameters. Defaults to `DefaultUrlParams`.
481-
*
482-
* @param url - The endpoint URL to which the request will be sent.
483-
* @param config - Optional configuration object for the request, including headers, method, body, query, and path parameters.
484-
*
485-
* @returns A promise that resolves to a `FetchResponse` containing the typed response data and request metadata.
486-
*
487-
* @example
488-
* ```typescript
489-
* const { data } = await fetchf<UserData>('/api/user', { method: 'GET' });
490-
* console.log(data);
491-
* ```
492-
*/
493-
export const fetchf = request;
488+
}

src/response-parser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export const prepareResponse = <
114114
if (!response) {
115115
return {
116116
ok: false,
117+
isFetching: false,
117118
// Enhance the response with extra information
118119
error,
119120
data: defaultResponse ?? null,
@@ -171,13 +172,15 @@ export const prepareResponse = <
171172
data,
172173
headers,
173174
config,
175+
isFetching: false,
174176
};
175177
}
176178

177179
// If it's a custom fetcher, and it does not return any Response instance, it may have its own internal handler
178180
if (isObject(response)) {
179181
response.error = error;
180182
response.headers = headers;
183+
response.isFetching = false;
181184
}
182185

183186
return response;

src/revalidator-manager.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { addTimeout, removeTimeout } from './timeout-wheel';
2020
import { FetchResponse } from './types';
2121
import { isBrowser, noop, timeNow } from './utils';
2222

23-
export type RevalidatorFn = () => Promise<FetchResponse | null>;
23+
export type RevalidatorFn = (
24+
isStaleRevalidation?: boolean,
25+
) => Promise<FetchResponse | null>;
2426

2527
type EventType = 'focus' | 'online';
2628

@@ -73,7 +75,7 @@ export function revalidateAll(
7375
const revalidator = isStaleRevalidation ? entry[4] : entry[0];
7476

7577
if (revalidator) {
76-
Promise.resolve(revalidator()).catch(noop);
78+
Promise.resolve(revalidator(isStaleRevalidation)).catch(noop);
7779
}
7880
});
7981
}
@@ -105,7 +107,7 @@ export async function revalidate<T = unknown>(
105107

106108
// If no revalidator function is registered, return null
107109
if (revalidator) {
108-
return await revalidator();
110+
return await revalidator(isStaleRevalidation);
109111
}
110112
}
111113

src/types/request-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export interface ExtendedResponse<
8686
> | null;
8787
headers: HeadersObject & HeadersInit;
8888
config: RequestConfig<ResponseData, QueryParams, PathParams, RequestBody>;
89-
isFetching?: boolean;
89+
isFetching: boolean;
9090
}
9191

9292
/**

0 commit comments

Comments
 (0)