1. 나의 코드는 loading 지옥이었다
개발 초기, 제 코드는 항상 이런 식전 기도(?)로 시작했습니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
// ...드디어 본문 시작
}
문제는 이런 코드가 모든 컴포넌트에 복붙되어 있다는 거였죠.
TodoList에도, Sidebar에도, Settings에도.
앱이 커질수록 useState(true)와 if (loading)이 바이러스처럼 퍼졌습니다.
가장 큰 문제는 "사용자 경험(UX)"이 엉망이라는 점이었습니다. 데이터를 가져오는 순서에 따라 로딩 스피너가 여기저기서 깜빡거리는 'Waterfall' 현상이 발생했으니까요.
2. 명령형(Imperative) vs 선언형(Declarative)
기존 방식은 명령형입니다.
- "데이터를 가져와. 그동안 로딩 상태를 true로 해. 다 되면 false로 바꾸고 데이터를 보여줘. 에러 나면 에러 보여줘."
- 개발자가 '어떻게(How)' 처리할지를 일일이 명령합니다.
React 팀이 제시한 Suspense와 ErrorBoundary는 선언형입니다.
- "이 컴포넌트는 데이터가 필요해. 데이터가 준비 안 됐어? 그럼 부모한테 알려."
- 개발자는 '무엇을(What)' 보여줄지만 정의합니다.
비유하자면 이렇습니다.
- 기존: 직원이 문제 생길 때마다 사장님한테 전화해서 "어떡하죠?" 물어봄.
- 선언형: 사장님이 미리 매뉴얼(ErrorBoundary)을 줌. "문제 생기면 이 매뉴얼대로 하고 나한테 보고만 해."
3. Suspense: 로딩을 외주 맡기기
Suspense를 쓰면 컴포넌트 내부에서 loading 상태를 지울 수 있습니다.
대신 부모 컴포넌트가 로딩 처리를 '대신' 해줍니다.
// 1. 컴포넌트는 데이터가 '있다'고 가정하고 작성
function UserProfile() {
const user = useUserQuery(); // 데이터 없으면 여기서 실행 중단 (Suspend)
return <div>{user.name}</div>;
}
// 2. 부모가 로딩을 책임짐
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}
이제 UserProfile은 지저분한 if (loading) 분기문에서 해방되었습니다.
데이터가 로딩 중이면 React가 알아서 렌더링을 멈추고(Suspend), 가장 가까운 Suspense의 fallback을 보여줍니다.
이게 왜 강력하냐고요? 로딩 단위를 내 맘대로 조절할 수 있기 때문입니다.
// 1. 각각 로딩하고 싶으면? (개별적으로 뜸)
<Suspense fallback={<Spinner />}><Header /></Suspense>
<Suspense fallback={<Spinner />}><Body /></Suspense>
// 2. 한 번에 로딩하고 싶으면? (둘 다 준비돼야 뜸)
<Suspense fallback={<BigSpinner />}>
<Header />
<Body />
</Suspense>
코드를 거의 안 고치고도 로딩 UX를 드라마틱하게 바꿀 수 있습니다.
4. ErrorBoundary: 에러를 한 곳에서 포획하기
에러 처리도 마찬가지입니다. try-catch로 도배하는 대신, 에러가 났을 때 보여줄 UI를 선언합니다.
// React 문서에 있는 표준 ErrorBoundary
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) return <ErrorPage />;
return this.props.children;
}
}
// 사용
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
이제 UserProfile에서 API 에러가 터지든, 렌더링 에러가 터지든, ErrorBoundary가 낚아채서(Catch) ErrorPage를 보여줍니다.
마치 JavaScript의 try-catch 블록이 돔(DOM) 레벨로 올라온 느낌이죠.
꿀팁: react-error-boundary 라이브러리를 쓰면 훨씬 편합니다. 재시도(Retry) 기능도 쉽게 붙일 수 있어요.
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => resetQuery()} // 다시 시도 버튼 누르면 실행
>
<UserProfile />
</ErrorBoundary>
5. Render-as-you-fetch 패턴의 등장
Suspense는 단순히 문법적 설탕(Syntactic Sugar)이 아닙니다. 데이터 패칭의 패러다임을 바꿉니다.
- Fetch-on-render (기존): 렌더링 시작 -> useEffect 실행 -> 데이터 요청. (느리고 Waterfall 발생)
- Fetch-then-render (Redux 등): 데이터 먼저 싹 다 요청 -> 다 오면 렌더링. (초기 로딩 늦음)
- Render-as-you-fetch (Suspense): 데이터 요청과 렌더링을 동시에 시작. 데이터 오는 대로 갈아끼움.
React Query(TanStack Query)나 Relay 같은 라이브러리가 이 패턴을 완벽하게 지원합니다.
// React Query와 Suspense 조합
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
suspense: true // 요거 한 줄이면 끝!
});
6. 마무리 - 개발자는 행복해져야 한다
Suspense와 ErrorBoundary를 도입하고 나서 제 코드는 절반으로 줄었습니다. 무엇보다 "로딩 상태 관리"라는 지루한 작업에서 벗어나, "어떤 데이터를 보여줄까"라는 본질에 집중하게 되었습니다.
아직도 if (loading) return ...을 치고 계신가요?
이제 그 짐을 React에게 내려놓으세요. 선언적 UI의 세계에 오신 것을 환영합니다.
Stop Writing 'if (loading) return Loading...' (Suspense & ErrorBoundary)
1. My Code Was loading Hell
Early in my career, my components always started with this ritual prayer:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
// ...Actual content starts here
}
The problem was, this code was copy-pasted into every single component.
TodoList, Sidebar, Settings...
As the app grew, useState(true) and if (loading) spread like a virus.
Worst of all, the User Experience (UX) was terrible. Spinners flashed everywhere chaotically (the Waterfall effect) depending on when data arrived.
2. Imperative vs Declarative
The old way is Imperative.
- "Fetch data. Set loading to true. When done, set to false and show data. If error, show error."
- You tell the computer 'How' to handle every step.
React Suspense and ErrorBoundary are Declarative.
- "This component needs data. Data not ready? Tell the parent."
- You define 'What' to show.
Analogy:
- Imperative: Employee calls the database for every small issue asking "What do I do?"
- Declarative: Boss gives a manual (ErrorBoundary). "If there's a problem, follow this manual and just report to me."
3. Suspense: Outsourcing Loading States
With Suspense, you delete the loading state from your component.
The parent component handles loading 'on your behalf'.
// 1. Component assumes data exists
function UserProfile() {
const user = useUserQuery(); // If no data, execution SUSPENDS here
return <div>{user.name}</div>;
}
// 2. Parent takes responsibility
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}
Now UserProfile is freed from the messy if (loading) blocks.
When data is loading, React pauses rendering and shows the nearest Suspense's fallback.
Why is this powerful? You control the loading granularity.
// 1. Load separately (Popcorn effect)
<Suspense fallback={<Spinner />}><Header /></Suspense>
<Suspense fallback={<Spinner />}><Body /></Suspense>
// 2. Load together (All or Nothing)
<Suspense fallback={<BigSpinner />}>
<Header />
<Body />
</Suspense>
You can drastically change the Loading UX without touching the component logic.
4. ErrorBoundary: Catching Errors in One Place
Same for errors. Instead of try-catch everywhere, declare a UI fallback for crashes.
// Standard ErrorBoundary from React Docs
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) return <ErrorPage />;
return this.props.children;
}
}
// Usage
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
Now, whether it's an API error in UserProfile or a render error, ErrorBoundary Catches it and resolves the UI to ErrorPage.
It's like a JavaScript try-catch block lifted up to the DOM level.
Pro Tip: Use react-error-boundary. It handles things like 'Retry' buttons easily.
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => resetQuery()} // Runs when user clicks Retry
>
<UserProfile />
</ErrorBoundary>
5. The Rise of "Render-as-you-fetch"
Suspense isn't just syntactic sugar. It changes the data fetching paradigm.
- Fetch-on-render (Old): Render starts -> useEffect runs -> Data requested. (Slow, Waterfall)
- Fetch-then-render (Redux): Request all data first -> Render when all done. (Slow initial load)
- Render-as-you-fetch (Suspense): Start fetching and rendering simultaneously. Swap in data as it arrives.
Libraries like React Query (TanStack Query) or Relay support this perfectly.
// Combining React Query and Suspense
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
suspense: true // Just this one line!
});
6. The Future: React 19 and the use Hook
React 19 introduces a new hook simply called use.
This allows you to unwrap promises directly inside your component, without needing a special library like React Query (though libraries still help with caching).
// React 19: No more useEffect!
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
const user = use(userPromise); // Pauses here if promise is pending
return <div>{user.name}</div>;
}
function App() {
const userPromise = fetchUser(1); // Start fetching early
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
This brings the "Async/Await" mental model directly into the render phase. It's cleaner, safer, and native to the platform.
7. Common Pitfall: ErrorBoundary Limitations
A very common mistake is expecting ErrorBoundary to catch errors in Event Handlers.
IT DOES NOT.
// ❌ ErrorBoundary won't catch this!
const handleClick = () => {
throw new Error("Button crashed!");
};
Why? Because Event Handlers run outside the render phase. If they crash, the UI is still intact. React doesn't need to unmount the component tree.
For Event Handlers, just use standard try-catch.
What does ErrorBoundary catch?
- Errors during Rendering.
- Errors in Lifecycle Methods (useEffect).
- Errors in Constructors.
If you want to catch async errors (like inside fetch), you need to pass them to the nearest ErrorBoundary by updating state: setState(() => { throw err; }). (Or use a library that does this for you).
8. The Future of Suspense: Streaming & PPR
Suspense is not just for the client. It empowers Streaming Server-Side Rendering (SSR).
In Next.js App Router, wrapping a component in <Suspense> allows the server to send the initial HTML shell immediately, and then "stream" the slow data as it becomes available.
This leads to a new architecture: Partial Prerendering (PPR).
- Static parts (Header, Footer) are prerendered at build time.
- Dynamic parts (User Profile, Cart) are wrapped in Suspense and stream dynamically.
Before Suspense, the server had to wait for everything before sending anything. Now, thanks to Suspense boundaries, we can unblock the UI painting process. The declarative nature of Suspense makes this complex orchestration automatic. You don't need to manually configure streams or buffers; React handles the heavy lifting, ensuring your Time to First Byte (TTFB) is as low as possible while still delivering dynamic content.