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
).
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.
- Overview
- Core Concepts
- Quick Start
- Hooks & Utilities
- Migrating Common Patterns
- Error Handling
- Caching
- Comparison with Alternatives
- Why Loadable?
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.
npm install @tobq/loadable
# or
yarn add @tobq/loadable
A Loadable<T>
can be:
- Loading: represented by a special
loading
symbol (or an optional “loading token”). - Loaded: the actual data of type
T
. - Failed: a
LoadError
object describing the failure.
This single union type replaces the typical isLoading
/ data
/ error
triple.
Below is a minimal comparison of how you might load data with and without Loadable:
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>
)
}
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 asloading
and becomes the loaded data when ready.hasLoaded(properties)
ensures the data is neither loading nor an error.
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>
)
}
Use useAllThen
or the all()
helper to coordinate multiple loadable values:
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} />
}
useLoadable(fetcher, deps, options?)
Returns aLoadable<T>
by calling the asyncfetcher
.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 callsfetcher
.useLoadableWithCleanup(fetcher, deps, options?)
LikeuseLoadable
, but returns[Loadable<T>, cleanupFunc]
for manual aborts.
Helpers include:
hasLoaded(loadable)
loadFailed(loadable)
all(...)
map(...)
toOptional(...)
orElse(...)
isUsable(...)
Before:
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:
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} />
Before:
useEffect(() => {
let cancelled = false
getUser().then(user => {
if (!cancelled) {
setUser(user)
getUserPosts(user.id).then(posts => {
if (!cancelled) {
setPosts(posts)
}
})
}
})
return () => { cancelled = true }
}, [])
After:
const user = useLoadable(() => getUser(), [])
const posts = useThen(user, (u) => getUserPosts(u.id))
By default, if a fetch fails, useLoadable
returns a LoadError
. You can handle or display it:
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} />
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:
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.
Loadable supports optional caching of fetched data, allowing you to bypass refetching if the data already exists in memory, localStorage, or indexedDB.
Within the options
object passed to useLoadable
, you can include:
cache?: string | {
key: string
store?: "memory" | "localStorage" | "indexedDB"
}
- String (e.g.
cache: "myDataKey"
):- Interpreted as the cache key, defaults to
"localStorage"
for storage.
- Interpreted as the cache key, defaults to
- Object (e.g.
cache: { key: "myDataKey", store: "indexedDB" }
):- Fully specifies both the cache key and the storage backend.
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).
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.
- 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.
- 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.
- 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
, aLoadError
, 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!