많은 이벤트 핸들러에 걸쳐 많은 상태 업데이트가 있는 컴포넌트는 압도적일 수 있습니다. 이러한 경우에는 리듀서라는 단일 함수에서 컴포넌트 외부의 모든 상태 업데이트 로직을 통합할 수 있습니다.
리듀서로 상태 로직 통합하기
컴포넌트가 복잡해짐에 따라 컴포넌트의 상태가 업데이트되는 다양한 방법을 한눈에 파악하기가 어려워질 수 있습니다. 예를 들어, 아래의 TaskApp 컴포넌트는 상태의 작업 배열을 보유하고 있으며 작업을 추가, 제거, 편집하기 위해 세 가지 다른 이벤트 핸들러를 사용합니다
https://codesandbox.io/s/yrv1mz?file=/App.js&utm_medium=sandpack
sleepy-austin-yrv1mz - CodeSandbox
sleepy-austin-yrv1mz using react, react-dom, react-scripts
codesandbox.io
각 이벤트 핸들러는 setTasks를 호출하여 상태를 업데이트합니다. 이 컴포넌트가 커짐에 따라 그 안에 흩어진 상태 로직의 양도 증가합니다. 이러한 복잡성을 줄이고 모든 로직을 쉽게 접근할 수 있는 한 곳에 유지하기 위해 "리듀서"라는 컴포넌트 외부의 단일 함수로 상태 로직을 이동할 수 있습니다.
리듀서는 상태를 처리하는 또 다른 방법입니다. useState에서 useReducer로 이동하는 것은 세 단계로 이루어집니다
- state 설정에서 액션 디스패치로 이동
- 리듀서 함수 작성
- 컴포넌트에서 리듀서 사용
Step 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));
}
모든 상태 설정 로직을 제거하십시오. 남겨진 것은 세 개의 이벤트 핸들러입니다
- handleAddTask(text)는 사용자가 "추가"를 누를 때 호출됩니다.
- handleChangeTask(task)는 사용자가 작업을 전환하거나 "저장"을 누를 때 호출됩니다.
- handleDeleteTask(taskId)는 사용자가 "삭제"를 누를 때 호출됩니다.
리듀서를 사용하여 상태를 관리하는 것은 상태를 직접 설정하는 것과 약간 다릅니다. 상태를 설정하여 React에게 "무엇을 할지" 알리는 대신 이벤트 핸들러에서 "액션"을 디스패치하여 "사용자가 방금 무엇을 했는지" 명시합니다. (상태 업데이트 로직은 다른 곳에 존재할 것입니다!) 따라서 이벤트 핸들러를 통해 "작업 설정"하는 대신 "작업 추가/변경/삭제" 액션을 디스패치합니다. 이는 사용자의 의도를 더 명확하게 설명합니다.
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,
});
}
디스패치에 전달하는 객체를 "액션"이라고 합니다
function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}
이는 일반적인 JavaScript 객체입니다. 그 안에 무엇을 넣을지 결정할 수 있지만, 일반적으로 무슨 일이 발생했는지에 대한 최소한의 정보를 포함해야 합니다. (디스패치 함수 자체는 나중 단계에서 추가합니다.)
💡 액션 객체는 어떠한 모양이든 될 수 있습니다.
관례적으로, 무슨 일이 발생했는지 설명하는 문자열 type을 주고, 다른 필드에서 추가 정보를 전달하는 것이 일반적입니다. 타입은 컴포넌트에 특정하므로, 이 예제에서는 'added' 또는 'added_task'가 모두 괜찮습니다. 무슨 일이 발생했는지 설명하는 이름을 선택하세요!
dispatch({ // specific to component type: 'what_happened', // other fields go here });
Step 2: 리듀서 함수 작성
리듀서 함수는 상태 로직을 넣을 곳입니다. 현재 상태와 액션 객체를 두 개의 인수로 받고, 다음 상태를 반환합니다
function yourReducer(state, action) {
// return next state for React to set
}
React는 리듀서에서 반환하는 것을 상태로 설정합니다.
이 예제에서 이벤트 핸들러의 상태 설정 로직을 리듀서 함수로 이동하려면 다음을 수행합니다
- 현재 상태(tasks)를 첫 번째 인수로 선언합니다.
- action 객체를 두 번째 인수로 선언합니다.
- 리듀서에서 다음 상태를 반환합니다(React가 상태로 설정할 것입니다).
다음은 상태 설정 로직이 리듀서 함수로 이동한 모습입니다
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 문을 한눈에 보기 쉽습니다.
이 문서의 나머지 부분에서는 다음과 같이 사용할 것입니다
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으로 끝나야 합니다. 반환을 잊으면 코드가 다음 case로 떨어져 내려가 fall through 오류가 발생할 수 있습니다!
아직 switch 문에 익숙하지 않다면, if/else를 사용하는 것이 완전히 괜찮습니다.
리듀서가 왜 이렇게 불리는지?
리듀서는 컴포넌트 내의 코드 양을 "줄일(reduce)" 수 있지만, 실제로는 배열에서 수행할 수 있는 reduce() 연산의 이름을 따서 지어졌습니다.
reduce() 연산을 사용하면 배열을 가져와서 여러 값 중 하나를 "누적(accumulate)"할 수 있습니다
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
reduce에 전달하는 함수를 "리듀서"라고 합니다. 리듀서는 지금까지의 결과와 현재 항목을 받아 다음 결과를 반환합니다. React 리듀서는 동일한 개념의 예시입니다.지금까지의 상태와 액션을 가져와서 다음 상태를 반환합니다. 이런 방식으로 시간이 지남에 따라 액션을 상태로 누적합니다.
심지어 reduce() 메서드를 초기 상태와 액션 배열과 함께 사용하여 리듀서 함수를 전달하여 최종 상태를 계산할 수도 있습니다
https://codesandbox.io/s/ogkvy1?file=/index.js&utm_medium=sandpack
eloquent-mountain-ogkvy1 - CodeSandbox
eloquent-mountain-ogkvy1 using react, react-dom, react-scripts
codesandbox.io
직접 이렇게 할 필요는 없겠지만, React가 하는 것과 비슷합니다!
Step 3: 컴포넌트에서 리듀서 사용하기
마지막으로, tasksReducer를 컴포넌트에 연결해야 합니다. React에서 useReducer Hook을 가져옵니다
import {useReducer} from 'react';
그런 다음 useState를
const [tasks, setTasks] = useState(initialTasks);
이렇게 useReducer로 교체할 수 있습니다
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer Hook은 useState와 비슷하지만 초기 상태를 전달해야 하고 상태가 있는 값과 상태를 설정하는 방법(이 경우 dispatch 함수)을 반환합니다. 하지만 약간 다릅니다.
useReducer Hook은 두 가지 인자를 받습니다
- 리듀서 함수
- 초기 상태
그리고 반환합니다
- 상태 있는 값
- 리듀서에 사용자 액션을 "디스패치"하는 디스패치 함수
이제 완전히 연결되었습니다! 여기서 리듀서는 컴포넌트 파일 하단에 선언됩니다.
https://codesandbox.io/s/eueycz?file=/App.js&utm_medium=sandpack
happy-glade-eueycz - CodeSandbox
happy-glade-eueycz using react, react-dom, react-scripts
codesandbox.io
원한다면, 리듀서를 다른 파일로 옮길 수도 있습니다.
https://codesandbox.io/s/3uuhkl?file=/App.js&utm_medium=sandpack
objective-aj-3uuhkl - CodeSandbox
objective-aj-3uuhkl using react, react-dom, react-scripts
codesandbox.io
이렇게 관심사를 분리하면 컴포넌트 로직을 읽기가 더 쉬워집니다. 이제 이벤트 핸들러는 액션을 디스패치하여 무슨 일이 발생했는지만 명시하고, 리듀서 함수는 그들에 대한 응답으로 상태가 어떻게 업데이트되는지 결정합니다.
useState와 useReducer 비교하기
리듀서에도 단점이 없는 것은 아닙니다! 몇 가지 비교 방법은 다음과 같습니다
- 코드 크기: 일반적으로 useState를 사용하면 초기에 작성해야 할 코드가 적습니다. useReducer를 사용하면 리듀서 함수와 디스패치 액션을 모두 작성해야 합니다. 그러나 useReducer는 여러 이벤트 핸들러가 비슷한 방식으로 상태를 수정하는 경우 코드를 줄이는 데 도움이 됩니다.
- 가독성: useState는 상태 업데이트가 간단할 때 읽기 매우 쉽습니다. 복잡해지면 컴포넌트의 코드가 비대해지고 스캔하기 어려워집니다. 이 경우 useReducer를 사용하면 업데이트 로직의 방법과 이벤트 핸들러의 발생 사항을 깔끔하게 분리할 수 있습니다.
- 디버깅: useState로 버그가 발생한 경우, 어디에서 상태가 잘못 설정되었는지와 이유를 파악하기 어려울 수 있습니다. useReducer를 사용하면 리듀서에 콘솔 로그를 추가하여 모든 상태 업데이트와 발생 이유(어떤 액션 때문에)를 확인할 수 있습니다. 각 액션이 정확하면 리듀서 로직 자체에 오류가 있다는 것을 알 수 있습니다. 그러나 useState보다 더 많은 코드를 거쳐야 합니다.
- 테스트: 리듀서는 컴포넌트에 의존하지 않는 순수 함수입니다. 이는 리듀서를 따로 내보내어 격리시켜 테스트할 수 있다는 의미입니다. 일반적으로 컴포넌트를 더 현실적인 환경에서 테스트하는 것이 좋지만, 복잡한 상태 업데이트 로직의 경우 특정 초기 상태와 액션에 대해 리듀서가 특정 상태를 반환한다고 주장하는 것이 유용할 수 있습니다.
- 개인 취향: 어떤 사람들은 리듀서를 좋아하고, 어떤 사람들은 그렇지 않습니다. 괜찮습니다. 취향 문제입니다. useState와 useReducer 사이를 항상 변환할 수 있습니다. 그들은 동등합니다!
특정 컴포넌트에서 잘못된 상태 업데이트로 인한 버그가 자주 발생하고 코드에 더 많은 구조를 도입하려는 경우 리듀서를 사용하는 것이 좋습니다. 모든 것에 리듀서를 사용할 필요는 없습니다. 자유롭게 혼합하고 맞춰보세요! 심지어 같은 컴포넌트에서 useState와 useReducer를 함께 사용할 수도 있습니다.
리듀서를 잘 작성하기
리듀서를 작성할 때 다음 두 가지 팁을 기억하세요
- 리듀서는 순수해야 합니다. 상태 업데이터 함수와 유사하게, 리듀서는 렌더링 중에 실행됩니다! (액션은 다음 렌더링까지 대기합니다.) 이는 리듀서가 순수해야 함을 의미합니다—같은 입력은 항상 동일한 출력을 생성해야 합니다. 리듀서는 요청을 보내거나 타임아웃을 예약하거나 컴포넌트 외부에 영향을 주는 부수 효과(오퍼레이션)를 수행해서는 안 됩니다. 객체와 배열을 변경 없이 업데이트해야 합니다.
- 각 액션은 단일 사용자 상호작용을 설명합니다. 데이터에서 여러 변경이 발생하더라도 그렇습니다. 예를 들어, 리듀서가 관리하는 5개의 필드가 있는 폼에서 사용자가 "Reset"을 누르면, 5개의 별도 set_field 액션을 디스패치하는 것보다 하나의 reset_form 액션을 디스패치하는 것이 더 합리적입니다. 리듀서에서 모든 액션을 로깅하면, 로그는 어떤 상호작용이나 응답이 어떤 순서로 발생했는지 재구성할 수 있을 만큼 명확해야 합니다. 이것은 디버깅에 도움이 됩니다!
Immer로 간결한 리듀서 작성하기
일반 상태에서 객체와 배열을 업데이트하는 것처럼 Immer 라이브러리를 사용하여 리듀서를 더 간결하게 만들 수 있습니다. 여기서 useImmerReducer는 push 또는 arr[i] = 할당을 사용하여 상태를 변형할 수 있게 해줍니다.
https://codesandbox.io/s/1kbqy4?file=/App.js&utm_medium=sandpack
withered-thunder-1kbqy4 - CodeSandbox
withered-thunder-1kbqy4 using immer, react, react-dom, react-scripts, use-immer
codesandbox.io
리듀서는 순수해야 하므로 상태를 변형해서는 안 됩니다. 그러나 Immer는 변형해도 안전한 특별한 초안 객체를 제공합니다. 내부적으로 Immer는 초안에 대한 변경 사항이 있는 상태의 복사본을 생성합니다. 이것이 useImmerReducer로 관리되는 리듀서가 첫 번째 인수를 변형할 수 있고 상태를 반환할 필요가 없는 이유입니다.
요약
- useState에서 useReducer로 전환하려면
- 이벤트 핸들러에서 액션을 디스패치합니다.
- 주어진 상태와 액션에 대한 다음 상태를 반환하는 리듀서 함수를 작성합니다.
- useState를 useReducer로 교체합니다.
- 리듀서는 약간 더 많은 코드를 작성해야 하지만 디버깅과 테스트에 도움이 됩니다.
- 리듀서는 순수해야 합니다.
- 각 액션은 하나의 사용자 상호작용을 설명합니다.
- 변형 스타일로 리듀서를 작성하려면 Immer를 사용하세요.
2023.07.16 - [React/Documentation] - 리액트 공식 문서 번역-21. Context를 사용해 깊게 데이터 전달하기
'React > Documentation' 카테고리의 다른 글
리액트 공식 문서 번역-22. 리듀서와 컨텍스트를 확장하기 (0) | 2023.07.16 |
---|---|
리액트 공식 문서 번역-21. Context를 사용해 깊게 데이터 전달하기 (0) | 2023.07.16 |
리액트 공식 문서 번역-19. 상태 보존 및 초기화 (0) | 2023.07.14 |
리액트 공식 문서 번역-18. 컴포넌트간 상태 공유하기 (0) | 2023.07.13 |
리액트 공식 문서 번역-17. 상태 구조 선택 (0) | 2023.07.13 |