여러 이벤트 핸들러에 뿌려진 상태 업데이트가 많은 컴포넌트는 압도적이에요. 이러한 경우에는 모든 업데이트 로직을 컴포넌트 외부에 하나의 함수로 통합할 수 있는데 이를 리듀서라고 불러요.
이 페이지에서는
- 리듀서 함수가 무엇인지
-useState
를useReducer
로 어떻게 리팩토링 하는지
- 리듀서를 언제 사용하는지
- 어떻게 써야 올바르게 쓰는지
를 알아볼 거예요.
Consolidate state logic with a reducer | 리듀서로 상태 로직 합치기
컴포넌트가 복잡해질수록 컴포넌트의 상태가 업데이트되는 방법을 힐끗 봐서는 알 수가 없어요. 예를 들어 아래의 TaskApp
컴포넌트는 tasks
의 배열을 상태로 가지고 있고 할 일의 추가, 삭제, 수정을 위한 3개의 서로 다른 이벤트 핸들러를 사용해요.
각각의 이벤트 핸들러는 상태를 업데이트하기 위해 setTasks
라고 불려요. 컴포넌트가 커질수록 많은 상태 로직이 컴포넌트 안에 뿌려져요. 이 복잡도를 낮추고 모든 로직을 하나의 접근 가능한 공간에 보관하려면 상태 로직을 컴포넌트 외부에 "리듀서"라고 불리는 단일 함수로 옮기세요.
리듀서는 상태를 다루는 또 다른 방법이에요. useState
에서useReducer
로의 마이그레이션은 세 단계를 거쳐야해요.
- 상태 설정에서 액션 디스패치로 옮기세요.
- 리듀서 함수를 작성하세요.
- 컴포넌트에서 리듀서를 사용하세요.
Step 1: Move from setting state to dispatching actions | 1단계: 상태 설정을 액션 디스패치로 옮기기
이벤트 핸들러는 상태를 설정해서 무엇을 해야하는지를 지정해요.
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
모든 상태 설정 로직을 제거하세요. 남겨놓은 것들은 세 개의 이벤트 핸들러에요.
handleAddTast(text)
는 사용자가 "Add"를 누를 때 호출돼요.handleChangeTast(task)
는 사용자가 할 일을 토글하거나 사용자가 "Save"를 누를 때 호출돼요.handleDeleteTast(taskId)
는 사용자가 "Delete"를 누를 때 호출돼요.
리듀서를 사용한 상태 관리는 직접적으로 상태를 설정하는 것과는 약간 달라요. 리액트에게 상태를 설정하여 "무엇을 해야할지"를 말해주는 대신 "사용자가 방금 무엇을 했는지"를 이벤트 핸들러에서 "액션"을 디스패치하여 정해줘요. (상태 업데이트 로직은 다른 어딘가에 있어요!) 이벤트 핸들러를 통하여 "tast
를 설정하는" 대신에 "할 일이 추가/삭제/변경되었어요."라는 액션을 디스패치해요. 이는 사용자의 의도를 더 잘 설명해요.
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
dispatch
로 전달한 객체는 "액션"이라고 불러요.
function handleDeleteTask(taskId) {
dispatch(
// "action" 객체:
{
type: 'deleted',
id: taskId,
}
);
}
이 객체는 일반적인 자바스크립트 객체에요. 안에 무엇을 넣을지를 정하지만 일반적으로무슨 일이 일어났는지에 대한 최소한의 정보를 포함해요.(이후 단계에서dispatch
함수 자체를 넣을 거예요.)
노트
액션 객체는 어떤 모양이든 상관 없어요.
컨벤션에 따르면 무슨 일이 일어났는지를 설명하는type
을 문자열로 주고 다른 필드에 추가적인 정보를 전달하는 것이 일반적이에요.type
은 컴포넌트에 특화되어 있기 때문에 이 예시에서는added
또는added_task
를 사용하는 것이 좋아요. 무슨 일이 발생했는지를 알려주는 이름을 골라보세요!
dispatch({ // 컴포넌트에 특화된 이름 type: 'what_happened', // 다른 필드는 이곳에 위치해요 });
Step 2: Write a reducer function | 2단계: 리듀서 함수 작성하기
리듀서 함수는 상태 로직을 넣을 곳이에요. 현재 상태와 액션 객체를 인자로 받고 다음 상태를 반환해요.
function yourReducer(state, action) {
// 리액트가 설정할 수 있도록 다음 상태를 반환하세요
}
리액트는 리듀서에서 반환할 상태를 설정해요.
이 예시에서 상태 설정 로직을 이벤트 핸들러에서 리듀서 함수로 옮기고 싶다면 아래의 과정을 거치세요.
- 현재 상태(
tasks
)를 첫 번째 인자로 선언하세요. action
객체를 두 번째 인자로 선언하세요.- (리액트가 상태로 설정할) 다음 상태를 리듀서에서 반환하세요.
모든 설정 로직을 리듀서 함수로 이동시키면 이렇게 돼요.
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Unknown action: ' + action.type);
}
}
리듀서 함수는 상태(tasks
)를 인자로 가지기 때문에 컴포넌트 외부에서 선언할 수 있어요. 이렇게 하면 들여쓰기의 수가 감소하고 코드의 가독성을 더 높일 수 있어요.
노트
위의 코드는 if/else 문을 사용하고 있지만 리듀서 안에서는 switch 구문을 사용하는 것이 컨벤션이에요. 결과는 같지만 switch 구문이 한 눈에 보기 편해요.
이제 이 문서에서는 아래와 같은 switch 구문을 사용할 거예요.
각각의function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [ ...tasks, { id: action.id, text: action.text, done: false, }, ]; } case 'changed': { return tasks.map((t) => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter((t) => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } }
case
블록을 중괄호{
와}
안에 넣었기 때문에 각기 다른case
들에서 선언된 변수가 서로 충돌하지 않아요. 또한case
는 보통return
으로 끝나요. 만약return
을 잊어버리면 코드는 다음case
로 "넘어가버려서" 오류를 만들어요!
만약 switch 구문이 아직 편하지 않다면 if/else를 사용해도 전혀 문제가 없어요.
리듀서는 왜 이렇게 호출되나요?
더 알아보기리듀서는 컴포넌트 안에 있는 코드의 양을 "줄여주지만" 실제로는 배열에서 동작하는reduce
함수에 따라 명명되었어요.reduce()
는 배열의 가져와서 여러 값 중 하나를 "누적해요."
const arr = [1, 2, 3, 4, 5]; const sum = arr.reduce( (result, number) => result + number ); // 1 + 2 + 3 + 4 + 5
reduce
에 전달한 함수를 "리듀서"라고 불러요. 리듀서는 지금까지의 결과와 현재 항목을 받고나서 다음 결과를 반환해요. 리액트 리듀서는 동일한 아이디어의 예시에요. 리액트 리듀서 또한 지금까지의 상태와 액션을 받고 나서 다음 상태를 반환해요. 이러한 방식으로 리듀서는 시간이 지남에 따라 액션을 상태로 누적해요.
이를 직접 할 필요는 없겠지만 리액트가 하는 일은 이와 비슷해요!
Step 3: Use the reducer from your component | 3단계: 컴포넌트에서 리듀서 사용하기
마지막으로 컴포넌트에 taskReducer
를 훅으로 추가하세요. useReducer
훅을 리액트에서 불러오세요.
impot { useReducer } from 'react';
그리고 useState
를
const [tasks, setTasks] = useState(initialTasks);
useReducer
로 바꾸세요.
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer
훅은 useState
와 비슷해요. 초기 상태를 전달해야하고, 상태값을 반환하며 상태를 설정하는 방법(이 경우에는 디스패치 함수)을 반환해요. 그러나 조금 달라요.
useReducer
훅은 두 개의 인자가 필요해요.
- 리듀서 함수
- 초기 상태 값
이제 완벽하게 연결되었어요! 리듀서는 컴포넌트 파일의 맨 아래에 선언되어있어요.
만약 원한다면 리듀서를 다른 파일로 옮겨도 돼요.
이처럼 관심을 분리하면 컴포넌트 로직은 더 읽기 쉬워져요. 이제 이벤트 핸들러는 액션을 디스패치하여 무슨 일이 일어났는지만을 결정하고 리듀서 함수는 상태가 어떻게 업데이트 되어야하는지를 액션에 맞춰서 결정해요.
Comparing useState
and useReducer
| useState
와 useReducer
비교하기
리듀서는 단점이 없는 게 아니에요! 이제 이 둘을 비교해볼게요.
- 코드 크기: 일반적으로
useState
를 사용하면 미리 작성해야하는 코드가 적어요.useReducer
를 사용하면 리듀서 함수와 디스패치 함수를 모두 작성해야해요. 그러나useReducer
는 여러 이벤트 핸들러가 비슷한 방법으로 상태를 업데이트 한다면 코드를 줄여줄 거예요. - 가독성:
useState
는 상태 업데이트가 단순하다면 읽기가 쉬워요. 조금 더 복잡해진다면 컴포넌트 코드를 복잡하게 만들고 훑어보기 어려워져요. 이런 경우에서는useReducer
가 업데이트 로직의 어떻게 부분을 이벤트 핸들러의 일어난 일에서 깔끔히 분리해줘요. - 디버깅:
useState
로 버그가 발생했다면 상태가 어디서 그리고 왜 잘못 설정되었는지를 찾기 어려워요.useReducer
를 사용하면 모든 상태 업데이트와 왜 버그가 발생했는지(어떤action
에서 발생했는지)를 보기 위해 콘솔창에 로그를 찍어볼 수 있어요. 만약action
이 모두 맞다면 리듀서 로직 자체에서 실수가 발생했다는 것을 알 수 있어요. 그러나useState
를 사용한 것보다 더 많은 코드를 하나씩 봐야만 해요. - 테스트: 리듀서는 컴포넌트에 의존하지 않는 순수함수에요. 이는 독립적으로 각각을 내보내서 테스트 할 수 있음을 의미해요. 일반적으로 실제와 같은 환경에서 테스트하는 것이 좋지만 복잡한 상태 업데이트 로직은 리듀서가 특정 초기 상태와 액션에서 특정 상태를 반환했음을 알기에 유용해요.
- 개인적 선호: 어떤 사람은 리듀서를 좋아하지만 어떤 사람은 그렇지 않아요. 상관 없어요. 이건 선호의 문제니까요.
useState
와useReducer
를 서로 변환해도 돼요. 이 둘은 똑같아요!
만약 컴포넌트에서 옳지 않은 상태 업데이트 때문에 버그와 종종 맞닥뜨리거나 코드를 더욱 구조화하고 싶다면 리듀서를 사용할 것을 권장해요. 모든 곳에 리듀서를 쓸 필요는 없어요. 편하게 섞어서 사용하세요. 같은 컴포넌트 안에서 useState
와 useReducer
를 모두 사용해도 돼요!
Writing reducers well | 리듀서를 올바르게 작성하기
리듀서를 작성할 때 아래 두 가지를 꼭 기억하세요.
- 리듀서는 순수해야해요. 상태 업데이터 함수와 비슷하게 리듀서는 렌더링 동안 실행해요. (액션은 다음 렌더링까지 대기열에 들어가있어요.) 이는 리듀서가 순수해야한다는 것을 의미해요. 동일 입력에는 동일한 결과를 보여줘야해요. 요청을 보내서는 안되고 타임아웃을 예약해서도 안되며 (컴포넌트 외부에 있는 것들에 영향을 미치는 연산인) 다른 사이드 이펙트를 수행해서도 안돼요. 뮤테이션 없이 객체와 배열을 업데이트 해야해요.
- 데이터에서 여러가지를 변경한다고 해도 액션은 하나의 사용자 상호작용을 설명해야해요. 예를 들어 만약 사용자가 리듀서로 관리되는 5개의 필드를 가진 폼에서 "초기화"를 눌렀다면 5개의 각기 다른
set_field
액션보다는 하나의reset_form
액션을 갖고 있는 편이 더 합리적이에요. 만약 리듀서에서 모든 액션 로그를 기록한다면 그 로그는 어떤 상호작용과 반응이 어떤 순서로 발생했는지를 재구성하기 충분할 정도로 명확해야해요. 이는 디버깅에 도움이 돼요!
Writing concise reducers with Immer | Immer를 사용하여 간결한 리듀서 작성하기
일반적인 상태에서 객체와 배열을 업데이트하는 것처럼 Immer 라이브러리를 사용하여 리듀서를 더 간결하게 작성할 수 있어요. useImmerReducer
는 push
나 arr[i] =
할당을 통해 상태를 바꾸도록 만들어줘요.
리듀서는 순수해야하기 때문에 상태를 변경하면 안돼요. 그러나 Immer는 변형해도 안전한 특별한 draft
객체를 제공해요. 실제로는 Immer가 변경사항을 포함한 상태의 사본을 draft
로 만드는 거예요. 이는 useImmerReducer
로 관리되는 리듀서가 첫 번째 인자를 변형하고 반환 구문이 필요하지 않는 이유에요.
Recap | 요약
useState
를useReducer
로 바꾸기 위해서는- 이벤트 핸들러에서 액션을 디스패치하세요.
- 주어진 상태와 액션으로 다음 상태를 반환하는 리듀서 함수를 작성하세요.
useState
를useReducer
로 변경하세요.
- 리듀서는 조금 더 많은 코드를 작성해야하지만 디버깅과 테스팅을 도와줘요.
- 리듀서는 순수해야해요.
- 각 액션은 단일 사용자 상호작용을 설명해요.
- 만약 불변 스타일이 아닌 리듀서를 작성하고 싶다면 Immer를 사용하세요.
Challenges | 도전 과제
1. 이벤트 핸들러에서 액션 디스패치하기
현재 ContactList.js
와 Chat.js
안의 이벤트 핸들러에는 // TODO
라는 주석이 있어요. 입력창에 타이핑이 되지 않고 버튼을 클릭해도 선택된 수신인이 변하지 않는 이유에요.
이 두 // TODO
를 해당하는 액션을 dispatch
하는 코드로 바꿔보세요. 기대되는 형태와 액션의 타입을 보고 싶다면 messengerReducer.js
안의 리듀서를 확인하세요. 이 리듀서는 이미 작성되었기 때문에 바꿀 필요가 없어요. ContactList.js
와 Chat.js
의 액션만 디스패치하세요.
Hint
dispatch
함수는 prop으로 전달되었기 때문에 이미 두 컴포넌트에서 사용할 수 있어요. 따라서 해당하는 액션 객체에 dispatch
를 호출하세요.
액션 객체의 모양을 체크하려면 리듀서를 보고 어떤 action
필드를 기대하는지 확인하세요. 예를 들어 리듀서의 changed_selection
케이스는 아래와 같아요.
case 'changed_selection': {
return {
...state,
selectedId: action.contactId
};
}
이는 액션 객체가 type: 'changed_selection'
을 갖고 있다는 것을 의미해요. action.contactId
가 사용된 것을 보아 contactId
속성을 액션에 추가해야한다는 사실을 알 수 있어요.
Solution
리듀서 코드에서 액션은 아래와 같이 동작한다는 것을 유추할 수 있어요.
// 사용자가 "Alice"를 눌렀을 때
dispatch({
type: 'changed_selection',
contactId: 1,
});
// 사용자가 "Hello!"을 입력했을 때
dispatch({
type: 'edited_message',
message: 'Hello!',
});
해당하는 메시지를 디스패치하도록 업데이트한 예시에요.
2. 메시지를 전송할 때 입력창 비우기
현재, "Send" 버튼을 눌러도 아무일이 일어나지 않아요. 이벤트 핸들러를 "Send" 버튼에 추가하여 아래와 같이 동작하게 만드세요.
alert
로 수신인의 이메일과 메시지를 보여주세요.- 메시지 입력창을 지우세요.
Solution
"Send" 버튼의 이벤트 핸들러 안에서 이 문제를 해결할 방법은 여러개가 있어요. 첫 번째 방법은 alert에 보여주고 edited_message
액션을 빈 message
로 디스패치 하는 거예요.
이 방법은 "Send"를 눌렀을 때 동작하고 입력창을 비워줘요.
그러나 사용자의 관점*에서는 메시지를 보내는 것은 필드를 수정하는 것과 다른 액션이에요. 이를 반영하면 sent_messge
라는 *새로운 액션을 만들고 리듀서에서 분리아여 다룰 수 있어요.
동작은 동일해요. 하지만 이상적인 액션 타입은 "상태를 어떻게 바꿀지"가 아니라 "사용자가 무엇을 했는지"를 설명해야해요. 이렇게 하면 나중에 더 많은 특성을 더 쉽게 추가할 수 있어요.
두 방법에서 alert
를 리듀서 안에 넣지 않는 것이 중요해요. 리듀서는 순수함수여야하고 다음 상태를 계산하는 작업만을 수행해야해요. 사용자에게 메시지를 보여주는 것을 포함한 그 어떤 작업도 "수행"해서는 안돼요. 그런 작업들은 이벤트 핸들러에서 이루어져야해요. (이와 같은 실수를 잡기 위해 엄격한 모드에서 리액트는 리듀서를 여러번 호출해요. 만약 alert가 리듀서에 있다면 두 번 실행되는 이유에요.)
3. 탭을 바꿀 때 입력값 복구하기
이 예시에서 수신인을 바꾸는 것은 항상 입력된 텍스트를 지워버려요.
case 'changed_selection': {
return {
...state,
selectedId: action.contactId,
message: '' // 입력창 초기화
};
왜냐하면 여러 수신인 사이에서 하나의 메시지 초안을 공유하고 싶지 않기 때문이에요. 그러나 만약 앱이 각 연락처마다 초안들을 분리해서 "기억한다면" 연락처를 바꿀 때 그 메시지를 복구시키는 것이 좋을 거예요.
과제는 상태가 구조화된 방법을 변경하여 각 연락처마다 메시지 초안을 분리하여 기억하도록 만드는 거예요. 리듀서, 초기 상태 그리고 컴포넌트를 약간씩 고치면 돼요.
Hint
아래와 같이 상태를 구조와 할 수 있어요.
export const initialState = {
selectedId: 0,
messages: {
0: 'Hello, Taylor', // contactId = 0의 초안
1: 'Hello, Alice', // contactId = 1의 초안
},
};
속성 계산 구문인 [key]: value
는 message
객체를 업데이트하는데 도움이 돼요.
{
...state.messages,
[id]: message
}
Solution
리듀서가 각각의 메시지 초안을 연락처마다 저장하고 업데이트하도록 만들어야해요.
// 입력이 수정되면
case 'edited_message': {
return {
// selection 처럼 다른 상태를 유지하세요
...state,
messages: {
// 다른 연락처는 유지하세요
...state.messages,
// 그러나 선택된 연락처의 메시지는 바꾸세요
[state.selectedId]: action.message
}
};
}
현재 선택된 연락처의 메시지를 읽도록 Messenger
컴포넌트를 업데이트 해야해요.
const message = state.messages[state.selectedId];
완성된 답안이에요.
이렇게 다른 동작을 구현하기 위해 어떤 이벤트 핸들러도 수정하지 않아도 돼요. 리듀서가 없다면 상태를 업데이트하는 모든 이벤트 핸들러를 수정해야해요.
4. useReducer
를 스크래치에서 구현하기
앞선 예시에는 useReducer
훅을 리액트에서 불러왔어요. 이번에는 직접 useReducer
훅을 구현해볼게요! 시작을 위한 스텁 함수는 있어요. 코드는 10줄 이상 작성할 필요가 없어요.
수정사항을 테스트해보려면 입력창에 타이핑을 해보거나 연락처를 선택하세요.
Hint
조금 더 상세한 구현 스케치를 드릴게요.
export function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);
function dispatch(action) {
// ???
}
return [state, dispatch];
}
두 인자, 현재 상태와 액션 객체를 넣어서 리듀서 함수를 재호출하고 다음 상태를 반환하세요. dispatch
구현이 이를 가지고 무엇을 해야할까요?
Solution
액션을 디스패치하면 현재 상태와 액션으로 리듀서를 호출하고 다음 상태로 결과를 저장해요. 코드로 구현하면 아래와 같아요.
대부분의 경우에는 문제가 되지 않지만 조금 더 정확한 구현은 아래와 같아요.
function dispatch(action) {
setState((s) => reducer(s, action));
}
이는 업데이터 함수와 비슷하게 디스패치된 액션이 다음 렌더링까지 대기열에 있기 때문이에요.