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

React 의 Promise — fetch, state, 에러

~14 min · promises, fetch, async

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Suspense 와 use() 전엔 useEffect + useState. 패턴은 여전히 자리 있어 — Track 3 lesson 2 가 다룸. 이 레슨은 왜 더 나은 거 원하는지 보여줘.

3-state 춤

모든 fetch 가 세 state 생산: loading, error, success. Suspense 없이는 셋 다 명시적으로 추적:

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

+ 라이프사이클 오케스트레이트하는 useEffect + AbortController 로 cleanup + 언마운트 후 state 설정 방지 guard. Fetch 하나에 보일러플레이트 많음.

Discriminated-union 업그레이드

Track 2 lesson 5 의 discriminated union 이 세 state 를 하나로 축소:

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

컴포넌트가 state.status 기반 렌더; 불가능한 조합 (loading AND data) 존재 불가. 진전 — 다만 보일러플레이트는 여전.

Suspense + use() 업그레이드

Loading state 가 컴포넌트 위의 Suspense 경계에 (안 아니라), error state 가 위의 error boundary 에, success 경로가 컴포넌트 자체 안의 유일한 branch 면 어떨까? 그게 use() + Suspense 가 달성하는 것. 다음 네 레슨이 조각조각 조립.

Feature 쫓는 게 아니라 선언적 데이터로 가는 중. useState 체인 → discriminated union → Suspense + use() 의 궤적은 같은 호: loading/error 관심사를 컴포넌트 body 밖으로 밀어서 success 경로가 유일한 경로처럼 읽히게. 각 단계가 실재 가치.

Code

옛 모양 — useEffect + 세 state·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>;
}
Discriminated-union 업그레이드 — 여전히 body 안, 다만 더 깨끗·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

UserOld 컴포넌트 가져와서 보여준 두 업그레이드로 리팩토링: 먼저 discriminated union 가진 useFetchedUser 로, 그 다음 use() + Suspense 버전 어떻게 보일지 미리보기 (lesson 4 에서 진짜 빌드). 각 단계마다 얼마나 많은 state 사라지는지 관찰.
Hint
Discriminated-union 리팩토링 후 컴포넌트는 result.status 의 switch. Suspense 업그레이드 후 컴포넌트는 return <p>{user.name}</p> — loading 과 error 가 부모에.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.