Skip to content

RemoteData — loading states

Every async data fetch has exactly four moments: before it starts, while it’s loading, when it fails, and when it succeeds. That’s the whole picture. But it’s common to spread these four states across separate variables that can get out of sync and produce combinations that shouldn’t exist. RemoteData<E, A> gives each state a name and keeps them mutually exclusive — only one is active at any given time.

stateDiagram-v2
  direction TB
  [*] --> NotAsked
  NotAsked --> Loading : fetch starts
  Loading --> Success : data arrives
  Loading --> Failure : request fails
  Failure --> Loading : retry
  Success --> Loading : refresh

A typical approach to loading states looks like this:

const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

Three separate pieces of state, but they’re not actually independent — only certain combinations are meaningful. The type allows loading: true and error: "timeout" at the same time, which is contradictory. Nothing prevents you from forgetting to reset error when a new request starts, or showing stale data while loading is true.

RemoteData makes the states explicit and mutually exclusive. There’s one value with one state at a time:

import { pipe } from "@nlozgachev/pipelined/composition";
import { RemoteData } from "@nlozgachev/pipelined/core";

type State = RemoteData<string, User>;

// State transitions:
let state: State = RemoteData.notAsked(); // before the user triggers a fetch
state = RemoteData.loading(); // request in flight
state = RemoteData.failure("Timed out"); // request failed
state = RemoteData.success(user); // request succeeded

Each state is represented once, and they can’t overlap. The type system prevents you from combining them incorrectly.

sequenceDiagram
  participant U as user action
  participant C as your component
  participant S as server

  C->>C: notAsked
  U->>C: click "Load"
  C->>C: loading
  C->>S: fetch /users/123
  alt success
    S-->>C: 200 OK { name: "Alice" }
    C->>C: success(user)
  else failure
    S-->>C: 404 Not Found
    C->>C: failure("Not found")
  end
RemoteData.notAsked(); // NotAsked — no fetch triggered yet
RemoteData.loading(); // Loading  — fetch in progress
RemoteData.failure("Not found"); // Failure  — fetch failed with an error
RemoteData.success(user); // Success  — fetch succeeded with a value

match is the primary way to consume a RemoteData. It requires a handler for every state, so the compiler ensures you’ve covered all cases:

const message = pipe(
	userData,
	RemoteData.match({
		notAsked: () => "Click to load",
		loading: () => "Loading...",
		failure: (err) => `Failed: ${err}`,
		success: (user) => `Hello, ${user.name}`,
	}),
);

Because all four branches are required, there’s no way to accidentally skip the loading state or forget to handle errors. The type checker will tell you if a case is missing. fold is the positional form — notAsked, loading, failure, success — if you’d rather not name the cases.

map transforms the value inside Success, leaving all other states unchanged:

pipe(
	RemoteData.success(5),
	RemoteData.map((n) => n * 2),
); // Success(10)
pipe(
	RemoteData.loading(),
	RemoteData.map((n) => n * 2),
); // Loading
pipe(
	RemoteData.failure("!"),
	RemoteData.map((n) => n * 2),
); // Failure("!")
pipe(
	RemoteData.notAsked(),
	RemoteData.map((n) => n * 2),
); // NotAsked

This lets you transform data as part of a pipeline without breaking out of the RemoteData context:

const userName = pipe(
	userData, // RemoteData<string, User>
	RemoteData.map((u) => u.name), // RemoteData<string, string>
	RemoteData.getOrElse(() => "Unknown"),
);

mapError transforms the error inside Failure, leaving other states unchanged:

pipe(
	RemoteData.failure("connection refused"),
	RemoteData.mapError((e) => ({ code: 503, message: e })),
); // Failure({ code: 503, message: "connection refused" })

Useful for normalizing error types from different sources before they reach your rendering logic.

chain sequences a second fetch that depends on the result of the first. If the current state is Success, it passes the value to the function and returns whatever that produces. All other states pass through:

pipe(
	userData, // RemoteData<string, User>
	RemoteData.chain((user) => fetchUserPosts(user.id)), // RemoteData<string, Post[]>
);

If userData is Loading, Failure, or NotAsked, the chain step is skipped and that state propagates.

recover provides a fallback RemoteData when the current state is Failure. Unlike Result.recover, it receives the error value so you can use it in the recovery logic. The fallback can produce a different success type, widening the result to RemoteData<E, A | B>:

pipe(
	fetchFromPrimary(url),
	RemoteData.recover((err) => {
		console.warn("Primary failed:", err);
		return fetchFromFallback(url);
	}),
);

getOrElse — returns the success value or a default thunk () => B for any other state. The thunk is only called when the value is not Success. The default can be a different type, widening the result to the union of both:

pipe(RemoteData.success(5), RemoteData.getOrElse(() => 0)); // 5
pipe(RemoteData.loading(), RemoteData.getOrElse(() => 0)); // 0
pipe(RemoteData.failure("!"), RemoteData.getOrElse(() => 0)); // 0
pipe(RemoteData.loading(), RemoteData.getOrElse(() => null)); // null — typed as number | null

When you need to work with a part of the system that uses Maybe or Result, you can convert. toMaybe maps Success to Some and everything else to None. toResult maps Success to Ok and Failure to Err; NotAsked and Loading both become Err using a fallback you provide — they’re both “not yet succeeded”:

RemoteData.toMaybe(RemoteData.success(42)); // Some(42)
RemoteData.toMaybe(RemoteData.loading()); // None

pipe(
	RemoteData.success(42),
	RemoteData.toResult(() => "not loaded yet"),
); // Ok(42)

pipe(
	RemoteData.loading(),
	RemoteData.toResult(() => "not loaded yet"),
); // Err("not loaded yet")

If you need to distinguish NotAsked from Loading after conversion, keep using RemoteData directly — both states collapse into Err and the distinction is lost.

Use RemoteData when:

  • You’re displaying fetched data in a UI and need to handle all loading states explicitly
  • You want the type system to prevent invalid state combinations like simultaneous loading and error
  • You want a single value in state instead of three separate flags

It’s also useful outside UI contexts — any time you’re tracking the lifecycle of an async operation and need to distinguish “hasn’t started” from “in progress” from “done”.