use-async-wrapper
v0.1.1
Published
Typed async state and declarative rendering for React. Stop hand-writing if (isLoading) blocks.
Maintainers
Readme
use-async-wrapper
You fetch some data. So you add an isLoading boolean, an error state, and a null check before rendering. Then the component needs a second fetch, and now you're juggling six state variables and a wall of ifs just to decide what to show — and TypeScript still makes you ! the data at the end. You've written this component a hundred times.
use-async-wrapper puts the whole thing in one typed value: map it, combine two of them, hand it to components that render the right thing for every state — loading, error, data, or refetching with stale data still on screen — without writing a single if condition.
▶ Live playground — every feature as an interactive Storybook demo: drive the state machine with buttons and watch the UI react.
AsyncData<T, E>— an immutable value describing an async operation: empty, loading, error, or data — with stale-while-revalidate built inuseAsyncWrapper— typed components that render the right state, colocated with the data that drives themcombine/map/flatMap— merge and transform async values without null guards or non-null assertions- Zero dependencies. Works with plain
fetch, websockets, one-off promises — any async source. Typed errors end to end. - Optional React Query bridge — React Query fetches and caches; this renders
Contents
Install
npm install use-async-wrapperRequires React 18+. The optional React Query bridge requires @tanstack/react-query v5.
ESM-only
This package ships ESM only. It works out of the box with Vite, Next.js, webpack, Rollup, esbuild, and any bundler that reads the exports field — and require() works too on Node 20.19+ / 22.12+, which support require(esm).
The one setup that needs a nudge is Jest in CommonJS mode, which doesn't use Node's require(esm) and will fail with createRequireEsmError. Run Jest in ESM mode instead:
NODE_OPTIONS=--experimental-vm-modules npx jest// jest.config.mjs
export default { transform: {} };If you're on TypeScript's legacy "moduleResolution": "node", prefer "bundler" or "nodenext" so the exports map resolves.
The problem
Async state in React is deceptively messy. Take something as common as fetching a product list and an exchange rate, then converting prices before rendering:
const ProductList = ({ currency }: { currency: string }) => {
const [products, setProducts] = useState<Product[] | null>(null);
const [productsLoading, setProductsLoading] = useState(false);
const [productsError, setProductsError] = useState<string | null>(null);
const [exchangeRate, setExchangeRate] = useState<number | null>(null);
const [exchangeRateLoading, setExchangeRateLoading] = useState(false);
const [exchangeRateError, setExchangeRateError] = useState<string | null>(null);
// Products load once...
useEffect(() => {
setProductsLoading(true);
fetchProducts()
.then(data => { setProducts(data); setProductsLoading(false); })
.catch(err => { setProductsError(err.message); setProductsLoading(false); });
}, []);
// ...but the rate refetches every time currency changes
useEffect(() => {
setExchangeRateLoading(true);
fetchExchangeRate(currency)
.then(rate => { setExchangeRate(rate); setExchangeRateLoading(false); })
.catch(err => { setExchangeRateError(err.message); setExchangeRateLoading(false); });
}, [currency]);
// Have to guard against null even though the ifs below already do that
const convertedProducts = useMemo(() => {
if (!products || !exchangeRate) return null;
return products.map(p => ({ ...p, price: p.price * exchangeRate }));
}, [products, exchangeRate]);
if (productsLoading || exchangeRateLoading) return <div>Loading...</div>;
if (productsError) return <div>Error loading products: {productsError}</div>;
if (exchangeRateError) return <div>Error loading exchange rate: {exchangeRateError}</div>;
// TypeScript still has no idea these are set despite all the checks above
if (!convertedProducts) return null;
return (
<ul>
{convertedProducts!.map(p => <li key={p.id}>{p.name} — {p.price}</li>)}
</ul>
);
};Six state variables, two useEffects (which can't be merged into one Promise.all — the fetches have different lifetimes), a useMemo that has to re-guard what the ifs below already check, a non-null assertion at the end because TypeScript still isn't convinced, and four early returns — for something as routine as fetching two things and combining them.
Here's the same component with use-async-wrapper:
import { AsyncData, useAsyncWrapper } from 'use-async-wrapper';
const ProductList = ({ currency }: { currency: string }) => {
const [products, setProducts] = useState(new AsyncData<Product[]>());
const [exchangeRate, setExchangeRate] = useState(new AsyncData<number>());
useEffect(() => {
setProducts(prev => prev.withLoading());
fetchProducts()
.then(data => setProducts(prev => prev.withData(data)))
.catch(err => setProducts(prev => prev.withError(err.message)));
}, []);
useEffect(() => {
setExchangeRate(prev => prev.withLoading());
fetchExchangeRate(currency)
.then(rate => setExchangeRate(prev => prev.withData(rate)))
.catch(err => setExchangeRate(prev => prev.withError(err.message)));
}, [currency]);
const convertedProducts = useMemo(
() => AsyncData.combine(products, exchangeRate)
.map(([products, rate]) => products.map(p => ({ ...p, price: p.price * rate }))),
[products, exchangeRate],
);
const { AsyncWrapper, AsyncWrapperData, AsyncWrapperError } = useAsyncWrapper(convertedProducts);
return (
<AsyncWrapper renderLoading="no-data">
<AsyncWrapperError>{(error) => <div>Error: {error}</div>}</AsyncWrapperError>
<AsyncWrapperData>
{(products, isLoading) => (
<>
{isLoading && <SmallSpinner />}
<ul>
{products.map(p => <li key={p.id}>{p.name} — {p.price}</li>)}
</ul>
</>
)}
</AsyncWrapperData>
</AsyncWrapper>
);
};combine merges the two states — if either is loading or errored, the combined state reflects that. map transforms the data only when both are ready. No null guards, no non-null assertions, no redundant checks — and the error render function receives a typed error.
renderLoading="no-data" means the full loading state only shows before the first load. When currency changes, the product list stays visible with the stale prices and isLoading flips on for the small spinner — stale-while-revalidate without any extra bookkeeping. (Doing the same in the version above means yet another round of booleans.)
If you're familiar with functional programming:
AsyncDatais essentially anEitherwith an extra loading dimension.map,flatMap, andcombineare the functor/monad/applicative operations you'd recognise from Haskell, Rust'sOption/Result, or fp-ts. If those words mean nothing to you, don't worry — you don't need any of that to use this.
AsyncData
AsyncData<T, E = string> holds four pieces of state:
| Field | Type | Description |
|---|---|---|
| data | T \| AsyncData.Empty | The loaded value, or Empty if not yet loaded |
| isLoading | boolean | Whether a fetch is in progress |
| error | E \| null | The error, if one occurred |
| abortController | AbortController \| null | The controller for the in-flight request |
Instances are immutable — every transition returns a new AsyncData, which is what makes them safe to hold in React state.
Initial state
// Empty, not loading, no error — a blank slate
const state = new AsyncData<User>();Custom error types
The second type parameter is the error type. It defaults to string, but any type works — whatever you pass to withError comes back, fully typed, in AsyncWrapperError:
interface ApiError {
code: number;
message: string;
}
const [users, setUsers] = useState(new AsyncData<User[], ApiError>());
setUsers(prev => prev.withError({ code: 503, message: 'Users service is down' }));
<AsyncWrapperError>
{(error) => <ErrorBanner message={`${error.code} — ${error.message}`} />}
</AsyncWrapperError>One rule of thumb: combine requires all its sources to share the same error type, so pick one error type per pipeline.
withLoading
Marks the state as loading. Preserves existing data, so stale-while-revalidate works naturally.
setUsers(prev => prev.withLoading(controller)); // controller is optionalwithData / withError
// On success
setUsers(prev => prev.withData(data));
// On failure
setUsers(prev => prev.withError('Failed to load users'));withData and withError both clear abortController. withLoading preserves existing data so you can show stale data while refetching.
Cancelling in-flight requests
The abortController field lets you cancel a previous request before starting a new one:
const fetchUsers = () => {
users.abortController?.abort(); // cancel any in-flight request
const controller = new AbortController();
setUsers(prev => prev.withLoading(controller));
fetch('/api/users', { signal: controller.signal })
.then(res => res.json())
.then(data => setUsers(prev => prev.withData(data)))
.catch(err => {
if (err.name !== 'AbortError') {
setUsers(prev => prev.withError(err.message));
}
});
};get / unwrap
get() returns the data or undefined if empty. unwrap() returns the data or throws.
const value = state.get(); // User | undefined
const value = state.unwrap(); // User (throws if empty)Use get() when you want to safely check, unwrap() when you can guarantee data is present — for example, in an event handler that can only be triggered from within AsyncWrapperData.
map
Transforms the data value if present. The isLoading, error, and abortController state always passes through unchanged — so a stale-while-revalidate state (data present + refetch in flight) keeps its loading flag after the transform.
const ids = users.map(users => users.map(u => u.id));
// AsyncData<number[]> — same loading/error state, data transformedIf the data is empty (not yet loaded), the mapper is not called.
mapError
The error-channel counterpart of map — transforms the error if present, leaves data/loading untouched. Useful for normalizing error types at a boundary so sources with different error types can combine:
// users: AsyncData<User[], ApiError>
const normalized = users.mapError(e => e.message);
// AsyncData<User[], string>If the error is null, the mapper is not called. In components, wrap derived values in useMemo as usual — or for React Query, use useQueryAsyncData's mapError option, which memoizes for you.
flatMap
Like map, but the mapper returns an AsyncData which is flattened into the result (the monadic bind — chain in fp-ts, and_then in Rust). Use it when the transformation is itself async-stateful.
// Dependent async values: pick a per-user AsyncData once the user is loaded
const posts = user.flatMap(u => postsByUserId[u.id] ?? new AsyncData<Post[]>());
// AsyncData<Post[]> — empty until user loads, then tracks the inner stateRules:
- If the outer data is empty, the mapper is not called and the empty/loading/error state passes through, as with
map - If the outer data is present, the result merges both states: loading if either is loading, first error wins, data comes from the mapper's result
combine
Merges any number of independent AsyncData instances into one that resolves when all are ready. The combined data is a tuple typed from the arguments.
const combined = AsyncData.combine(user, posts);
// AsyncData<[User, Post[]]>
const bigger = AsyncData.combine(user, posts, comments, settings);
// AsyncData<[User, Post[], Comment[], Settings]>Rules:
- If any has an error, the combined result has that error (first error wins)
- If any is loading, the combined result is loading
- If any has no data yet, the combined result has no data
- Only when all have data is the combined result populated
useAsyncWrapper
Returns typed React components bound to an AsyncData instance.
const { AsyncWrapper, AsyncWrapperData, AsyncWrapperError, AsyncWrapperLoading } =
useAsyncWrapper(asyncData);AsyncWrapperData is typed to T and AsyncWrapperError is typed to E, both inferred from the asyncData you pass in — no manual type parameters.
Basic usage
<AsyncWrapper>
<AsyncWrapperData>
{(users) => (
<ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
)}
</AsyncWrapperData>
</AsyncWrapper>Default loading and error states
If you don't provide AsyncWrapperLoading or AsyncWrapperError children, AsyncWrapper renders built-in fallbacks automatically:
// While loading renders: <div>Loading...</div>
// On error renders: <div>Error: {errorMessage}</div>Custom loading and error states
<AsyncWrapper>
<AsyncWrapperLoading>
<Spinner />
</AsyncWrapperLoading>
<AsyncWrapperError>
{(error) => <ErrorBanner message={error} />}
</AsyncWrapperError>
<AsyncWrapperData>
{(users) => <UserList users={users} />}
</AsyncWrapperData>
</AsyncWrapper>The error render function receives the error typed as E.
Suppressing default fallbacks
If you want to handle loading or error states yourself outside of AsyncWrapper:
<AsyncWrapper dontRenderDefaultLoading dontRenderDefaultError>
<AsyncWrapperData>
{(users) => <UserList users={users} />}
</AsyncWrapperData>
</AsyncWrapper>renderLoading
Controls when the loading state is shown. Default is 'always'.
'always' — shows the loading state whenever isLoading is true, even if data is already present. Stale data is replaced with the loading UI during refetches.
'no-data' — only shows the loading state when there is no data yet. Once data has loaded, refetches render the data child with isLoading: true — stale-while-revalidate:
<AsyncWrapper renderLoading="no-data">
<AsyncWrapperLoading>
<Spinner />
</AsyncWrapperLoading>
<AsyncWrapperData>
{(users, isLoading) => (
<UserList users={users} dimmed={isLoading} />
)}
</AsyncWrapperData>
</AsyncWrapper>
// data=Empty, isLoading=true → <Spinner />
// data=[...], isLoading=true → <UserList dimmed /> (stale data stays visible)
// data=[...], isLoading=false → <UserList />The second argument to the AsyncWrapperData render function is isLoading, so you can reflect a background refetch in the UI (dimming, disabling a refresh button).
State priority
AsyncWrapper resolves one of four states, in this order:
- Error — if
error !== null, renders the error state (custom or default) - Loading — if
isLoadingand therenderLoadingcondition is met - Empty — if data is still
Empty, renders nothing - Data — renders
AsyncWrapperData
An error always takes priority over loading, and loading takes priority over stale data when renderLoading="always".
The state components read the resolved state from context, so they can be nested anywhere inside AsyncWrapper — with one caveat: default-fallback detection only sees direct children, so to replace a default, keep the state component a direct child (or set the corresponding dontRenderDefault*). Children that aren't state components always render, so static content can live inside the wrapper alongside the state components.
Why not Suspense?
Suspense is a solid model, and if it's working for you, keep it. The tradeoffs that motivated this library instead:
- Errors are untyped — an
ErrorBoundarycatchesError, not the typed error your fetch actually produced.AsyncWrapperErrorreceivesE. - Loading and error UI live away from the data — boundaries sit up the tree; per-section error UI means a boundary per section, which forces component splits along boundary lines rather than logical ones. Here, fallbacks are colocated with the data that drives them.
- Keeping stale UI during refetches requires orchestration — transitions (
useTransition,useDeferredValue) or library support. Here it's a prop:renderLoading="no-data". - Suspense wants a cache layer — suspending on plain promises requires stable promise identity across renders, which in practice means a framework or query library underneath.
AsyncDatais just a value; it works with a barefetch. - React still ships no
ErrorBoundarycomponent — you write the class component yourself or add a dependency.
React Query
If you use React Query, this library isn't a competitor — it's designed to sit on top. React Query owns fetching: caching, deduplication, retries, invalidation. What it leaves to you is the render layer: per-component state checks, cross-query composition, and app-consistent fallbacks. v5's discriminated unions narrow a single query, but narrowing doesn't compose — a component depending on two queries is back to cross-checking states by hand.
The bridge converts query results into AsyncData, so combine, map, and AsyncWrapper work on top:
import { AsyncData, useAsyncWrapper } from 'use-async-wrapper';
import { useQueryAsyncData } from 'use-async-wrapper/react-query';
const ProductList = ({ currency }: { currency: string }) => {
const productsQuery = useQuery({ queryKey: ['products'], queryFn: fetchProducts });
const rateQuery = useQuery({ queryKey: ['rate', currency], queryFn: () => fetchExchangeRate(currency) });
const products = useQueryAsyncData(productsQuery, { mapError: e => e.message });
const rate = useQueryAsyncData(rateQuery, { mapError: e => e.message });
const converted = useMemo(
() => AsyncData.combine(products, rate)
.map(([products, rate]) => products.map(p => ({ ...p, price: p.price * rate }))),
[products, rate],
);
const { AsyncWrapper, AsyncWrapperData, AsyncWrapperError } = useAsyncWrapper(converted);
return (
<AsyncWrapper renderLoading="no-data">
<AsyncWrapperError>{(error) => <ErrorBanner message={error} />}</AsyncWrapperError>
<AsyncWrapperData>
{(products, isLoading) => (
<ul data-dim={isLoading}>
{products.map(p => <li key={p.id}>{p.name} — {p.price}</li>)}
</ul>
)}
</AsyncWrapperData>
</AsyncWrapper>
);
};Because isFetching maps to isLoading and cached data is preserved, React Query's background refetches flow straight into renderLoading="no-data" — stale prices stay visible, dimmed, while the new rate loads.
Why mapError? React Query hands you Error objects; your error render functions want something typed and renderable. mapError does that conversion once, at the boundary — here e => e.message turns both queries into AsyncData<..., string>. That's also what makes them composable: combine requires all its sources to share one error type, so normalizing each query's error at the edge is what lets query-backed and manually-fetched AsyncDatas mix in the same pipeline. Omit it and the error passes through as Error.
Notes:
mapErroris read through a ref, so passing an inline arrow function doesn't churn the memoized result.- Errors win over cached data, mirroring
AsyncWrapper's state priority. undefineddata is treated as "not loaded" — don't use the bridge for queries whereundefinedis a valid payload.queryToAsyncDatais the pure, non-hook version of the same conversion, useful in tests or outside components.
License
MIT
