@tobq/loadable
Version:
A library for simplifying asynchronous operations in React
373 lines (279 loc) • 11.5 kB
Markdown
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`).
---
# Loadable
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.
## Table of Contents
- [Overview](#overview)
- [Core Concepts](#core-concepts)
- [Quick Start](#quick-start)
- [Basic Example](#basic-example)
- [Chaining Async Calls](#chaining-async-calls)
- [Fetching Multiple Loadables](#fetching-multiple-loadables)
- [Hooks & Utilities](#hooks--utilities)
- [Migrating Common Patterns](#migrating-common-patterns)
- [Error Handling](#error-handling)
- [Caching](#advanced-caching)
- [Comparison with Alternatives](#comparison-with-alternatives)
- [Why Loadable?](#why-loadable)
---
## Overview
React doesn’t come with an official solution for data fetching, which often leads to repetitive patterns:
- **Booleans** to track loading states.
- **Conditionals** to check null data or thrown errors.
- **Cleanups** to avoid updating unmounted components.
**Loadable** unifies these concerns:
- A **single type** encapsulates “loading,” “loaded,” and “error” states.
- Easy-to-use **hooks** (`useLoadable`, `useThen`, etc.) to chain and compose fetches.
- Automatic **cancellation** of in-flight requests to avoid stale updates.
---
## Installation
```bash
npm install @tobq/loadable
# or
yarn add @tobq/loadable
```
---
## Core Concepts
### Loadable Type
A `Loadable<T>` can be:
1. **Loading**: represented by a special `loading` symbol (or an optional “loading token”).
2. **Loaded**: the actual data of type `T`.
3. **Failed**: a `LoadError` object describing the failure.
This single union type replaces the typical `isLoading` / `data` / `error` triple.
---
## Quick Start
### Basic Example
Below is a minimal comparison of how you might load data **with** and **without** Loadable:
#### Without Loadable
```tsx
function Properties() {
const [properties, setProperties] = useState<Property[] | null>(null)
const [isLoading, setLoading] = useState(true)
useEffect(() => {
getPropertiesAsync()
.then((props) => {
setProperties(props)
setLoading(false)
})
.catch(console.error)
}, [])
if (isLoading || !properties) {
return <div>Loading…</div>
}
return (
<div>
{properties.map((p) => (
<PropertyCard key={p.id} property={p} />
))}
</div>
)
}
```
#### With Loadable
```tsx
import { useLoadable, hasLoaded } from "@tobq/loadable"
function Properties() {
const properties = useLoadable(() => getPropertiesAsync(), [])
if (!hasLoaded(properties)) {
return <div>Loading…</div>
}
return (
<div>
{properties.map((p) => (
<PropertyCard key={p.id} property={p} />
))}
</div>
)
}
```
- No “isLoading” boolean or separate error state needed.
- `properties` starts as `loading` and becomes the loaded data when ready.
- `hasLoaded(properties)` ensures the data is neither loading nor an error.
### Chaining Async Calls
```tsx
import { useLoadable, useThen, hasLoaded } from "@tobq/loadable"
function UserProfile({ userId }) {
// First load the user
const user = useLoadable(() => fetchUser(userId), [userId])
// Then load the user’s posts, using the loaded `user`
const posts = useThen(user, (u) => fetchPostsForUser(u.id))
if (!hasLoaded(user)) return <div>Loading user…</div>
if (!hasLoaded(posts)) return <div>Loading posts…</div>
return (
<div>
<h1>{user.name}</h1>
{posts.map((p) => (
<Post key={p.id} {...p} />
))}
</div>
)
}
```
### Fetching Multiple Loadables
Use `useAllThen` or the `all()` helper to coordinate multiple loadable values:
```tsx
import { useAllThen, hasLoaded } from "@tobq/loadable"
function Dashboard() {
const user = useLoadable(() => fetchUser(), [])
const stats = useLoadable(() => fetchStats(), [])
// Wait for both to be loaded, then call `fetchDashboardSummary()`
const summary = useAllThen(
[user, stats],
(u, s, signal) => fetchDashboardSummary(u.id, s.range, signal),
[]
)
if (!hasLoaded(summary)) return <div>Loading Dashboard…</div>
return <DashboardSummary {...summary} />
}
```
---
## Hooks & Utilities
- **`useLoadable(fetcher, deps, options?)`**
Returns a `Loadable<T>` by calling the async `fetcher`.
- **`useThen(loadable, fetcher, deps?, options?)`**
Waits for a loadable to finish, then chains another async call.
- **`useAllThen(loadables, fetcher, deps?, options?)`**
Waits for multiple loadables to finish, then calls `fetcher`.
- **`useLoadableWithCleanup(fetcher, deps, options?)`**
Like `useLoadable`, but returns `[Loadable<T>, cleanupFunc]` for manual aborts.
**Helpers** include:
- `hasLoaded(loadable)`
- `loadFailed(loadable)`
- `all(...)`
- `map(...)`
- `toOptional(...)`
- `orElse(...)`
- `isUsable(...)`
---
## Migrating Common Patterns
### Manual Loading States
**Before**:
```tsx
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
setLoading(true)
getData()
.then(res => setData(res))
.catch(err => setError(err))
.finally(() => setLoading(false))
}, [])
```
**After**:
```tsx
import { useLoadable, loadFailed, hasLoaded } from "@tobq/loadable"
const loadable = useLoadable(() => getData(), [])
if (loadFailed(loadable)) {
return <ErrorComponent error={loadable} />
}
if (!hasLoaded(loadable)) {
return <LoadingSpinner />
}
return <RenderData data={loadable} />
```
### Chaining Fetches
**Before**:
```tsx
useEffect(() => {
let cancelled = false
getUser().then(user => {
if (!cancelled) {
setUser(user)
getUserPosts(user.id).then(posts => {
if (!cancelled) {
setPosts(posts)
}
})
}
})
return () => { cancelled = true }
}, [])
```
**After**:
```tsx
const user = useLoadable(() => getUser(), [])
const posts = useThen(user, (u) => getUserPosts(u.id))
```
---
## Error Handling
By default, if a fetch fails, `useLoadable` returns a `LoadError`. You can handle or display it:
```tsx
const users = useLoadable(fetchUsers, [], {
onError: (error) => console.error("Error loading users:", error)
})
if (loadFailed(users)) {
return <ErrorBanner error={users} />
}
if (!hasLoaded(users)) {
return <Spinner />
}
return <UsersList items={users} />
```
---
## Advanced: Symbol vs. Class-based Loading Token
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:
```ts
import { LoadingToken, newLoadingToken } from "@tobq/loadable"
const token = newLoadingToken() // brand-new token with a timestamp
```
You can store additional metadata (like `startTime`) in the token. Internally, the library handles both `loading` (symbol) and `LoadingToken` interchangeably.
---
## Advanced: Caching
Loadable supports optional caching of fetched data, allowing you to bypass refetching if the data already exists in **memory**, **localStorage**, or **indexedDB**.
### Using `cache` in `useLoadable`
Within the **`options`** object passed to `useLoadable`, you can include:
```ts
cache?: string | {
key: string
store?: "memory" | "localStorage" | "indexedDB"
}
```
1. **String** (e.g. `cache: "myDataKey"`):
- Interpreted as the cache key, defaults to `"localStorage"` for storage.
2. **Object** (e.g. `cache: { key: "myDataKey", store: "indexedDB" }`):
- Fully specifies both the cache key and the storage backend.
#### Example
```tsx
function MyComponent() {
// #1: Simple string for cache => defaults to localStorage
const dataLoadable = useLoadable(fetchMyData, [], {
cache: "myDataKey",
hideReload: false,
onError: (err) => console.error("Load error:", err),
})
if (dataLoadable === loading) {
return <div>Loading...</div>
}
if (!hasLoaded(dataLoadable)) {
// must be an error
return <div>Error: {dataLoadable.message}</div>
}
return <pre>{JSON.stringify(dataLoadable, null, 2)}</pre>
}
```
The first time the component mounts, it checks `localStorage["myDataKey"]`.
- If **not found**, it fetches from the server, **writes** to localStorage, and returns the result.
- Subsequent renders can immediately read from localStorage before re-fetching or revalidating (depending on `hideReload` or your logic).
### Cache Stores
- **`memory`**: A global in-memory map (fast, but resets on page refresh).
- **`localStorage`**: Persists across refreshes, limited by localStorage size (~5MB in many browsers).
- **`indexedDB`**: Can store larger data more efficiently, though usage is a bit more complex.
### Notes on Caching Strategy
- **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.
- **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.
- **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.
---
## Comparison with Alternatives
- **React Query / SWR / Apollo**: Powerful, feature-rich solutions (caching, revalidation, etc.), which can be overkill if you don’t need those extras.
- **Manual `useEffect`**: Often leads to repetitive loading booleans and tricky cleanup logic. Loadable unifies these states for you.
- **Redux**: While Redux can handle async, it’s heavy if you only need local data fetching without global state.
---
## Why Loadable?
- **Less Boilerplate**: Eliminate scattered `useState` variables and conditionals for loading/error states.
- **Declarative**: Compose async operations with `useLoadable`, `useThen`, `useAllThen`, etc.
- **Safe & Explicit**: Distinguish between `loading`, a `LoadError`, or real data in one type.
- **Flexible**: Use a simple symbol or a class-based token with timestamps or custom fields.
- **Caching**: Optionally store and retrieve data from memory, localStorage, or IndexedDB with minimal extra code.
- **Familiar**: Similar to `useEffect`, but with a focus on minimal boilerplate.
Get rid of manual loading checks and experience simpler, more maintainable React apps. Give **Loadable** a try today!