Skip to main content

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:

StateWhat's happeningYou have data?
stale-immediateData just arrived but the refresh strategy already considers it stale (e.g. a very short TTL, or pollUntil rejected it)Yes
stale-initialPreviously-fresh data was marked stale, re-fetch hasn't started yetYes
stale-pendingRe-fetch is in progress, old data still visibleYes

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.