여러 이벤트 핸들러에 분산된 상태 업데이트가 많은 컴포넌트는 부담스러울 수도 있습니다. 이러한 경우 컴포넌트 외부의 모든 상태 업데이트 로직을 리듀서라고 하는 단일 함수에 통합할 수 있습니다,
컴포넌트가 복잡해짐에 따라 컴포넌트의 상태가 업데이트되는 다양한 방식을 한눈에 확인하기가 어려워질 수 있습니다. 예를 들어, 아래 컴포넌트 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 로 마이그레이션할 수 있습니다.
- state를 설정하는 것에서 action들을 전달하는 것으로 변경하기
- 리듀서 함수를 작성합니다.
- 컴포넌트에 리듀서를 사용합니다.
현재 이벤트 핸들러는 상태를 설정하여 수행할 작업을 지정하고 있습니다.
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));
}
모든 상태 설정 로직을 제거합니다. 이제 세 개의 이벤트 핸들러만 남았습니다.
- 사용자가 Add를 누르면
handleAddTask(text)가 호출됩니다.- 사용자가 task를 토글하거나 Save를 누르면
handleChangeTask(task)가 호출됩니다.- 사용자가 Delete를 누르면
handleDeleteTask(taskId)가 호출됩니다.
리듀서를 사용한 상태 관리는 상태를 직접 설정하는 것과 약간 다릅니다. 상태를 설정하여 React에게 무엇을 할지 지시하는 대신, 이벤트 핸들러에서 “Action”을 전달하여 “사용자가 방금 한 일”을 지정합니다. (상태 업데이트 로직을 다른 곳에 있습니다). 즉, 이벤트 핸들러를 통헤 “task 를 설정” 하는 대신 “task를 추가/변경/삭제” 하는 action을 전달하는 것입니다. 이러한 방식이 사용자의 의도를 더 명확하게 설명합니다.
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 함수에 넣어준 객체를 “action”이라고 합니다.
function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}
이 객체는 일반적인 JavaScript 객체입니다. 여기에 무엇을 넣을지는 여러분이 결정하지만, 일반적으로 무슨 일이 일어났는지에 대한 최소한의 정보를 포함해야 합니다. ( dispatch 함수 자체는 이후 단계에서 추가할 것입니다.)
action 객체는 어떤 형태든 될 수 있습니다.
그렇지만 무슨 일이 일어나는지 설명하는 문자열 타입의 type 을 지정하고 추가적인 정보는 다른 필드를 통해 전달하도록 작성하는게 일반적입니다. type 은 컴포넌트에 따라 다르므로 이 예에서는 'added' 또는 'added_task' 를 사용하면 됩니다.
dispatch({
// specific to component
type: 'what_happened',
// other fields go here
});
리듀서 함수에 상태 로직을 둘 수 있습니다. 이 함수는 두 개의 매개변수를 가지는데, 하나는 현재 상태이고 하나는 action 객체입니다. 그리고 이 함수가 다음 상태를 반환합니다.
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 으로 끝나야 합니다. 만약 return 을 잊는 다면 이 코드는 다음 case 에 빠지게 될 것이며, 이는 실수로 이어질 수 있습니다.
리듀서들이 비록 컴포넌트 안에 있는 코드의 양을 “줄여주긴” 하지만, 이는 사실 배열에서 사용하는 reduce() 연산을 따서 지은 이름입니다.
reduce() 연산은 배열을 가지고 많은 값들을 하나의 값으로 누적합니다.
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
reduce 로 넘기는 함수가 “reducer”로 알려져 있습니다. 지금까지의 결과와 현재의 아이템을 가지고, 다음 결과를 반환합니다. React 리듀서는 이 아이디어와 똑같은 예시입니다. React 리듀서도 지금까지의 state 와 action 을 가지고 다음 state 를 반환합니다. 이런 방식으로 시간이 지나면서 action들을 state로 모으게 됩니다.
심지어 reduce() 메서드를 initialState 와 actions 배열을 사용해서 리듀서로 최종 state를 계산할 수도 있습니다.
마지막으로, 컴포넌트에 tasksReducer 를 연결해야 합니다. React 에서 useReducer Hook을 import 하고, useState 대신 useReducer 로 바꿉니다.
import { useReducer } from 'react';
const [tasks, setTasks] = useState(initialTasks);
useReducer Hook은 useState 와 유사합니다. 초기 상태 값을 전달해야 하며, 그 결과로 상태 값과 상태 설정자 함수(useReducer의 경우 dispatch 함수)를 반환합니다. 하지만 조금 다른 점이 있습니다.
useReducer Hook은 두 개의 인자를 받습니다.
- reducer 함수
- 초기 상태
그리고 아래 내용을 반환합니다.
- 상태 값
- dispatch 함수 (사용자의 action을 reducer에 “전달”해주는 함수)
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 useReducer리듀서도 좋은 점만 있는 것은 아닙니다. 다음은 useState 와 useReducer 를 비교할 수 있는 몇 가지 방법입니다.
- Code Size: 일반적으로
useState를 사용하면 미리 작성해야 하는 코드가 줄어듭니다.useReducer를 사용하면 리듀서 함수와 action을 전달하는 부분 모두 작성해야 합니다. 하지만, 많은 이벤트 핸들러가 비슷한 방식으로 state를 업데이트 하는 경우useReducer를 사용하면 코드를 줄이는 데 도움이 될 수 있습니다.- Readability :
useState로 간단한 상태를 업데이트 하는 경우 가독성이 좋습니다. 그렇지만, 상태의 구조가 더욱 복잡해지면, 컴포넌트의 코드의 양이 부풀어 오르고 한 눈에 읽이 어려워질 수 있습니다. 이 경우useReducer를 사용하면 업데이트 로직이 어떻게 동작하는지와 이벤트 핸들러를 통해 무엇이 일어났는지를 깔끔하게 분리할 수 있습니다.- Debugging :
useState에 버그가 있는 경우, 상태가 어디서 잘못 설정되었는지, 그리고 왜 그런지 알기 어려울 수 있습니다.useReducer를 사용하면, 리듀서에 콘솔 로그를 추가하여 모든 상태 업데이트와 왜 버그가 발생했는지 확인할 수 있습니다. 각action이 정확하다면, 버그가 리듀서 로직 자체에 있다는 것을 알 수 있습니다. 하지만useState를 사용할 때보다 더 많은 코드를 살펴봐야 합니다.- Testing : 리듀서는 컴포넌트에 의존하지 않는 순수한 함수입니다. 즉, 별도로 분리해서 내보내거나 테스트할 수 있습니다. 일반적으로 보다 현실적인 환경에서 컴포넌트를 테스트하는 것이 가장 좋지만, 복잡한 상태 업데이트 로직의 겨우 리듀서가 특정 초기 state와 action에 대해 특정 state를 반환한다고 단언하는 것이 유용할 수 있습니다.
- Personal preference : 어떤 사람은 리듀서를 좋아하고 어떤 사람은 싫어합니다.
useState와useReducer는 언제든지 앞 뒤로 변환할 수 있으며, 서로 동등합니다.
일부 컴포넌트에 잘못된 상태 업데이트로 인해 버그가 자주 발생하고 코드에 더 많은 구조를 도입하려는 경우 리듀서를 사용하는 것이 좋습니다. 모든 컴포넌트에 리듀서를 사용할 필요는 없으며 심지어 같은 컴포넌트 내에서 혼용할수도 있습니다.
리듀서를 작성할 때 다음 두 개의 팁을 기억하세요
- 리듀서는 반드시 순수해야 합니다. 상태 설정 함수와 마찬가지로 리듀서는 렌더링 중에 실행됩니다. 즉, 리듀서는 반드시 순수해야 합니다. 즉, 입력 값이 같ㅇ다면 결과 값도 항상 같아야 합니다. 요청을 보내거나 timeout을 스케쥴링하거나 사이드 이펙트를 수행해서는 안 됩니다. 리듀서는 객체 및 배열의 변이 없이 업데이트해야 합니다.
- 각 action은 여러 데이터가 변경되더라도, 하나의 사용자 상호작용을 설명해야 합니다. 예를 들어, 사용자가 리듀서가 관리하는 5개의 필드가 있는 양식에서 ‘재설정’을 누른 경우, 5개의 개별
set_fieldaction보다는reset_formaction을 전송하는 것이 더 합리적입니다. 모든 action을 리듀서에 기록하면 어떤 상호작요이나 응답이 어떤 순서로 일어났는지 재구성할 수 있을 만큼 로그가 명확해야 합니다.
일반 상태의 객체와 배열을 변경할 때와 마찬가지로 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 로 관리되는 리듀서는 첫 번째 인수를 변경할 수 있고, 상태를 반환할 필요가 없습니다.