C.W.K.
Stream
Lesson 01 of 07 · published

Promises in React — Fetch, State, Errors

~14 min · promises, fetch, async

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Before Suspense and use(), there was useEffect + useState. The pattern still has its place — Track 3 lesson 2 covered it. This lesson shows why we want something better.

The three-state dance

Every fetch produces three states: loading, error, success. Without Suspense, you track all three explicitly:

  • const [data, setData] = useState<T | null>(null)
  • const [error, setError] = useState<Error | null>(null)
  • const [loading, setLoading] = useState(true)

Plus a useEffect that orchestrates the lifecycle, plus cleanup with AbortController, plus a guard against setting state after unmount. That's a lot of boilerplate for one fetch.

The discriminated-union upgrade

Track 2 lesson 5's discriminated union shrinks the three states into one:

type Fetch<T> =
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: T };

The component renders based on state.status; impossible combinations (loading AND data) can't exist. That's progress — but the boilerplate is still there.

The Suspense + use() upgrade

What if the loading state lived in a Suspense boundary above the component (not inside it), the error state lived in an error boundary above it, and the success path was the only branch in the component itself? That's what use() + Suspense achieves. The next four lessons assemble it piece by piece.

You're moving toward declarative data, not chasing a feature. The trajectory from useState chains → discriminated unions → Suspense + use() is the same arc: push the loading/error concerns out of the component body so the success path reads like the only path. Each step is real value.

Code

The old shape — useEffect + three states·tsx
import { useEffect, useState } from "react";

function UserOld({ userId }: { userId: string }) {
  const [user, setUser] = useState<{ name: string } | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const ctrl = new AbortController();
    setLoading(true);
    setError(null);
    fetch(`/api/users/${userId}`, { signal: ctrl.signal })
      .then((r) => r.ok ? r.json() : Promise.reject(new Error(r.statusText)))
      .then(setUser)
      .catch((e) => { if (e.name !== "AbortError") setError(e); })
      .finally(() => setLoading(false));
    return () => ctrl.abort();
  }, [userId]);

  if (loading) return <p>Loading…</p>;
  if (error) return <p className="text-red-500">{error.message}</p>;
  if (!user) return null;
  return <p>{user.name}</p>;
}
The discriminated-union upgrade — still in-body, but cleaner·tsx
type Fetch<T> =
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: T };

function useFetchedUser(userId: string): Fetch<{ name: string }> {
  const [state, setState] = useState<Fetch<{ name: string }>>({ status: "loading" });
  useEffect(() => {
    const ctrl = new AbortController();
    setState({ status: "loading" });
    fetch(`/api/users/${userId}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then((data) => setState({ status: "success", data }))
      .catch((e) => {
        if (e.name !== "AbortError") setState({ status: "error", error: e });
      });
    return () => ctrl.abort();
  }, [userId]);
  return state;
}

function UserMid({ userId }: { userId: string }) {
  const result = useFetchedUser(userId);
  switch (result.status) {
    case "loading": return <p>Loading…</p>;
    case "error":   return <p className="text-red-500">{result.error.message}</p>;
    case "success": return <p>{result.data.name}</p>;
  }
}

External links

Exercise

Take the UserOld component and refactor it through the two upgrades shown: first into useFetchedUser with the discriminated union, then preview what the use() + Suspense version will look like (you'll build it for real in lesson 4). Note how much state disappears at each step.
Hint
After the discriminated-union refactor, the component is a switch statement on result.status. After the Suspense upgrade, the component is just return <p>{user.name}</p> — loading and error live in the parent.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.