서버 데이터를 불러오고 복잡한 상태를 useReducer로 관리하기.
데이터 패칭과 useReducer
대부분의 앱은 서버에서 데이터를 가져옵니다. 로딩·에러·성공이라는 여러 상태가 얽히죠. useEffect 로 데이터를 불러오고, useReducer 로 복잡한 상태를 깔끔하게 관리하는 법을 배웁니다.
학습 목표
useEffect안에서 데이터를 안전하게 fetch할 수 있다.- 로딩·에러·데이터 상태를 함께 다룰 수 있다.
- 빠른 입력 변화로 생기는 경쟁 상태를 클린업으로 막을 수 있다.
- 여러 상태가 얽힐 때
useReducer로 정리할 수 있다.
useEffect로 fetch하기
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then((r) => {
if (!r.ok) throw new Error('요청 실패');
return r.json();
})
.then(setUsers)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>불러오는 중...</p>;
if (error) return <p>에러: {error}</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
세 가지 상태를 모두 처리하는 게 핵심입니다.
| 상태 | 화면 |
|---|---|
| loading | "불러오는 중..." |
| error | 에러 메시지 |
| success | 실제 데이터 |
💡 TIP —
fetch는 404·500 같은 HTTP 오류에서도 reject하지 않습니다.r.ok를 직접 확인해 에러로 던져 줘야 합니다.
경쟁 상태(race condition) 막기
검색어가 빠르게 바뀌면 여러 요청이 동시에 진행되고, 늦게 보낸 요청이 먼저 도착해 엉뚱한 결과가 표시될 수 있습니다. 클린업으로 "오래된 요청 결과 무시" 플래그를 둡니다.
useEffect(() => {
let ignore = false; // 이 effect가 유효한지
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data); // 최신 요청만 반영
});
return () => { ignore = true; }; // query가 바뀌면 이전 요청 무효화
}, [query]);
AbortController 로 아예 요청을 취소할 수도 있습니다.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch((e) => { if (e.name !== 'AbortError') setError(e.message); });
return () => controller.abort();
}, [query]);
⚠️ 주의 — 클린업 없이 여러 요청을 던지면 "이전 결과가 최신 결과를 덮어쓰는" 버그가 간헐적으로 발생합니다. 재현이 어려워 더 위험하니 처음부터 막아 두세요.
useReducer — 복잡한 상태 정리
loading·error·data처럼 여러 state가 함께 바뀐다면, useReducer 로 한 덩어리로 관리하면 깔끔합니다.
import { useReducer, useEffect } from 'react';
const initial = { status: 'loading', data: null, error: null };
function reducer(state, action) {
switch (action.type) {
case 'loading': return { status: 'loading', data: null, error: null };
case 'success': return { status: 'done', data: action.data, error: null };
case 'error': return { status: 'error', data: null, error: action.error };
default: return state;
}
}
function Users() {
const [state, dispatch] = useReducer(reducer, initial);
useEffect(() => {
let ignore = false;
dispatch({ type: 'loading' });
fetch('/api/users')
.then((r) => r.json())
.then((data) => { if (!ignore) dispatch({ type: 'success', data }); })
.catch((e) => { if (!ignore) dispatch({ type: 'error', error: e.message }); });
return () => { ignore = true; };
}, []);
if (state.status === 'loading') return <p>불러오는 중...</p>;
if (state.status === 'error') return <p>에러: {state.error}</p>;
return <ul>{state.data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
useReducer(reducer, 초기상태)는[state, dispatch]를 반환합니다.dispatch({ type, ... })로 "무슨 일이 일어났는지"를 알리면reducer가 다음 상태를 계산합니다.- 상태 전이 로직이 한곳(reducer)에 모여 테스트·추적이 쉽습니다.
| 비교 | useState | useReducer |
|---|---|---|
| 적합한 경우 | 단순·독립된 값 | 서로 얽힌 여러 값 |
| 갱신 방식 | set 함수 | dispatch(action) |
| 로직 위치 | 컴포넌트 곳곳 | reducer 한 곳 |
💡 TIP — 불가능한 상태 조합(예: loading이면서 error)을 막으려면
status같은 단일 필드로 표현하는 "상태 머신" 방식이 안전합니다.
요약
- 데이터 패칭은 loading·error·success 세 상태를 함께 다룬다.
fetch는 HTTP 오류를 던지지 않으니r.ok를 직접 확인한다.- 입력이 자주 바뀌면 클린업(
ignore플래그·AbortController)으로 경쟁 상태를 막는다. - 얽힌 여러 state는
useReducer로 reducer 한곳에 모은다. - 실무에서는 React Query 같은 라이브러리가 이런 일을 대신해 주기도 한다.
연습문제
- 버튼 클릭 시 랜덤 사용자 한 명을 fetch해 로딩·에러·결과를 모두 표시하세요.
- 검색창 입력에 따라 결과를 불러오되,
ignore플래그로 경쟁 상태를 막으세요. - 2번을
AbortController방식으로 다시 구현해 보세요. - 카운터 예제를
useReducer로 작성하세요. (increment,decrement,reset액션) - 위
Users예제에 "새로고침" 버튼을 추가해 다시 fetch하도록 만드세요.
힌트 — 5번은 effect 의존성에
version같은 state를 넣고 버튼이 그 값을 증가시키게 하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“React.js” 강좌에 대한 댓글입니다.