
여러 이벤트 핸들러에 분산된 상태 업데이트가 많은 컴포넌트는 부담스러울 수 있다. 이러한 경우 컴포넌트 외부에 모든 상태 업데이트 로직을 리듀서라고 하는 단일 함수에 통합할 수 있다.

컴포넌트가 복잡해지면 컴포넌트의 상태가 업데이트되는 다양한 방식을 한 눈에 확인하기 더 어려워질 수 있다. 예를 들어 아래 TaskApp 컴포넌트는 상태에 있는 tasks 배열을 보유하고 세 가지 다른 이벤트 핸들러를 사용하여 작업을 추가, 제거 및 수정한다.
import { useState } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
export default function TaskApp() {
const [tasks, setTasks] = useState(initialTasks);
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));
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{id: 0, text: 'Visit Kafka Museum', done: true},
{id: 1, text: 'Watch a puppet show', done: false},
{id: 2, text: 'Lennon Wall pic', done: false},
];
각 이벤트 핸들러는 상태를 업데이트하기 위해 setTasks 를 호출한다. 이 컴포넌트가 커짐에 따라 전체에 뿌려지는 상태 로직의 양도 늘어난다. 이러한 복잡성을 줄이고 모든 로직을 액세스하기 쉬운 한 곳에 유지하려면 해당 상태 로직을 “리듀서”라고 하는 컴포넌트 외부의 단일 함수로 이동할 수 있다.
리듀서는 상태를 처리하는 방법이다. 다음 세 단계를 통해 useState 에서 useReducer 로 마이크레이션할 수 있다.
이벤트 핸들러는 현재 상태를 설정하여 수행할 작업을 지정한다.
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) 가 호출된다.리듀서로 상태를 관리하는 것은 상태를 직접 설정하는 것과 약간 다르다. 상태를 설정하여 리액트에게 “무엇을 해야하는지” 알려주는 대신, 이벤트 핸들러에서 “액션”을 전달하여 “사용자가 방금 수행한 작업”을 지정한다. (상태 업데이트 로직은 다른 곳에 있다.) 따라서 이벤트 핸들러를 통해 “tasks 세팅” 대신 “작업 추가/변경/삭제” 액션을 전달한다. 이는 사용자의 의도를 더 잘 설명한다.
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" object:
{
type: 'deleted',
id: taskId,
}
);
}
일반 JavaScript 객체이다. 무엇을 넣을지는 알아서 결정하지만 일반적으로 발생한 일에 대한 최소한의 정보만 포함해야한다. (나중에 dispatch 기능 자체를 추가할 것이다.)
액션 객체는 어떤 모양이든 가질 수 있다.
관례적으로, 발생한 일을 설명하는 문자열 type 을 제공하고 다른 필드에 추가 정보를 전달하는 것이 일반적이다. type 은 컴포넌트마다 다르므로 이 예에서는 'added' 또는 'added_task' 가 괜찮다. 무슨 일이 일어났는지 알 수 있는 이름을 선택해라.
dispatch({
// specific to component
type: 'what_happened',
// other fields go here
});
리듀서 함수는 상태 로직을 넣는 곳이다. 현재 상태와 액션 객체 두개의 인수를 사용하고 다음 상태를 반환한다.
function yourReducer(state, action) {
// return next state for React to set
}
리액트는 리듀서를 반환한 생태로 상태를 설정한다.
이 예에서는 상태 설정 로직을 이벤트 핸들러에서 리듀서 함수로 이동하려면 다음을 수행한다
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문은 한눈에 읽는 것이 더 쉬을 수 있다.
이 문서의 나머지 부분에서는 다음과 같이 사용할 것 이다.
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를 사용해도 괜찮다.
Finally, you need to hook up the tasksReducer to your component. Import the useReducer Hook from React:
마지막으로 tasksReducer 를 컴포넌트에 연결해야 한다. 리액트에서 useReducer 훅을 가져온다.
import { useReducer } from 'react';
useState 를 대체할 수 있다.
const [tasks, setTasks] = useState(initialTasks);
useReducer 를 사용하면 다음과 같다.
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer 훅은 useState 와 유사하다. 초기 상태를 전달해야 하며 상태 저장 값과 상태 설정 방법(이 경우 디스패치 함수)를 반환한다. 하지만 조금 다르다.
The useReducer Hook takes two arguments:
useReducer 훅은 두 가지 인수를 사용한다.
그리고 반환한다.
이제 완전히 연결되었다. 여기서 리듀서는 컴포넌트 파일의 맨 아래에 선언된다.
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
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,
});
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
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);
}
}
}
let nextId = 3;
const initialTasks = [
{id: 0, text: 'Visit Kafka Museum', done: true},
{id: 1, text: 'Watch a puppet show', done: false},
{id: 2, text: 'Lennon Wall pic', done: false},
];
useState and useReducerReducers are not without downsides! Here’s a few ways you can compare them:
리듀서에 단점이 없는 것은 아니다. 비교할 수 있는 몇 가지 다음과 같다.
useState 를 사용하면 미리 작성해야 하는 코드가 줄어든다. useReducer 를 사용하면 리듀서 함수와 디스패치 액션을 모두 작성해야한다. 그러나 useReducer 는 많은 이벤트 핸들러가 비슷한 방식으로 상태를 수정하는 경우 코드를 줄이는 데 도움이 될 수 있다.useState 는 상태 업데이트가 간단할 때 읽기가 매우 쉽다. 더 복잡해지면 컴포넌트의 코드가 비대해지고 스캔이 어려워질 수 있다. 이 경우 useReducer 를 사용하면 업데이트 로직의 방식과 이벤트 핸들러의 결과를 명확하게 구분할 수 있다useState 에 버그가 있는 경우 상태가 잘못 설정된 위치와 이유를 파악하기 어려울 수 있다. useReducer 를 사용하면 리듀서에 console log를 추가하여 모든 상태 업데이트와 해당 업데이트가 발생한 이유(어떤 action 으로 인해)를 확인할 수 있다. 각 action 이 정확하다면리듀서 로직 자체에 실수가 있다는 것을 알게 될 것이다. 하지만 useState 보다 더 많은 코드를 실행해야 한다useState 와 useReducer 를 변환할 수 있다.일부 컴포넌트의 잘못된 상태 업데이트로 인해 버그가 자주 발생하고 코드에 더 많은 구조를 도입하려는 경우 리듀서를 사용하는 것이 좋다. 모든 것에 리듀서를 사용할 필요는 없다. 자유롭게 혼합하여 컴포넌트에서 useState 와 useReducer 를 사용할수도 있다.
리듀서를 작성할 때 다음 두 가지 팁을 명심해라.
set_field 작업보다는 하나의 reset_form 작업을 전달하는 것이 더 합리적이다. 리듀서에 모든 작업을 기록한다면 해당 로그는 어떤 상호 작용이나 응답이 어떤 순서로 발생했는지 재구성할 수 있을 만큼 명확해야한다. 이는 디버깅에 도움이된다일반 상태에서 객체와 배열을 업데이트하는 것과 마찬가지로 Immer 라이브러리를 사용하여 리듀서를 더욱 간결하게 만들 수 있다. 여기에서 [useImmerReducer](https://github.com/immerjs/use-immer#useimmerreducer) 를 사용하면 push 또는 arr[i] = 할당을 사용하여 상태를 변경할 수 있다.
import { useImmerReducer } from 'use-immer';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
function tasksReducer(draft, action) {
switch (action.type) {
case 'added': {
draft.push({
id: action.id,
text: action.text,
done: false,
});
break;
}
case 'changed': {
const index = draft.findIndex((t) => t.id === action.task.id);
draft[index] = action.task;
break;
}
case 'deleted': {
return draft.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
export default function TaskApp() {
const [tasks, dispatch] = useImmerReducer(tasksReducer, initialTasks);
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,
});
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{id: 0, text: 'Visit Kafka Museum', done: true},
{id: 1, text: 'Watch a puppet show', done: false},
{id: 2, text: 'Lennon Wall pic', done: false},
];
리듀서는 순수해야 하므로 상태를 변경해서는 안된다. 그러나 Immer는 변경해도 안전한 특별한 draft 객체를 제공한다. 내부적으로 Immer는 draft 에 대한 변경 사항이 포함된 상태 복사본을 생성한다. 이것이 useImmerReducer 가 관리하는 리듀서가 첫 번째 인수를 변경할 수 있고 상태를 반환할 필요가 없는 이유다.
useState 에서 useReducer 로 변환하려면useState 를 useReducer 로 바꿔라.