@tobq/loadable
Version:
A library for simplifying asynchronous operations in React
1,100 lines (1,020 loc) • 36.3 kB
text/typescript
import {
DependencyList,
useCallback,
useEffect,
useRef,
useState,
} from "react"
/**
* Represents an integer timestamp (e.g. milliseconds since epoch or any monotonic count).
*
* @public
*/
export type TimeStamp = number
/**
* Returns the current time as a `TimeStamp`.
*
* @remarks
* By default, this is just `Date.now()`. You can replace this with a custom
* monotonic clock or high-resolution timer if desired.
*
* @returns The current time in milliseconds.
*
* @example
* ```ts
* const now = currentTimestamp()
* console.log("The time is", now)
* ```
*
* @public
*/
export function currentTimestamp(): TimeStamp {
return Date.now()
}
/**
* Provides a stable function that, when called, creates (or re-creates)
* an `AbortController` and returns its `signal`.
*
* @remarks
* Each render, the same function reference is returned. Calling it effectively
* aborts any in-flight request and starts a fresh `AbortController`.
*
* @returns A function which, when called, returns a fresh `AbortSignal`.
*
* @example
* ```ts
* function MyComponent() {
* const createAbortSignal = useAbort()
*
* useEffect(() => {
* const signal = createAbortSignal()
* fetch('/api', { signal }).catch(e => { ... })
* }, [])
* ...
* }
* ```
*
* @public
*/
export function useAbort() {
const abortControllerRef = useRef<AbortController | null>(null)
return useCallback(() => {
if (abortControllerRef.current) {
// If we already had a controller, abort it
abortControllerRef.current.abort()
}
abortControllerRef.current = new AbortController()
return abortControllerRef.current.signal
}, [])
}
// -------------------------------------------------------------------
// Loading Symbol + LoadingToken
// -------------------------------------------------------------------
/**
* A class-based token to represent a unique "loading" state instance.
*
* @remarks
* Using a `LoadingToken` instead of the default `loading` symbol allows you
* to store additional metadata—e.g., timestamps, request IDs, etc. This can
* facilitate debugging or concurrency strategies that rely on distinct tokens.
*
* @example
* ```ts
* import { LoadingToken } from "./useLoadable"
*
* const token = new LoadingToken()
* console.log("Loading started at:", token.startTime)
* ```
*
* @public
*/
export class LoadingToken {
/**
* Creates a new `LoadingToken`.
*
* @param startTime - When this token was created. Defaults to currentTimestamp().
*/
constructor(
public readonly startTime: TimeStamp = currentTimestamp()
) {}
}
/**
* A unique symbol representing a "loading" state.
*
* @remarks
* This symbol is used by default in loadable data when an async request is in-flight.
* Using a symbol is a simple approach for representing loading without additional metadata.
*
* @public
*/
export const loading: unique symbol = Symbol("loading")
/**
* A union type that can be either the default `loading` symbol or a class-based `LoadingToken`.
*
* @public
*/
export type Loading = typeof loading | LoadingToken
/**
* Checks if the given value represents a "loading" state.
*
* @param value - The value to check.
* @returns True if it’s either `loading` (symbol) or an instance of `LoadingToken`.
*
* @example
* ```ts
* if (isLoadingValue(loadable)) {
* return <Spinner />
* }
* ```
*
* @public
*/
export function isLoadingValue(value: unknown): value is Loading {
return value === loading || value instanceof LoadingToken
}
// -------------------------------------------------------------------
// Error for load failures
// -------------------------------------------------------------------
/**
* Represents an error that occurred while loading or fetching data.
*
* @remarks
* Wraps the original `cause` and optionally overrides the error message.
*
* @example
* ```ts
* // If a fetch fails, we might return a LoadError instead of a generic Error.
* throw new LoadError(err, "Failed to load user info")
* ```
*
* @public
*/
export class LoadError extends Error {
/**
* Creates a new `LoadError`.
*
* @param cause - The underlying reason for the load failure (e.g., an Error object).
* @param message - An optional descriptive message. Defaults to the cause’s message.
*/
constructor(public readonly cause: unknown, message?: string) {
super(
message ?? (cause instanceof Error ? cause.message : String(cause))
)
}
}
// -------------------------------------------------------------------
// Loadable types
// -------------------------------------------------------------------
/**
* A union type that can be either a "start" (e.g., `loading`) or a "result" (success or failure).
*
* @remarks
* - `Start` usually represents a `Loading` state.
* - `Result` can be the successful data type `T` or `LoadError`.
*
* @public
*/
export type Reaction<Start, Result> = Start | Result
/**
* A `Loadable<T>` can be:
* - `loading` or `LoadingToken` (in-flight),
* - a loaded value of type `T`, or
* - a `LoadError` (failed).
*
* @public
*/
export type Loadable<T> = Reaction<Loading, T | LoadError>
/**
* Extracts the loaded type from a `Loadable<T>`, excluding `loading` or `LoadError`.
*
* @public
*/
export type Loaded<T> = Exclude<T, Loading | LoadError>
/**
* Checks if a `Loadable<T>` has fully loaded (i.e., is neither loading nor an error).
*
* @param loadable - The loadable value to check.
* @returns True if it’s the successful data of type `T`.
*
* @public
*/
export function hasLoaded<T>(loadable: Loadable<T>): loadable is Loaded<T> {
return !isLoadingValue(loadable) && !loadFailed(loadable)
}
/**
* Checks if a `Loadable<T>` is a load failure (`LoadError`).
*
* @param loadable - The loadable value to check.
* @returns True if it’s a `LoadError`.
*
* @public
*/
export function loadFailed<T>(loadable: Loadable<T>): loadable is LoadError {
return loadable instanceof LoadError
}
/**
* Applies a mapper function to a loadable if it’s successfully loaded, returning a new loadable.
*
* @remarks
* If `loadable` is an error or loading, it’s returned unchanged.
*
* @param loadable - The original loadable.
* @param mapper - A function that transforms the loaded data `T` into `R`.
* @returns A new loadable with data of type `R`, or the same loading/error state.
*
* @public
*/
export function map<T, R>(loadable: Loadable<T>, mapper: (loaded: T) => R): Loadable<R> {
if (loadFailed(loadable)) return loadable
if (isLoadingValue(loadable)) return loadable
return mapper(loadable)
}
/**
* Combines multiple loadables into one. If any are still loading or have failed, returns `loading`.
*
* @remarks
* In reality, `all()` returns `loading` if ANY have not loaded. If all are loaded, it returns an array
* of their loaded values (typed to match each item in `loadables`).
*
* @param loadables - The loadable values to combine.
* @returns A single loadable that is `loading` if any item is not loaded, else an array of loaded items.
*
* @example
* ```ts
* const combined = all(userLoadable, postsLoadable, statsLoadable)
* if (!hasLoaded(combined)) {
* return <Spinner />
* }
* const [user, posts, stats] = combined
* ```
*
* @public
*/
export function all<T extends Loadable<unknown>[]>(...loadables: T): Loadable<{ [K in keyof T]: Loaded<T[K]> }> {
if (loadables.some(l => !hasLoaded(l))) {
return loading
}
return loadables.map(l => l) as { [K in keyof T]: Loaded<T[K]> }
}
/**
* Converts a loadable to `undefined` if not fully loaded, or the loaded value otherwise.
*
* @param loadable - The loadable value to unwrap.
* @returns `T` if loaded, otherwise `undefined`.
*
* @public
*/
export function toOptional<T>(loadable: Loadable<T>): T | undefined {
return hasLoaded(loadable) ? loadable : undefined
}
/**
* Returns the loaded value if `loadable` is fully loaded, otherwise `defaultValue`.
*
* @param loadable - The loadable value to unwrap.
* @param defaultValue - The fallback if loadable is not loaded.
* @returns The loaded `T` or the provided `defaultValue`.
*
* @public
*/
export function orElse<T, R>(loadable: Loadable<T>, defaultValue: R): T | R {
return hasLoaded(loadable) ? loadable : defaultValue
}
/**
* Checks if a loadable is fully loaded AND not null/undefined.
*
* @param loadable - A loadable that could be `null` or `undefined` once loaded.
* @returns True if the loadable is successfully loaded and non-nullish.
*
* @public
*/
export function isUsable<T>(loadable: Loadable<T | null | undefined>): loadable is T {
return hasLoaded(loadable) && loadable != null
}
// -------------------------------------------------------------------
// Basic fetcher type
// -------------------------------------------------------------------
/**
* A function type that fetches data and returns a promise, using an `AbortSignal`.
*
* @param signal - The `AbortSignal` to handle cancellations.
* @returns A promise resolving to the fetched data of type `T`.
*
* @public
*/
export type Fetcher<T> = (signal: AbortSignal) => Promise<T>
// -------------------------------------------------------------------
// Caching shapes
// -------------------------------------------------------------------
/**
* Defines the shape of a cache option with a key and an optional store.
*
* @public
*/
export interface CacheOption {
/**
* The key to store in the cache (e.g., "myUserData").
*/
key: string
/**
* The store used for caching. Defaults to `"localStorage"`.
*/
store?: "memory" | "localStorage" | "indexedDB"
}
/**
* Parses a `cache` field that could be a string or an object, returning a normalized object.
*
* @param cache - Either a string or `{ key, store }`.
* @returns An object with `key` and `store`.
*
* @internal
*/
function parseCacheOption(
cache?: string | CacheOption
): { key?: string; store: "memory" | "localStorage" | "indexedDB" } {
if (!cache) {
return { key: undefined, store: "localStorage" }
}
if (typeof cache === "string") {
// If user passed a string, that is the cache key, default to localStorage
return { key: cache, store: "localStorage" }
}
// Otherwise, user passed an object { key, store? }
return {
key: cache.key,
store: cache.store ?? "localStorage",
}
}
// -------------------------------------------------------------------
// Our caching utilities
// -------------------------------------------------------------------
/** @internal */
const memoryCache = new Map<string, unknown>()
/**
* Reads data from the specified cache store.
*
* @internal
* @param key - The cache key.
* @param store - Which store to use ("memory", "localStorage", or "indexedDB").
* @returns The cached data or `undefined` if not found.
*/
async function readCache<T>(
key: string,
store: "memory" | "localStorage" | "indexedDB"
): Promise<T | undefined> {
switch (store) {
case "memory": {
return memoryCache.get(key) as T | undefined
}
case "localStorage": {
const json = window.localStorage.getItem(key)
if (!json) return undefined
try {
return JSON.parse(json) as T
} catch {
return undefined
}
}
case "indexedDB": {
return await readFromIndexedDB<T>(key)
}
}
}
/**
* Writes data to the specified cache store.
*
* @internal
* @param key - The cache key.
* @param data - The data to store.
* @param store - The store to use.
*/
async function writeCache<T>(
key: string,
data: T,
store: "memory" | "localStorage" | "indexedDB"
): Promise<void> {
switch (store) {
case "memory": {
memoryCache.set(key, data)
break
}
case "localStorage": {
window.localStorage.setItem(key, JSON.stringify(data))
break
}
case "indexedDB": {
await writeToIndexedDB(key, data)
break
}
}
}
/**
* Opens (and initializes) an IndexedDB database named "myReactCacheDB" with an object store "idbCache".
*
* @internal
* @returns A promise resolving to the opened IDBDatabase.
*/
function openCacheDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open("myReactCacheDB", 1)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains("idbCache")) {
db.createObjectStore("idbCache")
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
/**
* Reads an item from the "idbCache" store in our "myReactCacheDB" IndexedDB.
*
* @internal
*/
async function readFromIndexedDB<T>(key: string): Promise<T | undefined> {
const db = await openCacheDB()
return new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction("idbCache", "readonly")
const store = tx.objectStore("idbCache")
const getReq = store.get(key)
getReq.onsuccess = () => resolve(getReq.result)
getReq.onerror = () => reject(getReq.error)
})
}
/**
* Writes an item to the "idbCache" store in our "myReactCacheDB" IndexedDB.
*
* @internal
*/
async function writeToIndexedDB<T>(key: string, data: T): Promise<void> {
const db = await openCacheDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction("idbCache", "readwrite")
const store = tx.objectStore("idbCache")
const putReq = store.put(data, key)
putReq.onsuccess = () => resolve()
putReq.onerror = () => reject(putReq.error)
})
}
// -------------------------------------------------------------------
// Options for useLoadable
// -------------------------------------------------------------------
/**
* The options object for `useLoadable`.
*
* @typeParam T - The data type we expect to load.
*
* @public
*/
export interface UseLoadableOptions<T = any> {
/**
* A prefetched loadable value, if available (used instead of calling the fetcher).
*/
prefetched?: Loadable<T>
/**
* An optional callback for load errors. Called with the raw error object.
*/
onError?: (error: unknown) => void
/**
* If true, once we have a loaded value, do **not** revert to `loading`
* on subsequent fetches; instead, keep the old value until the new fetch
* finishes or fails.
*/
hideReload?: boolean
/**
* Caching configuration. Can be:
* - A string: used as the cache key (store defaults to `"localStorage"`).
* - An object: `{ key: string, store?: "memory" | "localStorage" | "indexedDB" }`.
*/
cache?: string | CacheOption
}
// -------------------------------------------------------------------
// A custom hook for state with timestamps
// -------------------------------------------------------------------
/**
* A hook that manages a piece of state (`T`) alongside a timestamp, allowing you
* to ignore stale updates with older timestamps.
*
* @remarks
* Internally, it stores the current `value` plus a `loadStart` timestamp. Each time
* you set a new value, you can provide an optional new timestamp. If that timestamp
* is older than the current state's `loadStart`, the update is ignored.
*
* @param initial - The initial state value.
* @returns A tuple: `[value, setValue, loadStart]`.
*
* @example
* ```ts
* const [myValue, setMyValue, lastUpdated] = useLatestState(0)
*
* function handleUpdate(newVal: number) {
* // We'll pass a timestamp
* setMyValue(newVal, performance.now())
* }
* ```
*
* @public
*/
export function useLatestState<T>(
initial: T
): [T, (value: T | ((current: T) => T), loadStart?: TimeStamp) => void, TimeStamp] {
const [value, setValue] = useState<{
value: T
loadStart: TimeStamp
}>({
value: initial,
loadStart: 0,
})
function updateValue(
newValue: T | ((current: T) => T),
loadStart: TimeStamp = currentTimestamp()
) {
setValue(current => {
if (current.loadStart > loadStart) {
// Ignore older updates
return current
}
const nextValue =
typeof newValue === "function"
? (newValue as (c: T) => T)(current.value)
: newValue
return {
value: nextValue,
loadStart,
}
})
}
return [value.value, updateValue, value.loadStart]
}
// -------------------------------------------------------------------
// For debugging (optional)
// -------------------------------------------------------------------
/**
* A Set of timestamps indicating which requests are currently in-flight.
*
* @remarks
* This is used internally for debugging and to signal "prerenderReady" when no requests remain.
* Attach it to `window` in dev environments if desired.
*
* @internal
*/
const currentlyLoading = new Set<number>()
// @ts-ignore
if (typeof window !== "undefined") {
;(window as any).currentlyLoading = currentlyLoading
}
// -------------------------------------------------------------------
// Overloads for useLoadable
// -------------------------------------------------------------------
/**
* Overload: `useLoadable(waitable, readyCondition, fetcher, dependencies, optionsOrOnError?)`
*/
export function useLoadable<W, R>(
waitable: W,
readyCondition: (loaded: W) => boolean,
fetcher: (loaded: W, abort: AbortSignal) => Promise<R>,
dependencies: DependencyList,
optionsOrOnError?: ((e: unknown) => void) | UseLoadableOptions<R>
): Loadable<R>
/**
* Overload: `useLoadable(fetcher, deps, options?)`
*/
export function useLoadable<T>(
fetcher: Fetcher<T>,
deps: DependencyList,
options?: UseLoadableOptions<T>
): Loadable<T>
/**
* The core hook that returns a `Loadable<T>` by calling an async fetcher.
*
* @remarks
* Has two main usage patterns:
* 1. **Waitable** form: You pass a "waitable" value plus a `readyCondition`, and a `fetcher`.
* - The effect only runs when `readyCondition(waitable)` is true.
* - If `hideReload` is false, it will revert to loading each time the waitable changes.
*
* 2. **Simple** form: You pass just a `fetcher`, dependencies, and optional `UseLoadableOptions`.
* - The fetcher is called whenever dependencies change.
* - The result is stored in a loadable: `loading` until success or `LoadError` on failure.
*
* Caching:
* - If `cache` is provided (string or `{ key, store }`), it tries to read from that cache first.
* If found, returns it immediately. Then (optionally) re-fetches in the background, or
* according to `hideReload`.
*
* @typeParam T - The successful data type when using the simple form.
* @typeParam W - The waitable type (for advanced usage).
* @typeParam R - The successful data type when using the waitable form.
*
* @public
*/
export function useLoadable<T, W, R>(
fetcherOrWaitable: Fetcher<T> | W,
depsOrReadyCondition: DependencyList | ((loaded: W) => boolean),
optionsOrFetcher?:
| UseLoadableOptions<T>
| ((loaded: W, abort: AbortSignal) => Promise<R>),
dependencies: DependencyList = [],
lastParam?: ((e: unknown) => void) | UseLoadableOptions<R>
): Loadable<T> | Loadable<R> {
// ============================
// CASE 1: waitable + readyCondition + fetcher
// ============================
if (typeof depsOrReadyCondition === "function") {
const waitable = fetcherOrWaitable as W
const readyCondition = depsOrReadyCondition as (loaded: W) => boolean
const fetcher = optionsOrFetcher as (
loaded: W,
abort: AbortSignal
) => Promise<R>
let onErrorCb: ((e: unknown) => void) | undefined
let hideReload = false
let cacheObj: ReturnType<typeof parseCacheOption> = {
key: undefined,
store: "localStorage",
}
if (typeof lastParam === "function") {
onErrorCb = lastParam
} else if (lastParam && typeof lastParam === "object") {
onErrorCb = lastParam.onError
hideReload = !!lastParam.hideReload
cacheObj = parseCacheOption(lastParam.cache)
}
const [value, setValue] = useLatestState<Loadable<R>>(loading)
const abort = useAbort()
const ready = readyCondition(waitable)
useEffect(() => {
const startTime = currentTimestamp()
// If hideReload=false or not yet loaded, revert to 'loading'
if (!hideReload || !hasLoaded(value)) {
setValue(loading, startTime)
}
if (ready) {
// Before fetching, try reading from cache (if provided)
if (cacheObj.key) {
// Attempt to read
;(async () => {
const cachedData = await readCache<R>(
cacheObj.key!,
cacheObj.store
)
if (cachedData !== undefined) {
// We found a valid cached value
// You could do stale-while-revalidate or just set it:
setValue(cachedData, startTime)
}
doFetch()
})()
} else {
doFetch()
}
function doFetch() {
currentlyLoading.add(startTime)
const signal = abort()
fetcher(waitable, signal)
.then(result => {
// On success, write to cache if key
if (cacheObj.key) {
writeCache(cacheObj.key, result, cacheObj.store).catch(
console.error
)
}
setValue(result, startTime)
})
.catch(e => {
onErrorCb?.(e)
setValue(new LoadError(e), startTime)
})
.finally(() => {
currentlyLoading.delete(startTime)
if (
currentlyLoading.size === 0 &&
typeof window !== "undefined" &&
"prerenderReady" in window
) {
;(window as any).prerenderReady = true
}
})
}
}
return () => {
abort()
currentlyLoading.delete(startTime)
}
}, [...dependencies, ready, hideReload])
return value
}
// ============================
// CASE 2: fetcher + deps + options
// ============================
const fetcher = fetcherOrWaitable as Fetcher<T>
const deps = depsOrReadyCondition as DependencyList
const options = optionsOrFetcher as UseLoadableOptions<T> | undefined
// Parse the cache field
const { key: cacheKey, store: cacheStore } = parseCacheOption(options?.cache)
// We'll piggyback on the waitable approach, with a "dummy" waitable always ready
return useLoadable(
loading,
() => true,
async (_ignored, signal) => {
//
// 1) Attempt to read from cache (if we have cacheKey)
//
if (cacheKey) {
const cachedData = await readCache<T>(cacheKey, cacheStore)
if (cachedData !== undefined) {
// Found a valid cached value
return cachedData
}
}
//
// 2) If there's a prefetched loadable
//
if (options?.prefetched !== undefined) {
if (options.prefetched === loading) {
return fetcher(signal)
} else if (options.prefetched instanceof LoadError) {
throw options.prefetched
} else if (isLoadingValue(options.prefetched)) {
// e.g. a LoadingToken
return fetcher(signal)
} else {
// Otherwise it's a T
if (cacheKey) {
await writeCache(cacheKey, options.prefetched, cacheStore)
}
return options.prefetched
}
}
//
// 3) Normal fetch
//
const data = await fetcher(signal)
if (cacheKey) {
await writeCache(cacheKey, data, cacheStore)
}
return data
},
deps,
{
onError: options?.onError,
hideReload: options?.hideReload,
}
) as Loadable<T>
}
// -------------------------------------------------------------------
// useThen + useAllThen
// -------------------------------------------------------------------
/**
* A hook that waits for a `loadable` to finish, then calls another async `fetcher`.
*
* @remarks
* If `loadable` is still loading or has failed, this hook returns the same `loadable` state.
* Otherwise, if `loadable` is loaded, it calls `fetcher(loadedValue)` and returns the result
* as a new `Loadable<R>`.
*
* @param loadable - The initial loadable value.
* @param fetcher - A function that takes the successfully loaded data plus an abort signal, returning a promise.
* @param dependencies - An optional list of dependencies to trigger re-runs. Defaults to `[hasLoaded(loadable)]`.
* @param options - Optional `UseLoadableOptions` for error handling, caching, etc.
* @returns A `Loadable<R>` that is `loading` until the chained fetch finishes, or a `LoadError` if it fails.
*
* @example
* ```ts
* const user = useLoadable(() => fetchUser(userId), [userId])
* const posts = useThen(user, (u) => fetchPostsForUser(u.id))
* ```
*
* @public
*/
export function useThen<T, R>(
loadable: Loadable<T>,
fetcher: (loaded: T, abort: AbortSignal) => Promise<R>,
dependencies: DependencyList = [hasLoaded(loadable)],
options?: UseLoadableOptions<R>
): Loadable<R> {
return useLoadable(
loadable,
l => hasLoaded(l),
async (val, abort) => map(val, v => fetcher(v, abort)),
dependencies,
options
)
}
/** @internal */
type UnwrapLoadable<T> = T extends Loadable<infer U> ? U : never
/** @internal */
type LoadableParameters<T extends Loadable<any>[]> = {
[K in keyof T]: UnwrapLoadable<T[K]>
}
/**
* A hook that waits for multiple loadables to finish, then calls a `fetcher` using all their loaded values.
*
* @remarks
* Internally, it calls `all(...loadables)`. If any loadable is still loading or fails, the combined is `loading`.
* Once all are loaded, calls `fetcher(...loadedValues, signal)` and returns a `Loadable<R>`.
*
* @param loadables - An array (spread) of loadable values, e.g. `[user, stats, posts]`.
* @param fetcher - A function that takes each loaded value plus an `AbortSignal`.
* @param dependencies - An optional list of dependencies to re-run the effect. Defaults to the loadables array.
* @param options - Optional config for error handling, caching, etc.
* @returns A loadable result of type `R`.
*
* @example
* ```ts
* const user = useLoadable(fetchUser, [])
* const stats = useLoadable(fetchStats, [])
*
* const combined = useAllThen(
* [user, stats],
* (u, s, signal) => fetchDashboard(u, s, signal),
* []
* )
* ```
*
* @public
*/
export function useAllThen<T extends Loadable<any>[], R>(
loadables: [...T],
fetcher: (...args: [...LoadableParameters<T>, AbortSignal]) => Promise<R>,
dependencies: DependencyList = loadables,
options?: UseLoadableOptions<R>
): Loadable<R> {
const combined = all(...loadables)
return useThen(
combined,
(vals, signal) => fetcher(...(vals as LoadableParameters<T>), signal),
dependencies,
options
)
}
// -------------------------------------------------------------------
// useLoadableWithCleanup
// -------------------------------------------------------------------
/**
* Overload: `useLoadableWithCleanup(waitable, readyCondition, fetcher, deps, optionsOrOnError?)`.
*/
export function useLoadableWithCleanup<W, R>(
waitable: W,
readyCondition: (loaded: W) => boolean,
fetcher: (loaded: W, abort: AbortSignal) => Promise<R>,
dependencies: DependencyList,
optionsOrOnError?: ((e: unknown) => void) | UseLoadableOptions<R>
): [Loadable<R>, () => void]
/**
* Overload: `useLoadableWithCleanup(fetcher, deps, options?)`.
*/
export function useLoadableWithCleanup<T>(
fetcher: Fetcher<T>,
deps: DependencyList,
options?: UseLoadableOptions<T>
): [Loadable<T>, () => void]
/**
* A variant of `useLoadable` that returns a `[Loadable<T>, cleanupFunc]` tuple.
*
* @remarks
* This lets you manually call `cleanupFunc()` to abort any in-flight request,
* instead of waiting for an unmount or effect re-run.
*
* @returns A tuple: `[Loadable<T>, cleanupFunc]`.
*
* @example
* ```ts
* const [userLoadable, cleanup] = useLoadableWithCleanup(fetchUser, [])
*
* // Manually abort the current fetch:
* cleanup()
* ```
*
* @public
*/
export function useLoadableWithCleanup<T, W, R>(
fetcherOrWaitable: Fetcher<T> | W,
depsOrReadyCondition: DependencyList | ((loaded: W) => boolean),
optionsOrFetcher?:
| UseLoadableOptions<T>
| ((loaded: W, abort: AbortSignal) => Promise<R>),
dependencies: DependencyList = [],
lastParam?: ((e: unknown) => void) | UseLoadableOptions<R>
): [Loadable<T> | Loadable<R>, () => void] {
const [value, setValue] = useLatestState<Loadable<any>>(loading)
const abortControllerRef = useRef<AbortController | null>(null)
let isCase1 = false
let waitableVal: W | undefined
let readyFn: ((w: W) => boolean) | undefined
let actualFetcher: ((w: W, signal: AbortSignal) => Promise<any>) | undefined
let hideReload = false
let onErrorCb: ((e: unknown) => void) | undefined
let deps: DependencyList
let cacheObj = parseCacheOption()
if (typeof depsOrReadyCondition === "function") {
// CASE 1
isCase1 = true
waitableVal = fetcherOrWaitable as W
readyFn = depsOrReadyCondition as (w: W) => boolean
actualFetcher = optionsOrFetcher as (w: W, signal: AbortSignal) => Promise<R>
deps = dependencies
if (typeof lastParam === "function") {
onErrorCb = lastParam
} else if (lastParam && typeof lastParam === "object") {
onErrorCb = lastParam.onError
hideReload = !!lastParam.hideReload
cacheObj = parseCacheOption(lastParam.cache)
}
} else {
// CASE 2
const fetcher = fetcherOrWaitable as Fetcher<T>
deps = depsOrReadyCondition as DependencyList
const options = optionsOrFetcher as UseLoadableOptions<T> | undefined
onErrorCb = options?.onError
hideReload = !!options?.hideReload
cacheObj = parseCacheOption(options?.cache)
// always "ready"
readyFn = () => true
// The actual fetcher that either reads from cache or calls the original fetcher
actualFetcher = async (_ignored: W, signal: AbortSignal) => {
// Read from cache if possible
if (cacheObj.key) {
const cachedData = await readCache<T>(cacheObj.key, cacheObj.store)
if (cachedData !== undefined) {
return cachedData
}
}
// If prefetched is available
if (options?.prefetched !== undefined) {
if (options.prefetched === loading) {
return fetcher(signal)
} else if (options.prefetched instanceof LoadError) {
throw options.prefetched
} else if (isLoadingValue(options.prefetched)) {
return fetcher(signal)
} else {
// T
if (cacheObj.key) {
await writeCache(cacheObj.key, options.prefetched, cacheObj.store)
}
return options.prefetched
}
}
// Normal fetch
const data = await fetcher(signal)
if (cacheObj.key) {
await writeCache(cacheObj.key, data, cacheObj.store)
}
return data
}
}
useEffect(() => {
const startTime = currentTimestamp()
const isReady = readyFn?.(waitableVal as W) ?? true
// If hideReload=false or current is not loaded, revert to 'loading'
if (!hideReload || !hasLoaded(value)) {
setValue(loading, startTime)
}
if (isReady && actualFetcher) {
abortControllerRef.current = new AbortController()
const signal = abortControllerRef.current.signal
currentlyLoading.add(startTime)
actualFetcher(waitableVal as W, signal)
.then(result => {
setValue(result, startTime)
})
.catch(e => {
onErrorCb?.(e)
setValue(new LoadError(e), startTime)
})
.finally(() => {
currentlyLoading.delete(startTime)
if (
currentlyLoading.size === 0 &&
typeof window !== "undefined" &&
"prerenderReady" in window
) {
;(window as any).prerenderReady = true
}
})
}
return () => {
abortControllerRef.current?.abort()
currentlyLoading.delete(startTime)
}
}, [
isCase1,
waitableVal,
readyFn,
actualFetcher,
hideReload,
onErrorCb,
value,
setValue,
...deps,
])
/**
* Cancels any current request immediately. This is the second element
* in the returned tuple from `useLoadableWithCleanup`.
*/
const cleanupFunc = useCallback(() => {
abortControllerRef.current?.abort()
}, [])
return [value, cleanupFunc]
}