├── .github └── workflows │ └── publish.yml ├── .gitignore ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── loadable.ts └── utils.ts └── tsconfig.json /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | registry-url: 'https://registry.npmjs.org/' 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Build 20 | run: npm run build 21 | - name: Publish to npm 22 | run: npm publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Below is an updated **README** that includes a section on **caching**. It starts with the basics and gradually introduces the concept of a loading token, then covers how to leverage caching (using in-memory, `localStorage`, or `indexedDB`). 2 | 3 | --- 4 | 5 | # Loadable 6 | 7 | A lightweight, type-safe, and composable library for managing asynchronous data in React. **Loadable** provides hooks and utilities to make fetching data clean, declarative, and free from repetitive “loading” and “error” state boilerplate. It’s an alternative to manually writing `useState + useEffect` or using heavier data-fetching libraries. 8 | 9 | ## Table of Contents 10 | - [Overview](#overview) 11 | - [Core Concepts](#core-concepts) 12 | - [Quick Start](#quick-start) 13 | - [Basic Example](#basic-example) 14 | - [Chaining Async Calls](#chaining-async-calls) 15 | - [Fetching Multiple Loadables](#fetching-multiple-loadables) 16 | - [Hooks & Utilities](#hooks--utilities) 17 | - [Migrating Common Patterns](#migrating-common-patterns) 18 | - [Error Handling](#error-handling) 19 | - [Caching](#advanced-caching) 20 | - [Comparison with Alternatives](#comparison-with-alternatives) 21 | - [Why Loadable?](#why-loadable) 22 | 23 | --- 24 | 25 | ## Overview 26 | 27 | React doesn’t come with an official solution for data fetching, which often leads to repetitive patterns: 28 | - **Booleans** to track loading states. 29 | - **Conditionals** to check null data or thrown errors. 30 | - **Cleanups** to avoid updating unmounted components. 31 | 32 | **Loadable** unifies these concerns: 33 | - A **single type** encapsulates “loading,” “loaded,” and “error” states. 34 | - Easy-to-use **hooks** (`useLoadable`, `useThen`, etc.) to chain and compose fetches. 35 | - Automatic **cancellation** of in-flight requests to avoid stale updates. 36 | 37 | --- 38 | 39 | ## Installation 40 | 41 | ```bash 42 | npm install @tobq/loadable 43 | # or 44 | yarn add @tobq/loadable 45 | ``` 46 | 47 | --- 48 | 49 | ## Core Concepts 50 | 51 | ### Loadable Type 52 | 53 | A `Loadable` can be: 54 | 1. **Loading**: represented by a special `loading` symbol (or an optional “loading token”). 55 | 2. **Loaded**: the actual data of type `T`. 56 | 3. **Failed**: a `LoadError` object describing the failure. 57 | 58 | This single union type replaces the typical `isLoading` / `data` / `error` triple. 59 | 60 | --- 61 | 62 | ## Quick Start 63 | 64 | ### Basic Example 65 | 66 | Below is a minimal comparison of how you might load data **with** and **without** Loadable: 67 | 68 | #### Without Loadable 69 | 70 | ```tsx 71 | function Properties() { 72 | const [properties, setProperties] = useState(null) 73 | const [isLoading, setLoading] = useState(true) 74 | 75 | useEffect(() => { 76 | getPropertiesAsync() 77 | .then((props) => { 78 | setProperties(props) 79 | setLoading(false) 80 | }) 81 | .catch(console.error) 82 | }, []) 83 | 84 | if (isLoading || !properties) { 85 | return
Loading…
86 | } 87 | return ( 88 |
89 | {properties.map((p) => ( 90 | 91 | ))} 92 |
93 | ) 94 | } 95 | ``` 96 | 97 | #### With Loadable 98 | 99 | ```tsx 100 | import { useLoadable, hasLoaded } from "@tobq/loadable" 101 | 102 | function Properties() { 103 | const properties = useLoadable(() => getPropertiesAsync(), []) 104 | 105 | if (!hasLoaded(properties)) { 106 | return
Loading…
107 | } 108 | return ( 109 |
110 | {properties.map((p) => ( 111 | 112 | ))} 113 |
114 | ) 115 | } 116 | ``` 117 | 118 | - No “isLoading” boolean or separate error state needed. 119 | - `properties` starts as `loading` and becomes the loaded data when ready. 120 | - `hasLoaded(properties)` ensures the data is neither loading nor an error. 121 | 122 | ### Chaining Async Calls 123 | 124 | ```tsx 125 | import { useLoadable, useThen, hasLoaded } from "@tobq/loadable" 126 | 127 | function UserProfile({ userId }) { 128 | // First load the user 129 | const user = useLoadable(() => fetchUser(userId), [userId]) 130 | 131 | // Then load the user’s posts, using the loaded `user` 132 | const posts = useThen(user, (u) => fetchPostsForUser(u.id)) 133 | 134 | if (!hasLoaded(user)) return
Loading user…
135 | if (!hasLoaded(posts)) return
Loading posts…
136 | 137 | return ( 138 |
139 |

{user.name}

140 | {posts.map((p) => ( 141 | 142 | ))} 143 |
144 | ) 145 | } 146 | ``` 147 | 148 | ### Fetching Multiple Loadables 149 | 150 | Use `useAllThen` or the `all()` helper to coordinate multiple loadable values: 151 | 152 | ```tsx 153 | import { useAllThen, hasLoaded } from "@tobq/loadable" 154 | 155 | function Dashboard() { 156 | const user = useLoadable(() => fetchUser(), []) 157 | const stats = useLoadable(() => fetchStats(), []) 158 | 159 | // Wait for both to be loaded, then call `fetchDashboardSummary()` 160 | const summary = useAllThen( 161 | [user, stats], 162 | (u, s, signal) => fetchDashboardSummary(u.id, s.range, signal), 163 | [] 164 | ) 165 | 166 | if (!hasLoaded(summary)) return
Loading Dashboard…
167 | 168 | return 169 | } 170 | ``` 171 | 172 | --- 173 | 174 | ## Hooks & Utilities 175 | 176 | - **`useLoadable(fetcher, deps, options?)`** 177 | Returns a `Loadable` by calling the async `fetcher`. 178 | - **`useThen(loadable, fetcher, deps?, options?)`** 179 | Waits for a loadable to finish, then chains another async call. 180 | - **`useAllThen(loadables, fetcher, deps?, options?)`** 181 | Waits for multiple loadables to finish, then calls `fetcher`. 182 | - **`useLoadableWithCleanup(fetcher, deps, options?)`** 183 | Like `useLoadable`, but returns `[Loadable, cleanupFunc]` for manual aborts. 184 | 185 | **Helpers** include: 186 | - `hasLoaded(loadable)` 187 | - `loadFailed(loadable)` 188 | - `all(...)` 189 | - `map(...)` 190 | - `toOptional(...)` 191 | - `orElse(...)` 192 | - `isUsable(...)` 193 | 194 | --- 195 | 196 | ## Migrating Common Patterns 197 | 198 | ### Manual Loading States 199 | 200 | **Before**: 201 | ```tsx 202 | const [data, setData] = useState(null) 203 | const [loading, setLoading] = useState(true) 204 | const [error, setError] = useState(null) 205 | 206 | useEffect(() => { 207 | setLoading(true) 208 | getData() 209 | .then(res => setData(res)) 210 | .catch(err => setError(err)) 211 | .finally(() => setLoading(false)) 212 | }, []) 213 | ``` 214 | 215 | **After**: 216 | ```tsx 217 | import { useLoadable, loadFailed, hasLoaded } from "@tobq/loadable" 218 | 219 | const loadable = useLoadable(() => getData(), []) 220 | 221 | if (loadFailed(loadable)) { 222 | return 223 | } 224 | if (!hasLoaded(loadable)) { 225 | return 226 | } 227 | 228 | return 229 | ``` 230 | 231 | ### Chaining Fetches 232 | 233 | **Before**: 234 | ```tsx 235 | useEffect(() => { 236 | let cancelled = false 237 | 238 | getUser().then(user => { 239 | if (!cancelled) { 240 | setUser(user) 241 | getUserPosts(user.id).then(posts => { 242 | if (!cancelled) { 243 | setPosts(posts) 244 | } 245 | }) 246 | } 247 | }) 248 | 249 | return () => { cancelled = true } 250 | }, []) 251 | ``` 252 | 253 | **After**: 254 | ```tsx 255 | const user = useLoadable(() => getUser(), []) 256 | const posts = useThen(user, (u) => getUserPosts(u.id)) 257 | ``` 258 | 259 | --- 260 | 261 | ## Error Handling 262 | 263 | By default, if a fetch fails, `useLoadable` returns a `LoadError`. You can handle or display it: 264 | 265 | ```tsx 266 | const users = useLoadable(fetchUsers, [], { 267 | onError: (error) => console.error("Error loading users:", error) 268 | }) 269 | 270 | if (loadFailed(users)) { 271 | return 272 | } 273 | if (!hasLoaded(users)) { 274 | return 275 | } 276 | 277 | return 278 | ``` 279 | 280 | --- 281 | 282 | ## Advanced: Symbol vs. Class-based Loading Token 283 | 284 | By default, **Loadable** uses a single symbol `loading` to represent the “loading” state. If you need **unique tokens** for better debugging or timestamp tracking, you can opt for the **class-based** token: 285 | 286 | ```ts 287 | import { LoadingToken, newLoadingToken } from "@tobq/loadable" 288 | 289 | const token = newLoadingToken() // brand-new token with a timestamp 290 | ``` 291 | You can store additional metadata (like `startTime`) in the token. Internally, the library handles both `loading` (symbol) and `LoadingToken` interchangeably. 292 | 293 | --- 294 | 295 | ## Advanced: Caching 296 | 297 | Loadable supports optional caching of fetched data, allowing you to bypass refetching if the data already exists in **memory**, **localStorage**, or **indexedDB**. 298 | 299 | ### Using `cache` in `useLoadable` 300 | 301 | Within the **`options`** object passed to `useLoadable`, you can include: 302 | 303 | ```ts 304 | cache?: string | { 305 | key: string 306 | store?: "memory" | "localStorage" | "indexedDB" 307 | } 308 | ``` 309 | 310 | 1. **String** (e.g. `cache: "myDataKey"`): 311 | - Interpreted as the cache key, defaults to `"localStorage"` for storage. 312 | 2. **Object** (e.g. `cache: { key: "myDataKey", store: "indexedDB" }`): 313 | - Fully specifies both the cache key and the storage backend. 314 | 315 | #### Example 316 | 317 | ```tsx 318 | function MyComponent() { 319 | // #1: Simple string for cache => defaults to localStorage 320 | const dataLoadable = useLoadable(fetchMyData, [], { 321 | cache: "myDataKey", 322 | hideReload: false, 323 | onError: (err) => console.error("Load error:", err), 324 | }) 325 | 326 | if (dataLoadable === loading) { 327 | return
Loading...
328 | } 329 | if (!hasLoaded(dataLoadable)) { 330 | // must be an error 331 | return
Error: {dataLoadable.message}
332 | } 333 | 334 | return
{JSON.stringify(dataLoadable, null, 2)}
335 | } 336 | ``` 337 | 338 | The first time the component mounts, it checks `localStorage["myDataKey"]`. 339 | - If **not found**, it fetches from the server, **writes** to localStorage, and returns the result. 340 | - Subsequent renders can immediately read from localStorage before re-fetching or revalidating (depending on `hideReload` or your logic). 341 | 342 | ### Cache Stores 343 | 344 | - **`memory`**: A global in-memory map (fast, but resets on page refresh). 345 | - **`localStorage`**: Persists across refreshes, limited by localStorage size (~5MB in many browsers). 346 | - **`indexedDB`**: Can store larger data more efficiently, though usage is a bit more complex. 347 | 348 | ### Notes on Caching Strategy 349 | 350 | - **Stale-While-Revalidate**: You can display cached data immediately while you do a new fetch in the background. Setting `hideReload: true` means you don’t revert to a “loading” state once something is cached; you only show the old data until the new fetch finishes. 351 | - **TTL or Expiration**: This minimal caching approach doesn’t implement TTL. For more complex logic, you can store timestamps or version data in your cached objects and skip using stale data if it’s outdated. 352 | - **Error Handling**: If the cached data is present but you still want to re-fetch, you can always ignore or override the cache. The code is flexible enough to support these flows. 353 | 354 | --- 355 | 356 | ## Comparison with Alternatives 357 | 358 | - **React Query / SWR / Apollo**: Powerful, feature-rich solutions (caching, revalidation, etc.), which can be overkill if you don’t need those extras. 359 | - **Manual `useEffect`**: Often leads to repetitive loading booleans and tricky cleanup logic. Loadable unifies these states for you. 360 | - **Redux**: While Redux can handle async, it’s heavy if you only need local data fetching without global state. 361 | 362 | --- 363 | 364 | ## Why Loadable? 365 | 366 | - **Less Boilerplate**: Eliminate scattered `useState` variables and conditionals for loading/error states. 367 | - **Declarative**: Compose async operations with `useLoadable`, `useThen`, `useAllThen`, etc. 368 | - **Safe & Explicit**: Distinguish between `loading`, a `LoadError`, or real data in one type. 369 | - **Flexible**: Use a simple symbol or a class-based token with timestamps or custom fields. 370 | - **Caching**: Optionally store and retrieve data from memory, localStorage, or IndexedDB with minimal extra code. 371 | - **Familiar**: Similar to `useEffect`, but with a focus on minimal boilerplate. 372 | 373 | Get rid of manual loading checks and experience simpler, more maintainable React apps. Give **Loadable** a try today! -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/__tests__"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 7 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 8 | setupFilesAfterEnv: ["/__tests__/setupTests.ts"] 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tobq/loadable", 3 | "version": "2.0.3", 4 | "description": "A library for simplifying asynchronous operations in React", 5 | "main": "dist/loadable.js", 6 | "types": "dist/loadable.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "build": "tsc -p . --outDir dist --noEmit false", 10 | "patch": "npm run build && npm version patch", 11 | "release": "npm run patch && git push --follow-tags && npm publish" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/tobq/loadable.git" 16 | }, 17 | "author": "Tobi Akinyemi", 18 | "license": "MPL-2.0", 19 | "bugs": { 20 | "url": "https://github.com/tobq/loadable/issues" 21 | }, 22 | "homepage": "https://github.com/tobq/loadable#readme", 23 | "peerDependencies": { 24 | "react": "^19.0.0" 25 | }, 26 | "dependencies": { 27 | "@types/jest": "^29.5.14", 28 | "@types/react": "^19.0.0", 29 | "jest": "^29.7.0", 30 | "react-dom": "^19.0.0", 31 | "ts-jest": "^29.2.5", 32 | "typescript": "^5.7.2" 33 | }, 34 | "keywords": [ 35 | "async", 36 | "loadable", 37 | "suspense", 38 | "React", 39 | "Hooks", 40 | "Data Loading", 41 | "Asynchronous", 42 | "Loadable", 43 | "TypeScript", 44 | "State Management", 45 | "Data Fetching", 46 | "React Components", 47 | "Async/Await", 48 | "Loading State", 49 | "Error Handling" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /src/loadable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DependencyList, 3 | useCallback, 4 | useEffect, 5 | useRef, 6 | useState, 7 | } from "react" 8 | 9 | /** 10 | * Represents an integer timestamp (e.g. milliseconds since epoch or any monotonic count). 11 | * 12 | * @public 13 | */ 14 | export type TimeStamp = number 15 | 16 | /** 17 | * Returns the current time as a `TimeStamp`. 18 | * 19 | * @remarks 20 | * By default, this is just `Date.now()`. You can replace this with a custom 21 | * monotonic clock or high-resolution timer if desired. 22 | * 23 | * @returns The current time in milliseconds. 24 | * 25 | * @example 26 | * ```ts 27 | * const now = currentTimestamp() 28 | * console.log("The time is", now) 29 | * ``` 30 | * 31 | * @public 32 | */ 33 | export function currentTimestamp(): TimeStamp { 34 | return Date.now() 35 | } 36 | 37 | /** 38 | * Provides a stable function that, when called, creates (or re-creates) 39 | * an `AbortController` and returns its `signal`. 40 | * 41 | * @remarks 42 | * Each render, the same function reference is returned. Calling it effectively 43 | * aborts any in-flight request and starts a fresh `AbortController`. 44 | * 45 | * @returns A function which, when called, returns a fresh `AbortSignal`. 46 | * 47 | * @example 48 | * ```ts 49 | * function MyComponent() { 50 | * const createAbortSignal = useAbort() 51 | * 52 | * useEffect(() => { 53 | * const signal = createAbortSignal() 54 | * fetch('/api', { signal }).catch(e => { ... }) 55 | * }, []) 56 | * ... 57 | * } 58 | * ``` 59 | * 60 | * @public 61 | */ 62 | export function useAbort() { 63 | const abortControllerRef = useRef(null) 64 | return useCallback(() => { 65 | if (abortControllerRef.current) { 66 | // If we already had a controller, abort it 67 | abortControllerRef.current.abort() 68 | } 69 | abortControllerRef.current = new AbortController() 70 | return abortControllerRef.current.signal 71 | }, []) 72 | } 73 | 74 | // ------------------------------------------------------------------- 75 | // Loading Symbol + LoadingToken 76 | // ------------------------------------------------------------------- 77 | 78 | /** 79 | * A class-based token to represent a unique "loading" state instance. 80 | * 81 | * @remarks 82 | * Using a `LoadingToken` instead of the default `loading` symbol allows you 83 | * to store additional metadata—e.g., timestamps, request IDs, etc. This can 84 | * facilitate debugging or concurrency strategies that rely on distinct tokens. 85 | * 86 | * @example 87 | * ```ts 88 | * import { LoadingToken } from "./useLoadable" 89 | * 90 | * const token = new LoadingToken() 91 | * console.log("Loading started at:", token.startTime) 92 | * ``` 93 | * 94 | * @public 95 | */ 96 | export class LoadingToken { 97 | /** 98 | * Creates a new `LoadingToken`. 99 | * 100 | * @param startTime - When this token was created. Defaults to currentTimestamp(). 101 | */ 102 | constructor( 103 | public readonly startTime: TimeStamp = currentTimestamp() 104 | ) {} 105 | } 106 | 107 | /** 108 | * A unique symbol representing a "loading" state. 109 | * 110 | * @remarks 111 | * This symbol is used by default in loadable data when an async request is in-flight. 112 | * Using a symbol is a simple approach for representing loading without additional metadata. 113 | * 114 | * @public 115 | */ 116 | export const loading: unique symbol = Symbol("loading") 117 | 118 | /** 119 | * A union type that can be either the default `loading` symbol or a class-based `LoadingToken`. 120 | * 121 | * @public 122 | */ 123 | export type Loading = typeof loading | LoadingToken 124 | 125 | /** 126 | * Checks if the given value represents a "loading" state. 127 | * 128 | * @param value - The value to check. 129 | * @returns True if it’s either `loading` (symbol) or an instance of `LoadingToken`. 130 | * 131 | * @example 132 | * ```ts 133 | * if (isLoadingValue(loadable)) { 134 | * return 135 | * } 136 | * ``` 137 | * 138 | * @public 139 | */ 140 | export function isLoadingValue(value: unknown): value is Loading { 141 | return value === loading || value instanceof LoadingToken 142 | } 143 | 144 | // ------------------------------------------------------------------- 145 | // Error for load failures 146 | // ------------------------------------------------------------------- 147 | 148 | /** 149 | * Represents an error that occurred while loading or fetching data. 150 | * 151 | * @remarks 152 | * Wraps the original `cause` and optionally overrides the error message. 153 | * 154 | * @example 155 | * ```ts 156 | * // If a fetch fails, we might return a LoadError instead of a generic Error. 157 | * throw new LoadError(err, "Failed to load user info") 158 | * ``` 159 | * 160 | * @public 161 | */ 162 | export class LoadError extends Error { 163 | /** 164 | * Creates a new `LoadError`. 165 | * 166 | * @param cause - The underlying reason for the load failure (e.g., an Error object). 167 | * @param message - An optional descriptive message. Defaults to the cause’s message. 168 | */ 169 | constructor(public readonly cause: unknown, message?: string) { 170 | super( 171 | message ?? (cause instanceof Error ? cause.message : String(cause)) 172 | ) 173 | } 174 | } 175 | 176 | // ------------------------------------------------------------------- 177 | // Loadable types 178 | // ------------------------------------------------------------------- 179 | 180 | /** 181 | * A union type that can be either a "start" (e.g., `loading`) or a "result" (success or failure). 182 | * 183 | * @remarks 184 | * - `Start` usually represents a `Loading` state. 185 | * - `Result` can be the successful data type `T` or `LoadError`. 186 | * 187 | * @public 188 | */ 189 | export type Reaction = Start | Result 190 | 191 | /** 192 | * A `Loadable` can be: 193 | * - `loading` or `LoadingToken` (in-flight), 194 | * - a loaded value of type `T`, or 195 | * - a `LoadError` (failed). 196 | * 197 | * @public 198 | */ 199 | export type Loadable = Reaction 200 | 201 | /** 202 | * Extracts the loaded type from a `Loadable`, excluding `loading` or `LoadError`. 203 | * 204 | * @public 205 | */ 206 | export type Loaded = Exclude 207 | 208 | /** 209 | * Checks if a `Loadable` has fully loaded (i.e., is neither loading nor an error). 210 | * 211 | * @param loadable - The loadable value to check. 212 | * @returns True if it’s the successful data of type `T`. 213 | * 214 | * @public 215 | */ 216 | export function hasLoaded(loadable: Loadable): loadable is Loaded { 217 | return !isLoadingValue(loadable) && !loadFailed(loadable) 218 | } 219 | 220 | /** 221 | * Checks if a `Loadable` is a load failure (`LoadError`). 222 | * 223 | * @param loadable - The loadable value to check. 224 | * @returns True if it’s a `LoadError`. 225 | * 226 | * @public 227 | */ 228 | export function loadFailed(loadable: Loadable): loadable is LoadError { 229 | return loadable instanceof LoadError 230 | } 231 | 232 | /** 233 | * Applies a mapper function to a loadable if it’s successfully loaded, returning a new loadable. 234 | * 235 | * @remarks 236 | * If `loadable` is an error or loading, it’s returned unchanged. 237 | * 238 | * @param loadable - The original loadable. 239 | * @param mapper - A function that transforms the loaded data `T` into `R`. 240 | * @returns A new loadable with data of type `R`, or the same loading/error state. 241 | * 242 | * @public 243 | */ 244 | export function map(loadable: Loadable, mapper: (loaded: T) => R): Loadable { 245 | if (loadFailed(loadable)) return loadable 246 | if (isLoadingValue(loadable)) return loadable 247 | return mapper(loadable) 248 | } 249 | 250 | /** 251 | * Combines multiple loadables into one. If any are still loading or have failed, returns `loading`. 252 | * 253 | * @remarks 254 | * In reality, `all()` returns `loading` if ANY have not loaded. If all are loaded, it returns an array 255 | * of their loaded values (typed to match each item in `loadables`). 256 | * 257 | * @param loadables - The loadable values to combine. 258 | * @returns A single loadable that is `loading` if any item is not loaded, else an array of loaded items. 259 | * 260 | * @example 261 | * ```ts 262 | * const combined = all(userLoadable, postsLoadable, statsLoadable) 263 | * if (!hasLoaded(combined)) { 264 | * return 265 | * } 266 | * const [user, posts, stats] = combined 267 | * ``` 268 | * 269 | * @public 270 | */ 271 | export function all[]>(...loadables: T): Loadable<{ [K in keyof T]: Loaded }> { 272 | if (loadables.some(l => !hasLoaded(l))) { 273 | return loading 274 | } 275 | return loadables.map(l => l) as { [K in keyof T]: Loaded } 276 | } 277 | 278 | /** 279 | * Converts a loadable to `undefined` if not fully loaded, or the loaded value otherwise. 280 | * 281 | * @param loadable - The loadable value to unwrap. 282 | * @returns `T` if loaded, otherwise `undefined`. 283 | * 284 | * @public 285 | */ 286 | export function toOptional(loadable: Loadable): T | undefined { 287 | return hasLoaded(loadable) ? loadable : undefined 288 | } 289 | 290 | /** 291 | * Returns the loaded value if `loadable` is fully loaded, otherwise `defaultValue`. 292 | * 293 | * @param loadable - The loadable value to unwrap. 294 | * @param defaultValue - The fallback if loadable is not loaded. 295 | * @returns The loaded `T` or the provided `defaultValue`. 296 | * 297 | * @public 298 | */ 299 | export function orElse(loadable: Loadable, defaultValue: R): T | R { 300 | return hasLoaded(loadable) ? loadable : defaultValue 301 | } 302 | 303 | /** 304 | * Checks if a loadable is fully loaded AND not null/undefined. 305 | * 306 | * @param loadable - A loadable that could be `null` or `undefined` once loaded. 307 | * @returns True if the loadable is successfully loaded and non-nullish. 308 | * 309 | * @public 310 | */ 311 | export function isUsable(loadable: Loadable): loadable is T { 312 | return hasLoaded(loadable) && loadable != null 313 | } 314 | 315 | // ------------------------------------------------------------------- 316 | // Basic fetcher type 317 | // ------------------------------------------------------------------- 318 | 319 | /** 320 | * A function type that fetches data and returns a promise, using an `AbortSignal`. 321 | * 322 | * @param signal - The `AbortSignal` to handle cancellations. 323 | * @returns A promise resolving to the fetched data of type `T`. 324 | * 325 | * @public 326 | */ 327 | export type Fetcher = (signal: AbortSignal) => Promise 328 | 329 | // ------------------------------------------------------------------- 330 | // Caching shapes 331 | // ------------------------------------------------------------------- 332 | 333 | /** 334 | * Defines the shape of a cache option with a key and an optional store. 335 | * 336 | * @public 337 | */ 338 | export interface CacheOption { 339 | /** 340 | * The key to store in the cache (e.g., "myUserData"). 341 | */ 342 | key: string 343 | /** 344 | * The store used for caching. Defaults to `"localStorage"`. 345 | */ 346 | store?: "memory" | "localStorage" | "indexedDB" 347 | } 348 | 349 | /** 350 | * Parses a `cache` field that could be a string or an object, returning a normalized object. 351 | * 352 | * @param cache - Either a string or `{ key, store }`. 353 | * @returns An object with `key` and `store`. 354 | * 355 | * @internal 356 | */ 357 | function parseCacheOption( 358 | cache?: string | CacheOption 359 | ): { key?: string; store: "memory" | "localStorage" | "indexedDB" } { 360 | if (!cache) { 361 | return { key: undefined, store: "localStorage" } 362 | } 363 | if (typeof cache === "string") { 364 | // If user passed a string, that is the cache key, default to localStorage 365 | return { key: cache, store: "localStorage" } 366 | } 367 | // Otherwise, user passed an object { key, store? } 368 | return { 369 | key: cache.key, 370 | store: cache.store ?? "localStorage", 371 | } 372 | } 373 | 374 | // ------------------------------------------------------------------- 375 | // Our caching utilities 376 | // ------------------------------------------------------------------- 377 | 378 | /** @internal */ 379 | const memoryCache = new Map() 380 | 381 | /** 382 | * Reads data from the specified cache store. 383 | * 384 | * @internal 385 | * @param key - The cache key. 386 | * @param store - Which store to use ("memory", "localStorage", or "indexedDB"). 387 | * @returns The cached data or `undefined` if not found. 388 | */ 389 | async function readCache( 390 | key: string, 391 | store: "memory" | "localStorage" | "indexedDB" 392 | ): Promise { 393 | switch (store) { 394 | case "memory": { 395 | return memoryCache.get(key) as T | undefined 396 | } 397 | case "localStorage": { 398 | const json = window.localStorage.getItem(key) 399 | if (!json) return undefined 400 | try { 401 | return JSON.parse(json) as T 402 | } catch { 403 | return undefined 404 | } 405 | } 406 | case "indexedDB": { 407 | return await readFromIndexedDB(key) 408 | } 409 | } 410 | } 411 | 412 | /** 413 | * Writes data to the specified cache store. 414 | * 415 | * @internal 416 | * @param key - The cache key. 417 | * @param data - The data to store. 418 | * @param store - The store to use. 419 | */ 420 | async function writeCache( 421 | key: string, 422 | data: T, 423 | store: "memory" | "localStorage" | "indexedDB" 424 | ): Promise { 425 | switch (store) { 426 | case "memory": { 427 | memoryCache.set(key, data) 428 | break 429 | } 430 | case "localStorage": { 431 | window.localStorage.setItem(key, JSON.stringify(data)) 432 | break 433 | } 434 | case "indexedDB": { 435 | await writeToIndexedDB(key, data) 436 | break 437 | } 438 | } 439 | } 440 | 441 | /** 442 | * Opens (and initializes) an IndexedDB database named "myReactCacheDB" with an object store "idbCache". 443 | * 444 | * @internal 445 | * @returns A promise resolving to the opened IDBDatabase. 446 | */ 447 | function openCacheDB(): Promise { 448 | return new Promise((resolve, reject) => { 449 | const request = indexedDB.open("myReactCacheDB", 1) 450 | request.onupgradeneeded = () => { 451 | const db = request.result 452 | if (!db.objectStoreNames.contains("idbCache")) { 453 | db.createObjectStore("idbCache") 454 | } 455 | } 456 | request.onsuccess = () => resolve(request.result) 457 | request.onerror = () => reject(request.error) 458 | }) 459 | } 460 | 461 | /** 462 | * Reads an item from the "idbCache" store in our "myReactCacheDB" IndexedDB. 463 | * 464 | * @internal 465 | */ 466 | async function readFromIndexedDB(key: string): Promise { 467 | const db = await openCacheDB() 468 | return new Promise((resolve, reject) => { 469 | const tx = db.transaction("idbCache", "readonly") 470 | const store = tx.objectStore("idbCache") 471 | const getReq = store.get(key) 472 | getReq.onsuccess = () => resolve(getReq.result) 473 | getReq.onerror = () => reject(getReq.error) 474 | }) 475 | } 476 | 477 | /** 478 | * Writes an item to the "idbCache" store in our "myReactCacheDB" IndexedDB. 479 | * 480 | * @internal 481 | */ 482 | async function writeToIndexedDB(key: string, data: T): Promise { 483 | const db = await openCacheDB() 484 | return new Promise((resolve, reject) => { 485 | const tx = db.transaction("idbCache", "readwrite") 486 | const store = tx.objectStore("idbCache") 487 | const putReq = store.put(data, key) 488 | putReq.onsuccess = () => resolve() 489 | putReq.onerror = () => reject(putReq.error) 490 | }) 491 | } 492 | 493 | // ------------------------------------------------------------------- 494 | // Options for useLoadable 495 | // ------------------------------------------------------------------- 496 | 497 | /** 498 | * The options object for `useLoadable`. 499 | * 500 | * @typeParam T - The data type we expect to load. 501 | * 502 | * @public 503 | */ 504 | export interface UseLoadableOptions { 505 | /** 506 | * A prefetched loadable value, if available (used instead of calling the fetcher). 507 | */ 508 | prefetched?: Loadable 509 | /** 510 | * An optional callback for load errors. Called with the raw error object. 511 | */ 512 | onError?: (error: unknown) => void 513 | /** 514 | * If true, once we have a loaded value, do **not** revert to `loading` 515 | * on subsequent fetches; instead, keep the old value until the new fetch 516 | * finishes or fails. 517 | */ 518 | hideReload?: boolean 519 | /** 520 | * Caching configuration. Can be: 521 | * - A string: used as the cache key (store defaults to `"localStorage"`). 522 | * - An object: `{ key: string, store?: "memory" | "localStorage" | "indexedDB" }`. 523 | */ 524 | cache?: string | CacheOption 525 | } 526 | 527 | // ------------------------------------------------------------------- 528 | // A custom hook for state with timestamps 529 | // ------------------------------------------------------------------- 530 | 531 | /** 532 | * A hook that manages a piece of state (`T`) alongside a timestamp, allowing you 533 | * to ignore stale updates with older timestamps. 534 | * 535 | * @remarks 536 | * Internally, it stores the current `value` plus a `loadStart` timestamp. Each time 537 | * you set a new value, you can provide an optional new timestamp. If that timestamp 538 | * is older than the current state's `loadStart`, the update is ignored. 539 | * 540 | * @param initial - The initial state value. 541 | * @returns A tuple: `[value, setValue, loadStart]`. 542 | * 543 | * @example 544 | * ```ts 545 | * const [myValue, setMyValue, lastUpdated] = useLatestState(0) 546 | * 547 | * function handleUpdate(newVal: number) { 548 | * // We'll pass a timestamp 549 | * setMyValue(newVal, performance.now()) 550 | * } 551 | * ``` 552 | * 553 | * @public 554 | */ 555 | export function useLatestState( 556 | initial: T 557 | ): [T, (value: T | ((current: T) => T), loadStart?: TimeStamp) => void, TimeStamp] { 558 | const [value, setValue] = useState<{ 559 | value: T 560 | loadStart: TimeStamp 561 | }>({ 562 | value: initial, 563 | loadStart: 0, 564 | }) 565 | 566 | function updateValue( 567 | newValue: T | ((current: T) => T), 568 | loadStart: TimeStamp = currentTimestamp() 569 | ) { 570 | setValue(current => { 571 | if (current.loadStart > loadStart) { 572 | // Ignore older updates 573 | return current 574 | } 575 | const nextValue = 576 | typeof newValue === "function" 577 | ? (newValue as (c: T) => T)(current.value) 578 | : newValue 579 | return { 580 | value: nextValue, 581 | loadStart, 582 | } 583 | }) 584 | } 585 | 586 | return [value.value, updateValue, value.loadStart] 587 | } 588 | 589 | // ------------------------------------------------------------------- 590 | // For debugging (optional) 591 | // ------------------------------------------------------------------- 592 | 593 | /** 594 | * A Set of timestamps indicating which requests are currently in-flight. 595 | * 596 | * @remarks 597 | * This is used internally for debugging and to signal "prerenderReady" when no requests remain. 598 | * Attach it to `window` in dev environments if desired. 599 | * 600 | * @internal 601 | */ 602 | const currentlyLoading = new Set() 603 | // @ts-ignore 604 | if (typeof window !== "undefined") { 605 | ;(window as any).currentlyLoading = currentlyLoading 606 | } 607 | 608 | // ------------------------------------------------------------------- 609 | // Overloads for useLoadable 610 | // ------------------------------------------------------------------- 611 | 612 | /** 613 | * Overload: `useLoadable(waitable, readyCondition, fetcher, dependencies, optionsOrOnError?)` 614 | */ 615 | export function useLoadable( 616 | waitable: W, 617 | readyCondition: (loaded: W) => boolean, 618 | fetcher: (loaded: W, abort: AbortSignal) => Promise, 619 | dependencies: DependencyList, 620 | optionsOrOnError?: ((e: unknown) => void) | UseLoadableOptions 621 | ): Loadable 622 | 623 | /** 624 | * Overload: `useLoadable(fetcher, deps, options?)` 625 | */ 626 | export function useLoadable( 627 | fetcher: Fetcher, 628 | deps: DependencyList, 629 | options?: UseLoadableOptions 630 | ): Loadable 631 | 632 | /** 633 | * The core hook that returns a `Loadable` by calling an async fetcher. 634 | * 635 | * @remarks 636 | * Has two main usage patterns: 637 | * 1. **Waitable** form: You pass a "waitable" value plus a `readyCondition`, and a `fetcher`. 638 | * - The effect only runs when `readyCondition(waitable)` is true. 639 | * - If `hideReload` is false, it will revert to loading each time the waitable changes. 640 | * 641 | * 2. **Simple** form: You pass just a `fetcher`, dependencies, and optional `UseLoadableOptions`. 642 | * - The fetcher is called whenever dependencies change. 643 | * - The result is stored in a loadable: `loading` until success or `LoadError` on failure. 644 | * 645 | * Caching: 646 | * - If `cache` is provided (string or `{ key, store }`), it tries to read from that cache first. 647 | * If found, returns it immediately. Then (optionally) re-fetches in the background, or 648 | * according to `hideReload`. 649 | * 650 | * @typeParam T - The successful data type when using the simple form. 651 | * @typeParam W - The waitable type (for advanced usage). 652 | * @typeParam R - The successful data type when using the waitable form. 653 | * 654 | * @public 655 | */ 656 | export function useLoadable( 657 | fetcherOrWaitable: Fetcher | W, 658 | depsOrReadyCondition: DependencyList | ((loaded: W) => boolean), 659 | optionsOrFetcher?: 660 | | UseLoadableOptions 661 | | ((loaded: W, abort: AbortSignal) => Promise), 662 | dependencies: DependencyList = [], 663 | lastParam?: ((e: unknown) => void) | UseLoadableOptions 664 | ): Loadable | Loadable { 665 | // ============================ 666 | // CASE 1: waitable + readyCondition + fetcher 667 | // ============================ 668 | if (typeof depsOrReadyCondition === "function") { 669 | const waitable = fetcherOrWaitable as W 670 | const readyCondition = depsOrReadyCondition as (loaded: W) => boolean 671 | const fetcher = optionsOrFetcher as ( 672 | loaded: W, 673 | abort: AbortSignal 674 | ) => Promise 675 | 676 | let onErrorCb: ((e: unknown) => void) | undefined 677 | let hideReload = false 678 | let cacheObj: ReturnType = { 679 | key: undefined, 680 | store: "localStorage", 681 | } 682 | 683 | if (typeof lastParam === "function") { 684 | onErrorCb = lastParam 685 | } else if (lastParam && typeof lastParam === "object") { 686 | onErrorCb = lastParam.onError 687 | hideReload = !!lastParam.hideReload 688 | cacheObj = parseCacheOption(lastParam.cache) 689 | } 690 | 691 | const [value, setValue] = useLatestState>(loading) 692 | const abort = useAbort() 693 | 694 | const ready = readyCondition(waitable) 695 | 696 | useEffect(() => { 697 | const startTime = currentTimestamp() 698 | 699 | // If hideReload=false or not yet loaded, revert to 'loading' 700 | if (!hideReload || !hasLoaded(value)) { 701 | setValue(loading, startTime) 702 | } 703 | 704 | if (ready) { 705 | // Before fetching, try reading from cache (if provided) 706 | if (cacheObj.key) { 707 | // Attempt to read 708 | ;(async () => { 709 | const cachedData = await readCache( 710 | cacheObj.key!, 711 | cacheObj.store 712 | ) 713 | if (cachedData !== undefined) { 714 | // We found a valid cached value 715 | // You could do stale-while-revalidate or just set it: 716 | setValue(cachedData, startTime) 717 | } 718 | doFetch() 719 | })() 720 | } else { 721 | doFetch() 722 | } 723 | 724 | function doFetch() { 725 | currentlyLoading.add(startTime) 726 | const signal = abort() 727 | fetcher(waitable, signal) 728 | .then(result => { 729 | // On success, write to cache if key 730 | if (cacheObj.key) { 731 | writeCache(cacheObj.key, result, cacheObj.store).catch( 732 | console.error 733 | ) 734 | } 735 | setValue(result, startTime) 736 | }) 737 | .catch(e => { 738 | onErrorCb?.(e) 739 | setValue(new LoadError(e), startTime) 740 | }) 741 | .finally(() => { 742 | currentlyLoading.delete(startTime) 743 | if ( 744 | currentlyLoading.size === 0 && 745 | typeof window !== "undefined" && 746 | "prerenderReady" in window 747 | ) { 748 | ;(window as any).prerenderReady = true 749 | } 750 | }) 751 | } 752 | } 753 | 754 | return () => { 755 | abort() 756 | currentlyLoading.delete(startTime) 757 | } 758 | }, [...dependencies, ready, hideReload]) 759 | 760 | return value 761 | } 762 | 763 | // ============================ 764 | // CASE 2: fetcher + deps + options 765 | // ============================ 766 | const fetcher = fetcherOrWaitable as Fetcher 767 | const deps = depsOrReadyCondition as DependencyList 768 | const options = optionsOrFetcher as UseLoadableOptions | undefined 769 | 770 | // Parse the cache field 771 | const { key: cacheKey, store: cacheStore } = parseCacheOption(options?.cache) 772 | 773 | // We'll piggyback on the waitable approach, with a "dummy" waitable always ready 774 | return useLoadable( 775 | loading, 776 | () => true, 777 | async (_ignored, signal) => { 778 | // 779 | // 1) Attempt to read from cache (if we have cacheKey) 780 | // 781 | if (cacheKey) { 782 | const cachedData = await readCache(cacheKey, cacheStore) 783 | if (cachedData !== undefined) { 784 | // Found a valid cached value 785 | return cachedData 786 | } 787 | } 788 | 789 | // 790 | // 2) If there's a prefetched loadable 791 | // 792 | if (options?.prefetched !== undefined) { 793 | if (options.prefetched === loading) { 794 | return fetcher(signal) 795 | } else if (options.prefetched instanceof LoadError) { 796 | throw options.prefetched 797 | } else if (isLoadingValue(options.prefetched)) { 798 | // e.g. a LoadingToken 799 | return fetcher(signal) 800 | } else { 801 | // Otherwise it's a T 802 | if (cacheKey) { 803 | await writeCache(cacheKey, options.prefetched, cacheStore) 804 | } 805 | return options.prefetched 806 | } 807 | } 808 | 809 | // 810 | // 3) Normal fetch 811 | // 812 | const data = await fetcher(signal) 813 | if (cacheKey) { 814 | await writeCache(cacheKey, data, cacheStore) 815 | } 816 | return data 817 | }, 818 | deps, 819 | { 820 | onError: options?.onError, 821 | hideReload: options?.hideReload, 822 | } 823 | ) as Loadable 824 | } 825 | 826 | // ------------------------------------------------------------------- 827 | // useThen + useAllThen 828 | // ------------------------------------------------------------------- 829 | 830 | /** 831 | * A hook that waits for a `loadable` to finish, then calls another async `fetcher`. 832 | * 833 | * @remarks 834 | * If `loadable` is still loading or has failed, this hook returns the same `loadable` state. 835 | * Otherwise, if `loadable` is loaded, it calls `fetcher(loadedValue)` and returns the result 836 | * as a new `Loadable`. 837 | * 838 | * @param loadable - The initial loadable value. 839 | * @param fetcher - A function that takes the successfully loaded data plus an abort signal, returning a promise. 840 | * @param dependencies - An optional list of dependencies to trigger re-runs. Defaults to `[hasLoaded(loadable)]`. 841 | * @param options - Optional `UseLoadableOptions` for error handling, caching, etc. 842 | * @returns A `Loadable` that is `loading` until the chained fetch finishes, or a `LoadError` if it fails. 843 | * 844 | * @example 845 | * ```ts 846 | * const user = useLoadable(() => fetchUser(userId), [userId]) 847 | * const posts = useThen(user, (u) => fetchPostsForUser(u.id)) 848 | * ``` 849 | * 850 | * @public 851 | */ 852 | export function useThen( 853 | loadable: Loadable, 854 | fetcher: (loaded: T, abort: AbortSignal) => Promise, 855 | dependencies: DependencyList = [hasLoaded(loadable)], 856 | options?: UseLoadableOptions 857 | ): Loadable { 858 | return useLoadable( 859 | loadable, 860 | l => hasLoaded(l), 861 | async (val, abort) => map(val, v => fetcher(v, abort)), 862 | dependencies, 863 | options 864 | ) 865 | } 866 | 867 | /** @internal */ 868 | type UnwrapLoadable = T extends Loadable ? U : never 869 | /** @internal */ 870 | type LoadableParameters[]> = { 871 | [K in keyof T]: UnwrapLoadable 872 | } 873 | 874 | /** 875 | * A hook that waits for multiple loadables to finish, then calls a `fetcher` using all their loaded values. 876 | * 877 | * @remarks 878 | * Internally, it calls `all(...loadables)`. If any loadable is still loading or fails, the combined is `loading`. 879 | * Once all are loaded, calls `fetcher(...loadedValues, signal)` and returns a `Loadable`. 880 | * 881 | * @param loadables - An array (spread) of loadable values, e.g. `[user, stats, posts]`. 882 | * @param fetcher - A function that takes each loaded value plus an `AbortSignal`. 883 | * @param dependencies - An optional list of dependencies to re-run the effect. Defaults to the loadables array. 884 | * @param options - Optional config for error handling, caching, etc. 885 | * @returns A loadable result of type `R`. 886 | * 887 | * @example 888 | * ```ts 889 | * const user = useLoadable(fetchUser, []) 890 | * const stats = useLoadable(fetchStats, []) 891 | * 892 | * const combined = useAllThen( 893 | * [user, stats], 894 | * (u, s, signal) => fetchDashboard(u, s, signal), 895 | * [] 896 | * ) 897 | * ``` 898 | * 899 | * @public 900 | */ 901 | export function useAllThen[], R>( 902 | loadables: [...T], 903 | fetcher: (...args: [...LoadableParameters, AbortSignal]) => Promise, 904 | dependencies: DependencyList = loadables, 905 | options?: UseLoadableOptions 906 | ): Loadable { 907 | const combined = all(...loadables) 908 | return useThen( 909 | combined, 910 | (vals, signal) => fetcher(...(vals as LoadableParameters), signal), 911 | dependencies, 912 | options 913 | ) 914 | } 915 | 916 | // ------------------------------------------------------------------- 917 | // useLoadableWithCleanup 918 | // ------------------------------------------------------------------- 919 | 920 | /** 921 | * Overload: `useLoadableWithCleanup(waitable, readyCondition, fetcher, deps, optionsOrOnError?)`. 922 | */ 923 | export function useLoadableWithCleanup( 924 | waitable: W, 925 | readyCondition: (loaded: W) => boolean, 926 | fetcher: (loaded: W, abort: AbortSignal) => Promise, 927 | dependencies: DependencyList, 928 | optionsOrOnError?: ((e: unknown) => void) | UseLoadableOptions 929 | ): [Loadable, () => void] 930 | 931 | /** 932 | * Overload: `useLoadableWithCleanup(fetcher, deps, options?)`. 933 | */ 934 | export function useLoadableWithCleanup( 935 | fetcher: Fetcher, 936 | deps: DependencyList, 937 | options?: UseLoadableOptions 938 | ): [Loadable, () => void] 939 | 940 | /** 941 | * A variant of `useLoadable` that returns a `[Loadable, cleanupFunc]` tuple. 942 | * 943 | * @remarks 944 | * This lets you manually call `cleanupFunc()` to abort any in-flight request, 945 | * instead of waiting for an unmount or effect re-run. 946 | * 947 | * @returns A tuple: `[Loadable, cleanupFunc]`. 948 | * 949 | * @example 950 | * ```ts 951 | * const [userLoadable, cleanup] = useLoadableWithCleanup(fetchUser, []) 952 | * 953 | * // Manually abort the current fetch: 954 | * cleanup() 955 | * ``` 956 | * 957 | * @public 958 | */ 959 | export function useLoadableWithCleanup( 960 | fetcherOrWaitable: Fetcher | W, 961 | depsOrReadyCondition: DependencyList | ((loaded: W) => boolean), 962 | optionsOrFetcher?: 963 | | UseLoadableOptions 964 | | ((loaded: W, abort: AbortSignal) => Promise), 965 | dependencies: DependencyList = [], 966 | lastParam?: ((e: unknown) => void) | UseLoadableOptions 967 | ): [Loadable | Loadable, () => void] { 968 | const [value, setValue] = useLatestState>(loading) 969 | const abortControllerRef = useRef(null) 970 | 971 | let isCase1 = false 972 | let waitableVal: W | undefined 973 | let readyFn: ((w: W) => boolean) | undefined 974 | let actualFetcher: ((w: W, signal: AbortSignal) => Promise) | undefined 975 | let hideReload = false 976 | let onErrorCb: ((e: unknown) => void) | undefined 977 | let deps: DependencyList 978 | let cacheObj = parseCacheOption() 979 | 980 | if (typeof depsOrReadyCondition === "function") { 981 | // CASE 1 982 | isCase1 = true 983 | waitableVal = fetcherOrWaitable as W 984 | readyFn = depsOrReadyCondition as (w: W) => boolean 985 | actualFetcher = optionsOrFetcher as (w: W, signal: AbortSignal) => Promise 986 | deps = dependencies 987 | 988 | if (typeof lastParam === "function") { 989 | onErrorCb = lastParam 990 | } else if (lastParam && typeof lastParam === "object") { 991 | onErrorCb = lastParam.onError 992 | hideReload = !!lastParam.hideReload 993 | cacheObj = parseCacheOption(lastParam.cache) 994 | } 995 | } else { 996 | // CASE 2 997 | const fetcher = fetcherOrWaitable as Fetcher 998 | deps = depsOrReadyCondition as DependencyList 999 | const options = optionsOrFetcher as UseLoadableOptions | undefined 1000 | 1001 | onErrorCb = options?.onError 1002 | hideReload = !!options?.hideReload 1003 | cacheObj = parseCacheOption(options?.cache) 1004 | // always "ready" 1005 | readyFn = () => true 1006 | 1007 | // The actual fetcher that either reads from cache or calls the original fetcher 1008 | actualFetcher = async (_ignored: W, signal: AbortSignal) => { 1009 | // Read from cache if possible 1010 | if (cacheObj.key) { 1011 | const cachedData = await readCache(cacheObj.key, cacheObj.store) 1012 | if (cachedData !== undefined) { 1013 | return cachedData 1014 | } 1015 | } 1016 | // If prefetched is available 1017 | if (options?.prefetched !== undefined) { 1018 | if (options.prefetched === loading) { 1019 | return fetcher(signal) 1020 | } else if (options.prefetched instanceof LoadError) { 1021 | throw options.prefetched 1022 | } else if (isLoadingValue(options.prefetched)) { 1023 | return fetcher(signal) 1024 | } else { 1025 | // T 1026 | if (cacheObj.key) { 1027 | await writeCache(cacheObj.key, options.prefetched, cacheObj.store) 1028 | } 1029 | return options.prefetched 1030 | } 1031 | } 1032 | // Normal fetch 1033 | const data = await fetcher(signal) 1034 | if (cacheObj.key) { 1035 | await writeCache(cacheObj.key, data, cacheObj.store) 1036 | } 1037 | return data 1038 | } 1039 | } 1040 | 1041 | useEffect(() => { 1042 | const startTime = currentTimestamp() 1043 | const isReady = readyFn?.(waitableVal as W) ?? true 1044 | 1045 | // If hideReload=false or current is not loaded, revert to 'loading' 1046 | if (!hideReload || !hasLoaded(value)) { 1047 | setValue(loading, startTime) 1048 | } 1049 | 1050 | if (isReady && actualFetcher) { 1051 | abortControllerRef.current = new AbortController() 1052 | const signal = abortControllerRef.current.signal 1053 | 1054 | currentlyLoading.add(startTime) 1055 | actualFetcher(waitableVal as W, signal) 1056 | .then(result => { 1057 | setValue(result, startTime) 1058 | }) 1059 | .catch(e => { 1060 | onErrorCb?.(e) 1061 | setValue(new LoadError(e), startTime) 1062 | }) 1063 | .finally(() => { 1064 | currentlyLoading.delete(startTime) 1065 | if ( 1066 | currentlyLoading.size === 0 && 1067 | typeof window !== "undefined" && 1068 | "prerenderReady" in window 1069 | ) { 1070 | ;(window as any).prerenderReady = true 1071 | } 1072 | }) 1073 | } 1074 | 1075 | return () => { 1076 | abortControllerRef.current?.abort() 1077 | currentlyLoading.delete(startTime) 1078 | } 1079 | }, [ 1080 | isCase1, 1081 | waitableVal, 1082 | readyFn, 1083 | actualFetcher, 1084 | hideReload, 1085 | onErrorCb, 1086 | value, 1087 | setValue, 1088 | ...deps, 1089 | ]) 1090 | 1091 | /** 1092 | * Cancels any current request immediately. This is the second element 1093 | * in the returned tuple from `useLoadableWithCleanup`. 1094 | */ 1095 | const cleanupFunc = useCallback(() => { 1096 | abortControllerRef.current?.abort() 1097 | }, []) 1098 | 1099 | return [value, cleanupFunc] 1100 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from "react"; 2 | 3 | export type TimeStamp = number 4 | 5 | export function currentTimestamp(): TimeStamp { 6 | return new Date().valueOf() 7 | } 8 | 9 | export function useAbort(): () => AbortSignal { 10 | const abortController = useRef(undefined) 11 | 12 | const abort = () => { 13 | const current = abortController.current 14 | const next = new AbortController() 15 | abortController.current = next 16 | if (current) { 17 | current.abort() 18 | // console.debug("Triggered abort signal and replaced abort controller") 19 | } 20 | return next.signal 21 | } 22 | 23 | useEffect(() => { 24 | return () => { 25 | abort() 26 | } 27 | }, []) 28 | 29 | return abort 30 | } 31 | 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "commonjs", 5 | "target": "es6", 6 | "declaration": true, 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["src"] 20 | } --------------------------------------------------------------------------------