The RemoteData Type
Every async request is in exactly one state: it hasn't started, it's in flight, it failed, or it succeeded.
Most code models this with a bag of booleans and optional values (isLoading, data?, error?), and nothing stops you from having isLoading: true and data: someValue at the same time.
RemoteData is a union type. One state at a time. TypeScript knows which one.
type RemoteData<T> =
| { type: 'initial' } // haven't started yet
| { type: 'pending' } // request in flight
| { type: 'failed'; errors; retry } // failed, with errors and a retry function
| { type: 'success'; value: T }; // succeeded, here's your data
When you're in the 'success' branch, value is T. Not T | undefined. Not T | null. Just T.
No narrowing gymnastics. TypeScript does it for you.
Stale data is still data
Most libraries throw your data away when they re-fetch. You had a dashboard full of numbers; now you have a spinner.
use-remote-data keeps the old value visible while the refresh happens. Three additional states handle this:
| State | What's happening | You have data? |
|---|---|---|
stale-immediate | Data just arrived but the refresh strategy already considers it stale (e.g. a very short TTL, or pollUntil rejected it) | Yes |
stale-initial | Previously-fresh data was marked stale, re-fetch hasn't started yet | Yes |
stale-pending | Re-fetch is in progress, old data still visible | Yes |
You don't need to memorize these. Inside <Await>, all three are collapsed into a single boolean, isStale, which tells you everything:
<Await store={store}>
{(data, isStale) => (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<Dashboard data={data} />
</div>
)}
</Await>
Data stays on screen. Users see something useful instead of a spinner. One boolean to style it.
Exhaustive matching
Most of the time you'll use <Await> and never touch these states directly.
But when you need custom control flow (logging, analytics, conditional rendering), you
can switch on the type field, and TypeScript will enforce that you handle every case:
function describe<T>(rd: RemoteData<T>): string {
switch (rd.type) {
case 'initial':
return 'Not started';
case 'pending':
return 'Loading...';
case 'failed':
return `${rd.errors.length} error(s)`;
case 'success':
return `Got: ${rd.value}`;
case 'stale-immediate':
return `Stale (just arrived): ${rd.stale.value}`;
case 'stale-initial':
return `Stale (waiting): ${rd.stale.value}`;
case 'stale-pending':
return `Refreshing: ${rd.stale.value}`;
// No default; TypeScript errors if you miss a case.
}
}
If you use ts-pattern, it works as you'd expect:
import { match } from 'ts-pattern';
const label = match(store.current)
.with({ type: 'initial' }, () => 'Idle')
.with({ type: 'pending' }, () => 'Loading...')
.with({ type: 'failed' }, ({ errors }) => `${errors.length} error(s)`)
.with({ type: 'success' }, ({ value }) => `Hello, ${value.name}`)
.with({ type: 'stale-immediate' }, ({ stale }) => `Stale: ${stale.value.name}`)
.with({ type: 'stale-initial' }, ({ stale }) => `Stale: ${stale.value.name}`)
.with({ type: 'stale-pending' }, ({ stale }) => `Refreshing: ${stale.value.name}`)
.exhaustive();
Prior art
The RemoteData pattern originates in Elm
and has been adopted across many typed languages.
The core idea (represent async state as a union type with a type tag, instead of a bag of booleans) is the same
principle behind zod (parse, don't validate),
neverthrow (errors as values),
and ts-pattern (exhaustive matching).
If you like TypeScript's tagged unions (sometimes called "discriminated unions"), RemoteData should feel familiar.