React로 간단한 todolist 만들기를 하면서 비동기 요청을 할 때 연관된 state가 여러개 필요했다. 이를 클린코드 react 강의에서 배운 여러가지 방법으로 다뤄보자.
비동기 데이터를 다루는 todolist
, 로딩중을 나타내는 loading
, 에러 처리를 위한 error
, 비동기 요청 성공을 나타내는 success
까지 총 4개의 연관된 상태가 있었다.
그리고 4개의 상태에 따라 다른 결과를 화면에 보여준다.
이전까지 tanstack-query
같은 라이브러리를 사용하지 않을 때에는 이런 방식으로 서버 상태를 다루곤 했다.
function App() {
const [todoList, setTodoList] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const fetchTodo = async () => {
try {
setLoading(true);
const response = await fetch(BASE_URL);
if (!response.ok) {
throw new Error("GET 요청 실패");
}
const data = await response.json();
setTodoList(data);
setSuccess(true);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTodo();
}, []);
if (loading) return <div>로딩중..</div>;
if (error) return <div>에러가 발생했습니다. Error: {error}</div>;
if (!success) return null;
return (
<div className={styles.container}>
<header className={styles.header}>
나<sub>만의</sub> 작<sub>은</sub> 스<sub>케줄러</sub>
</header>
<main className={styles.main}>
<TodoForm />
<TodoList todoList={todoList} />
</main>
</div>
);
}
export default App;
첫번째 개선 방법은 연관된 상태를 문자열을 이용해 하나의 상태로 만드는 것이다.
초기 코드를 잘 들여다보면 loading
, error
, success
를 하나의 state
로 묶어낼 수 있다는 것을 알 수 있다.
promiseState
를 loading
, error
, success
라는 3가지 문자열로 표현한다.
하지만 이 방법의 경우 연관된 상태가 true
나 false
일 때만 사용 가능하다는 단점이 있다.
그래서 error 메시지를 promiseState
로는 표현할 수 없다.
const state = {
loading: "loading",
error: "error",
success: "success",
};
function App() {
const [todoList, setTodoList] = useState([]);
const [promiseState, setPromiseState] = useState(state.loading);
const fetchTodo = async () => {
try {
setPromiseState(state.loading);
const response = await fetch(BASE_URL);
if (!response.ok) {
throw new Error("GET 요청 실패");
}
const data = await response.json();
setTodoList(data);
setPromiseState(state.success);
} catch (error) {
setPromiseState(state.error);
}
};
useEffect(() => {
fetchTodo();
}, []);
if (promiseState == "loading") {
return <div>로딩중..</div>;
}
if (promiseState == "error") {
return <div>에러가 발생했습니다.</div>;
}
if (promiseState == "success") {
return (
<div className={styles.container}>
<header className={styles.header}>
나<sub>만의</sub> 작<sub>은</sub> 스<sub>케줄러</sub>
</header>
<main className={styles.main}>
<TodoForm />
<TodoList />
</main>
</div>
);
}
}
export default App;
다음 개선 방법은 연관된 상태를 객체를 이용해서 표현하는 방법이다. 앞서 문자열로 묶어냈던 3가지 상태들은 하나가 true
이면 나머지가 false
라는 특징이 있다. 이 점을 이용해서 객체형 state
하나 만을 가지고 연관된 상태들을 표현할 수 있다.
객체를 사용하면 열거형에서와는 다르게 error 메시지를 상태에 담을 수 있다는 장점이 있다.
const initialState = {
loading: false,
error: false,
success: false,
};
const fetchTodo = async () => {
try {
setPromiseState({ ...initialState, loading: true });
const response = await fetch(BASE_URL);
if (!response.ok) {
throw new Error("GET 요청 실패");
}
const data = await response.json();
setTodoList(data);
setPromiseState({ ...initialState, success: true });
} catch (error) {
setPromiseState({ ...initialState, error: error });
}
};
useEffect(() => {
fetchTodo();
}, []);
if (promiseState.loading) {
return <div>로딩중..</div>;
}
if (promiseState.error) {
return <div>에러가 발생했습니다. Error: {promiseState.error}</div>;
}
if (promiseState.success) {
return (
<div className={styles.container}>
<header className={styles.header}>
나<sub>만의</sub> 작<sub>은</sub> 스<sub>케줄러</sub>
</header>
<main className={styles.main}>
<TodoForm />
<TodoList />
</main>
</div>
);
}
}
export default App;
useReducer
로 코드를 개선하기 전, 짚고 넘어가야하는 부분이 있다.
지금까지 다뤘던 상태중 success
는 사실 없어도 되는 상태다.
왜냐하면 비동기 요청이 성공적으로 완료되면 todoList
상태에 데이터가 들어가기 때문이다.
이렇게 다른 상태로 표현 가능한 상태는 없애는 것이 좋다.
객체형 state
의 경우에는 useState
대신 useReducer
라는 hook을 사용할 수 있다.
useReducer
의 경우 useState
와 비슷한데, state와 이를 변화시키는 dispatch 함수를 반환하고, 인자로는 reducer 함수와 초기 상태값을 받는다.
reducer
- 반드시 순수 함수여야 하며 prev state와 action을 인수로 받아서 next state를 반환한다. state와 action에는 모든 데이터 타입이 할당될 수 있다.dispatch
- state를 새로운 값으로 업데이트하고 리렌더링을 일으킨다. 함수형 프로그래밍에서는 어떤 외부 상태에 의존하지도 않고 변경시키지도 않는, 즉 부수 효과(Side Effect)가 없는 함수를 순수함수(Pure function)라고 한다. 즉, 동일한 입력이 주어지면 항상 동일한 출력을 반환하는 함수를 말한다.
actionType
의 경우, 그냥 문자열로 전달해줘도 되지만 실수를 방지하기 위해서 객체를 만들어서 다뤄보았다.
reducer
는 아래와 같이 switch case문으로 많이 쓰는데, 사실 if else로 써도 되고, lookup table로 써도 상관없다. 순수함수이기만 하면 됨!
const initialState = {
loading: false,
error: null,
data: null,
};
const actionType = {
loading: "LOADING",
error: "ERROR",
success: "SUCCESS",
};
const reducer = (state, action) => {
switch (action.type) {
case "LOADING":
return { loading: true, error: null, data: null };
case "ERROR":
return { loading: false, error: action.error, data: null };
case "SUCCESS":
return { loading: false, error: null, data: action.data };
default:
throw new Error(`해당하는 action type이 없습니다. ${action.type}`);
}
};
function App() {
const [promiseState, dispatch] = useReducer(reducer, initialState);
const fetchTodo = async () => {
try {
dispatch({ type: actionType.loading });
const response = await fetch(BASE_URL);
if (!response.ok) {
throw new Error("GET 요청 실패");
}
const data = await response.json();
dispatch({ type: actionType.success, data });
} catch (error) {
dispatch({ type: actionType.error, error });
console.error(error);
}
};
useEffect(() => {
fetchTodo();
}, []);
const { loading, error, data: todoList } = promiseState;
if (loading) {
return <div>로딩중..</div>;
}
if (error) {
return <div>에러가 발생했습니다. Error: {error}</div>;
}
return (
<div className={styles.container}>
<header className={styles.header}>
나<sub>만의</sub> 작<sub>은</sub> 스<sub>케줄러</sub>
</header>
<main className={styles.main}>
<TodoForm />
<TodoList todoList={todoList} />
</main>
</div>
);
}
export default App;
복잡한 부분을 숨기고 useReducer
로 개선한 코드를 재사용 할 수 있게 만들기 위해 custom hook으로 만들어보겠다.
먼저, 여러 비동기 요청을 할 때 custom hook을 재사용할 수 있도록 비동기 요청만 하는 함수를 따로 분리했다.
const getTodo = async () => {
const response = await fetch(BASE_URL);
return response;
};
그리고 reducer로 처리하는 부분을 custom hook으로 만들었다.
custom hook은 use prefix로 시작하는 것이 규칙이다.
비동기를 처리하는 callback과 useEffect의 dependency를 인자로 받도록 했다.
반환 값으로는 state와 fetchData를 설정했다. fetchData를 부를 때마다 다시 서버에 요청을 하기 때문에 refetch 함수로 쓸 수 있다.
이렇게 하면 여러 비동기 함수에서 재사용할 수 있는 useAsync hook이 만들어진다.
// useAsync.js
const reducer = (state, action) => {
switch (action.type) {
case "LOADING":
return { loading: true, error: null, data: null };
case "ERROR":
return { loading: false, error: action.error, data: null };
case "SUCCESS":
return { loading: false, error: null, data: action.data };
default:
throw new Error(`해당하는 action type이 없습니다. ${action.type}`);
}
};
const actionType = {
loading: "LOADING",
error: "ERROR",
success: "SUCCESS",
};
export const useAsync = (callback, deps = []) => {
const [state, dispatch] = useReducer(reducer, {
loading: false,
error: null,
data: null,
});
const fetchData = async () => {
dispatch({ type: actionType.loading });
try {
const response = await callback();
if (!response.ok) {
throw new Error("요청 실패");
}
const data = await response.json();
dispatch({ type: actionType.success, data });
} catch (error) {
dispatch({ type: actionType.error, error });
console.error(error);
}
};
useEffect(() => {
fetchData();
}, deps);
return [state, fetchData];
};
만든 useAsync hook은 아래와 같이 사용할 수 있다.
// App.js
import { useAsync } from "./hooks/useAsync";
const getTodo = async () => {
const response = await fetch(BASE_URL);
return response;
};
function App() {
const [promiseState, refetchTodo] = useAsync(getTodo);
const { loading, error, data: todoList } = promiseState;
if (loading) {
return <div>로딩중..</div>;
}
if (error) {
return <div>에러가 발생했습니다. Error: {error}</div>;
}
return (
<div className={styles.container}>
<header className={styles.header}>
나<sub>만의</sub> 작<sub>은</sub> 스<sub>케줄러</sub>
</header>
<main className={styles.main}>
<TodoForm />
<TodoList todoList={todoList} />
</main>
</div>
);
}
export default App;
이렇게 다양한 방법으로 연관된 상태를 관리할 수 있다는 것을 이번 todolist 만들기를 통해 배웠다.
이렇게 기본적인 것에서 이를 어떻게 발전시킬 수 있을지 고민하다보면 많은 것을 얻어갈 수 있다.
https://react.vlpt.us/
https://velog.io/@jini9256/순수함수란-무엇인가요
https://react.dev/reference/react/useReducer