Skip to content

Commit fdcca16

Browse files
committed
perf: Remove reliance on useEffect in React
refactor: rename getCache to getCacheEntry for clarity and update related references fix: update tests to reflect changes in cache management functions and improve accuracy feat: add onComplete utility for benchmark results logging
1 parent 2ee4c02 commit fdcca16

File tree

12 files changed

+179
-109
lines changed

12 files changed

+179
-109
lines changed

src/cache-manager.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function isCacheExpired(timestamp: number, maxStaleTime?: number): boolean {
168168
* @param {number|undefined} cacheTime - Maximum time to cache entry in seconds. 0 or -1 means no expiration.
169169
* @returns {CacheEntry<T> | null} - The cache entry if it exists and is not expired, null otherwise.
170170
*/
171-
export function getCache<T>(
171+
export function getCacheEntry<T>(
172172
key: string,
173173
cacheTime?: number,
174174
): CacheEntry<T> | null {
@@ -246,7 +246,7 @@ export async function mutate<
246246
return null;
247247
}
248248

249-
const cachedResponse = getCache<ResponseData>(key);
249+
const cachedResponse = getCacheEntry<ResponseData>(key);
250250

251251
if (!cachedResponse) {
252252
return null;
@@ -306,10 +306,28 @@ export function getCachedResponse<
306306
}
307307

308308
// Retrieve the cached entry
309-
const cachedEntry = getCache<
309+
const cachedEntry = getCacheEntry<
310310
FetchResponse<ResponseData, RequestBody, QueryParams, PathParams>
311311
>(cacheKey, cacheTime);
312312

313313
// If no cached entry or it is expired, return null
314314
return cachedEntry ? cachedEntry.data : null;
315315
}
316+
317+
/**
318+
* Retrieves a cached response from the internal cache using the provided key.
319+
*
320+
* @param key - The unique key identifying the cached entry. If null, returns null.
321+
* @returns The cached {@link FetchResponse} if found, otherwise null.
322+
*/
323+
export function getCache<ResponseData, RequestBody, QueryParams, PathParams>(
324+
key: string | null,
325+
): FetchResponse<ResponseData, RequestBody, QueryParams, PathParams> | null {
326+
if (!key) {
327+
return null;
328+
}
329+
330+
const entry = _cache.get(key);
331+
332+
return entry ? entry.data : null;
333+
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export {
1616
/** Cache management utilities */
1717
export {
1818
generateCacheKey, // Generate cache key from URL and config
19-
getCachedResponse, // Get cached response for a key
19+
getCache, // Get cached response for a key
20+
getCachedResponse, // Get cached response with revalidation
2021
mutate, // Update cache and notify subscribers
2122
setCache, // Set cache entry directly
2223
deleteCache, // Delete cache entry

src/react/index.ts

Lines changed: 49 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
1-
import {
2-
useEffect,
3-
useCallback,
4-
useSyncExternalStore,
5-
useMemo,
6-
useRef,
7-
} from 'react';
1+
import { useCallback, useSyncExternalStore, useMemo, useRef } from 'react';
82
import {
93
fetchf,
104
subscribe,
115
buildConfig,
126
mutate as globalMutate,
13-
setCache,
147
generateCacheKey,
158
getCachedResponse,
169
getInFlightPromise,
10+
getCache,
1711
} from 'fetchff';
1812
import type {
1913
DefaultParams,
@@ -28,6 +22,7 @@ import type { UseFetcherResult } from '../types/react-hooks';
2822
import {
2923
decrementRef,
3024
DEFAULT_DEDUPE_TIME_MS,
25+
getRefCount,
3126
incrementRef,
3227
INFINITE_CACHE_TIME,
3328
} from './cache-ref';
@@ -131,31 +126,57 @@ export function useFetcher<
131126

132127
// Attempt to get the cached response immediately and if not available, return null
133128
const getSnapshot = useCallback(() => {
134-
const cached = getCachedResponse(
129+
const cached = getCache<ResponseData, RequestBody, QueryParams, PathParams>(
135130
cacheKey,
136-
// Stale-While-Revalidate Pattern: By setting -1 always return cached data if available (even if stale)
137-
INFINITE_CACHE_TIME,
138-
config,
139131
);
140132

133+
// Only throw for Suspense if we're in 'reject' mode and have no data
134+
if (
135+
cacheKey &&
136+
config.strategy === 'reject' &&
137+
(!cached || (!cached.data && !cached.error))
138+
) {
139+
const pendingPromise = getInFlightPromise(cacheKey, dedupeTime);
140+
141+
if (pendingPromise) {
142+
throw pendingPromise;
143+
}
144+
}
145+
141146
return cached;
142-
}, [cacheKey, cacheTime]);
147+
}, [cacheKey]);
143148

144149
// Subscribe to cache updates for the specific cache key
145150
const doSubscribe = useCallback(
146151
(cb: () => void) => {
147-
const unsubscribe = subscribe(cacheKey, (data: FetchResponse | null) => {
148-
// Optimistic Updates: Reflect that a fetch is happening, so to catch "fetching" state. This can help with UI updates (e.g., showing loading spinners).
149-
if (cacheKey && data && data.isFetching) {
150-
setCache(cacheKey, data);
152+
incrementRef(cacheKey);
153+
const shouldFetch =
154+
shouldTriggerOnMount && url && cacheKey && getRefCount(cacheKey) === 1; // Check if no existing refs
155+
156+
// Initial fetch logic
157+
if (shouldFetch) {
158+
// Stale-While-Revalidate Pattern: By setting -1 always return cached data if available (even if stale)
159+
const cached = getCachedResponse(cacheKey, INFINITE_CACHE_TIME, config);
160+
161+
if (!cached) {
162+
// When the component mounts, we want to fetch data if:
163+
// 1. URL is provided
164+
// 2. shouldTriggerOnMount is true (so the "immediate" isn't specified or is true)
165+
// 3. There is no cached data
166+
// 4. There is no error
167+
// 5. There is no ongoing fetch operation
168+
refetch(false);
151169
}
170+
}
152171

153-
return cb();
154-
});
172+
const unsubscribe = subscribe(cacheKey, cb);
155173

156-
return unsubscribe;
174+
return () => {
175+
decrementRef(cacheKey, cacheTime, dedupeTime, url);
176+
unsubscribe();
177+
};
157178
},
158-
[cacheKey],
179+
[cacheKey, shouldTriggerOnMount, url, dedupeTime, cacheTime],
159180
);
160181

161182
const state =
@@ -172,18 +193,6 @@ export function useFetcher<
172193
PathParams
173194
>);
174195

175-
const isUnresolved = !state.data && !state.error;
176-
const isFetching = state.isFetching || false;
177-
178-
// Handle Suspense outside the snapshot function
179-
if (isUnresolved && config.strategy === 'reject') {
180-
const pendingPromise = getInFlightPromise(cacheKey, dedupeTime);
181-
182-
if (pendingPromise) {
183-
throw pendingPromise;
184-
}
185-
}
186-
187196
const refetch = useCallback(
188197
async (forceRefresh = true) => {
189198
const [currUrl, currConfig, currCacheKey] = currentValuesRef.current;
@@ -222,31 +231,6 @@ export function useFetcher<
222231
[cacheTime, dedupeTime],
223232
);
224233

225-
useEffect(() => {
226-
// Load the initial data if not already cached and not currently fetching
227-
if (
228-
shouldTriggerOnMount &&
229-
url &&
230-
!state.data &&
231-
!state.error &&
232-
!state.isFetching
233-
) {
234-
// When the component mounts, we want to fetch data if:
235-
// 1. URL is provided
236-
// 2. shouldTriggerOnMount is true (so the "immediate" isn't specified or is true)
237-
// 3. There is no cached data
238-
// 4. There is no error
239-
// 5. There is no ongoing fetch operation
240-
refetch(false);
241-
}
242-
243-
incrementRef(cacheKey);
244-
245-
return () => {
246-
decrementRef(cacheKey, cacheTime, dedupeTime, url);
247-
};
248-
}, [url, shouldTriggerOnMount, cacheKey, cacheTime]);
249-
250234
const mutate = useCallback<
251235
UseFetcherResult<
252236
ResponseData,
@@ -261,15 +245,20 @@ export function useFetcher<
261245
[cacheKey],
262246
);
263247

248+
const isUnresolved = !state.data && !state.error;
249+
const isFetching = state.isFetching || false;
250+
const isLoading =
251+
!!url && (isFetching || (isUnresolved && shouldTriggerOnMount));
252+
264253
// Consumers always destructure the return value and use the fields directly, so
265254
// memoizing the object doesn't change rerender behavior nor improve any performance here
266255
return {
267256
data: state.data,
268257
error: state.error,
269258
config: state.config,
270259
headers: state.headers,
271-
isValidating: isFetching,
272-
isLoading: !!url && (isFetching || (isUnresolved && shouldTriggerOnMount)),
260+
isFetching,
261+
isLoading,
273262
mutate,
274263
refetch,
275264
};

src/request-handler.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import {
2020
} from './inflight-manager';
2121
import { ABORT_ERROR, CANCELLED_ERROR, FUNCTION, REJECT } from './constants';
2222
import { prepareResponse, parseResponseData } from './response-parser';
23-
import { generateCacheKey, getCachedResponse, setCache } from './cache-manager';
23+
import {
24+
deleteCache,
25+
generateCacheKey,
26+
getCachedResponse,
27+
setCache,
28+
} from './cache-manager';
2429
import { buildConfig, defaultConfig, mergeConfigs } from './config-handler';
2530
import { getRetryAfterMs } from './retry-handler';
2631
import { withPolling } from './polling-handler';
@@ -251,6 +256,8 @@ export async function request<
251256
))
252257
) {
253258
setCache(_cacheKey, output);
259+
} else {
260+
deleteCache(_cacheKey);
254261
}
255262

256263
notifySubscribers(_cacheKey, output);
@@ -393,11 +400,16 @@ export async function request<
393400
// If cache key is specified, wrap the request with in-flight management
394401
const doRequestWithInFlight = _cacheKey
395402
? async () => {
396-
notifySubscribers(_cacheKey, {
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 = {
397405
isFetching: true,
398406
data: null,
399407
error: null,
400-
});
408+
headers: null,
409+
};
410+
setCache(_cacheKey, inFlightResponse);
411+
412+
notifySubscribers(_cacheKey, inFlightResponse);
401413

402414
return doRequestOnce();
403415
}

src/types/react-hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export interface UseFetcherResult<
4747
* Indicates if the request is currently validating or fetching data.
4848
* This is true when the request is in progress, including revalidations.
4949
*/
50-
isValidating: boolean;
50+
isFetching: boolean;
5151
/**
5252
* Indicates if the request is currently loading data.
5353
* This is true when the request is in progress, including initial fetches.

test/benchmarks/utils.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import chalk from 'chalk';
2+
3+
function onComplete() {
4+
// @ts-expect-error this is a Benchmark.js context
5+
const results = this.map((bench) => ({
6+
name: bench.name,
7+
hz: bench.hz,
8+
rme: bench.stats.rme,
9+
samples: bench.stats.sample.length,
10+
}));
11+
12+
// @ts-expect-error this is a Benchmark.js context
13+
results.sort((a, b) => b.hz - a.hz);
14+
15+
console.log(chalk.bold('\nBenchmark results:'));
16+
// @ts-expect-error this is a Benchmark.js context
17+
results.forEach((r) => {
18+
const name = chalk.yellow(r.name);
19+
const ops = chalk.green(`${r.hz.toFixed(2)} ops/sec`);
20+
const error = chalk.red(${r.rme.toFixed(2)}%`);
21+
const samples = chalk.blue(`(${r.samples} runs sampled)`);
22+
23+
console.log(`${name}: ${ops} ${error} ${samples}`);
24+
});
25+
26+
const fastest = results[0];
27+
console.log(chalk.bold.green(`\nFastest is ${fastest.name}`));
28+
29+
// @ts-expect-error this is a Benchmark.js context
30+
results.slice(1).forEach((r) => {
31+
const pctSlower = ((fastest.hz - r.hz) / fastest.hz) * 100;
32+
const slowerText = chalk.red(`${pctSlower.toFixed(2)}% slower`);
33+
console.log(
34+
`${chalk.yellow(r.name)} is ${slowerText} than ${chalk.green(fastest.name)}`,
35+
);
36+
});
37+
}
38+
39+
export { onComplete };

0 commit comments

Comments
 (0)