본문으로 건너뛰기

상태 논리를 변환기로 추출하기

상태 갱신이 여러 이벤트 처리기에 흩어져 있는 컴포넌트는 부담스러울 수 있습니다. 이러한 경우 모든 상태 갱신 논리를 컴포넌트 외부에 있는 변환기(reducer)라는 단일 함수로 통합할 수 있습니다.

학습 내용
  • 변환기 함수란 무엇인가
  • useStateuseReducer로 리팩터링하는 방법
  • 변환기를 언제 사용하는지
  • 변환기를 잘 작성하는 방법

상태 논리를 변환기로 통합하기

컴포넌트가 복잡해짐에 따라 컴포넌트의 상태가 갱신되는 다양한 방식이 한 눈에 들어오지 않을 수 있습니다.

예를 들어 다음 TaskApp 컴포넌트는 tasks 배열을 상태로 보유하고 세 가지 다른 이벤트 처리기를 사용하여 작업을 추가, 제거, 편집합니다.

각 이벤트 처리기는 상태를 갱신하기 위해 setTasks를 호출합니다. 이 컴포넌트가 커지면 전체에 뿌려지는 상태 논리의 양도 늘어날 것입니다. 이러한 복잡성을 줄이고 모든 논리를 접근하기 쉬운 하나의 장소에 보관하기 위해, 상태 논리를 변환기(reducer)라고 부르는 컴포넌트 외부의 단일 함수로 옮길 수 있습니다.

변환기는 상태를 처리하는 다른 방법입니다. 다음 세 단계를 거쳐 useState에서 useReducer로 마이그레이션할 수 있습니다.

  1. 상태 설정에서 작업 발송으로 전환합니다.
  2. 변환기 함수를 작성합니다.
  3. 컴포넌트에서 변환기를 사용합니다.

1단계: 상태 설정에서 작업 발송으로 전환하기

이벤트 처리기는 현재 상태를 설정하여 할 일을 지정합니다.

jsx
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));
}
jsx
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)는 사용자가 Add를 누르면 호출됩니다.
  • handleChangeTask(task)는 사용자가 작업을 전환하거나 Save를 누르면 호출됩니다.
  • handleDeleteTask(taskId)는 사용자가 Delete를 누르면 호출됩니다.

변환기로 상태를 관리하는 것은 상태를 직접 설정하는 것과 약간 다릅니다. 상태를 설정하여 리액트에게 할 일을 알려주는 대신, 이벤트 처리기에서 작업을 발송하여 사용자가 방금 수행한 일을 지정합니다. (상태 갱신 논리는 다른 곳에 있을 것입니다!) 따라서 이벤트 처리기를 통해 tasks를 설정하는 대신 작업의 추가·변경·삭제를 수행합니다. 이는 사용자의 의도를 더 잘 설명합니다.

jsx
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,
});
}
jsx
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)이라고 부릅니다.

jsx
function handleDeleteTask(taskId) {
dispatch(
// '작업' 객체:
{
type: 'deleted',
id: taskId,
},
);
}
jsx
function handleDeleteTask(taskId) {
dispatch(
// '작업' 객체:
{
type: 'deleted',
id: taskId,
},
);
}

이것은 일반적인 자바스크립트 객체입니다. 여기에 무엇을 넣을지는 자유이지만, 보통은 무슨 일이 일어났는지에 대한 정보를 넣습니다. (나중에는 dispatch 함수 자체를 추가합니다.)

참고

작업 객체의 모양은 어떤 것이든 가능합니다.

관례에 따라, 발생한 일을 설명하는 문자열로 된 type을 지정하고 다른 필드에는 추가 정보를 전달하는 것이 일반적입니다. type은 컴포넌트에 따라 다르며, 이 예시에서는 added 또는 added_task를 사용합니다. 무슨 일이 일어났는지 알려주는 이름을 선택하세요!

jsx
dispatch({
// 컴포넌트에 따라 다릅니다.
type: 'what_happened',
// 다른 필드가 추가될 수 있습니다.
});
jsx
dispatch({
// 컴포넌트에 따라 다릅니다.
type: 'what_happened',
// 다른 필드가 추가될 수 있습니다.
});

2단계: 변환기 함수 작성하기

변환기 함수는 상태 논리를 넣는 곳입니다. 현재 상태와 작업 객체라는 두 개의 인수를 받아서 다음 상태를 반환합니다.

jsx
function yourReducer(state, action) {
// 리액트가 설정할 다음 상태를 반환합니다.
}
jsx
function yourReducer(state, action) {
// 리액트가 설정할 다음 상태를 반환합니다.
}

리액트는 상태를 변환기에서 반환하는 값으로 설정합니다.

이 예시에서 상태 설정 논리를 이벤트 처리기에서 변환기 함수로 옮기려면 다음을 수행합니다.

  1. 현재 상태(tasks)를 첫 번째 인수로 선언합니다.
  2. action 객체를 두 번째 인수로 선언합니다.
  3. 변환기에서 다음 상태를 반환합니다. (리액트는 상태를 이 값으로 설정함)

변환기 함수로 마이그레이션된 모든 상태 설정 논리는 다음과 같습니다.

jsx
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);
}
}
jsx
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 문이 한 눈에 읽기에 더 쉬울 수 있습니다.

이 문서의 나머지 부분에서 다음을 사용할 것입니다.

jsx
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);
}
}
}
jsx
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를 사용해도 좋습니다.

심화

변환기라고 부르는 이유

변환기(reducer)는 컴포넌트 내부의 코드 양을 줄일(reduce) 수 있지만, 실제로는 배열에서 수행할 수 있는 reduce() 연산의 이름을 따서 명명되었습니다.

reduce() 연산을 사용하면 배열을 가져와 여러 값을 하나의 값으로 모을 수 있습니다.

jsx
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce((result, number) => result + number); // 1 + 2 + 3 + 4 + 5
jsx
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce((result, number) => result + number); // 1 + 2 + 3 + 4 + 5

reduce에 전달하는 함수를 변환기라고 합니다. 이 함수는 지금까지의 결과현재 항목을 받아 다음 결과를 반환합니다. 리액트 변환기는 같은 아이디어를 사용합니다. 지금까지의 상태작업을 받아, 다음 상태를 반환합니다. 변환기는 시간에 따른 작업을 상태로 축적합니다.

initialStateactions 배열과 함께 reduce() 메서드를 사용하면 변환기 함수를 전달하여 최종 상태를 계산할 수도 있습니다.

직접 할 필요는 없지만 리액트의 작동 방식이 이와 비슷합니다!

3단계: 컴포넌트에서 변환기 사용하기

마지막으로 tasksReducer를 컴포넌트에 연결해야 합니다. 리액트에서 useReducer 훅을 가져옵니다.

jsx
import { useReducer } from 'react';
jsx
import { useReducer } from 'react';

그런 다음 useState를 교체할 수 있습니다.

jsx
const [tasks, setTasks] = useState(initialTasks);
jsx
const [tasks, setTasks] = useState(initialTasks);

다음과 같이 useReducer를 사용합니다.

jsx
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
jsx
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

useReducer 훅은 useState와 비슷합니다. 초기 상태를 전달해야 하며 유상태 값과 상태를 설정하는 방법(발송 함수)을 반환합니다. 하지만 조금 다릅니다.

useReducer 훅은 두 가지 인수를 사용합니다.

  1. 변환기 함수
  2. 초기 상태

그리고 다음을 반환합니다.

  1. 유상태 값
  2. 발송 함수 (사용자 작업을 변환기로 발송)

이제 완전히 연결되었습니다! 여기서 변환기는 컴포넌트 파일의 맨 아래에 선언됩니다.

jsx
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 },
];
jsx
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 },
];

원한다면 변환기를 다른 파일로 옮길 수도 있습니다.

이렇게 관심사를 분리하면 컴포넌트 논리를 더 쉽게 읽을 수 있습니다. 이제 이벤트 처리기는 작업을 발송하여 무슨 일이 일어났는지만 지정하고, 변환기 함수는 이에 대한 응답으로 상태 갱신 방법을 결정합니다.

useStateuseReducer 비교하기

변환기에 단점이 없는 것은 아닙니다! 비교할 수 있는 몇 가지 방법은 다음과 같습니다.

  • 코드 크기 - 일반적으로 useState를 사용하면 더 적은 코드를 미리 작성해야 합니다. useReducer에서는 변환기 함수와 발송 작업을 모두 작성해야 합니다. 그러나 useReducer는 많은 이벤트 처리기가 유사한 방식으로 상태를 수정하는 경우, 코드를 줄이는 데 도움이 될 수 있습니다.
  • 가독성 - useState는 상태 갱신이 단순할 때 매우 읽기 쉽습니다. 상태 갱신이 더 복잡해지면 컴포넌트의 코드가 길어지고 살펴보기 어려울 수 있습니다. 이 경우 useReducer를 사용하면 갱신 논리의 방법과 이벤트 처리기의 결과를 깔끔하게 분리할 수 있습니다.
  • 디버깅 - useState에 버그가 있을 때, 상태가 잘못 설정된 위치이유를 파악하기 어려울 수 있습니다. useReducer를 사용하면 변환기에 콘솔 로그를 추가하여 모든 상태 갱신을 보고 이유(어떤 action 때문인지)를 확인할 수 있습니다. 각 action이 올바르다면, 변환기 논리 자체에 문제가 있다는 것을 알 수 있습니다. 그러나 useState보다 더 많은 코드를 거쳐야 합니다.
  • 테스트 - 변환기는 컴포넌트에 의존하지 않는 순수 함수입니다. 즉, 변환기는 별도로 내보내고 테스트할 수 있습니다. 일반적으로 보다 현실적인 환경에서 컴포넌트를 테스트하는 것이 가장 좋습니다. 하지만 복잡한 상태 갱신 논리의 경우에는, 변환기가 특정 초기 상태와 작업에 대해 특정 상태를 반환한다고 단언하는 것이 유용할 수 있습니다.
  • 개인적인 취향 - 어떤 사람은 변환기를 좋아하지만, 어떤 사람은 변환기를 싫어합니다. 괜찮습니다. 취향의 문제입니다. useStateuseReducer 사이를 언제든지 전환할 수 있습니다. 둘은 동일합니다!

일부 컴포넌트의 잘못된 상태 갱신으로 인해 버그가 자주 발생하고 해당 코드에 더 많은 구조를 도입하려는 경우에, 변환기를 사용하는 것이 좋습니다. 모든 것에 변환기를 사용할 필요는 없습니다. 자유롭게 섞어서 사용하세요! 하나의 컴포넌트에서 useStateuseReducer를 같이 사용하는 것도 가능합니다.

변환기 잘 작성하기

변환기를 작성할 때 다음 두 가지 팁을 기억하세요.

  • 변환기는 순수해야 합니다. 상태 갱신 함수와 유사하게 변환기는 렌더링 중에 실행됩니다! (작업은 다음 렌더링까지 대기합니다.) 이는 변환기가 순수해야 한다는 것을 의미합니다. 동일한 입력은 항상 동일한 출력을 생성해야 합니다. 요청을 보내거나, 타임아웃을 예약하거나, 부작용(컴포넌트 외부에 영향을 주는 작업)을 수행해서는 안 됩니다. 변형 없이 객체배열을 갱신해야 합니다.
  • 데이터가 여러 번 변경되더라도 각 작업은 사용자의 상호 작용 하나를 묘사합니다. 예를 들어 사용자가 변환기가 관리하는 5개의 필드가 있는 양식에서 Reset을 누르면 5개의 개별 set_field 작업보다 하나의 reset_form 작업을 전달하는 것이 더 합리적입니다. 변환기에서 모든 작업을 기록한다면, 그 기록은 어떤 상호 작용이나 응답이 어떤 순서로 발생했는지 재구성할 수 있을 만큼 충분히 명확해야 합니다. 이는 디버깅에 도움이 됩니다!

이머로 간결한 변환기 작성하기

일반적인 상태의 객체 갱신배열 갱신과 마찬가지로, 이머 라이브러리를 사용하여 변환기를 더 간결하게 만들 수 있습니다. 여기서 useImmerReducer를 사용하면 pusharr[i] = 할당으로 상태를 변형할 수 있습니다.

jsx
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 },
];
jsx
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 },
];

변환기는 순수해야 하므로 상태를 변형해서는 안 됩니다. 그러나 이머는 변형에 안전한, 특별한 draft 객체를 제공합니다. 내부적으로 이머는 draft에 대한 변경 사항이 포함된 상태의 복사본을 생성합니다. 이것이 useImmerReducer가 관리하는 변환기가 첫 번째 인수를 변형할 수 있고 상태를 반환할 필요가 없는 이유입니다.

요약

  • useState에서 useReducer로 변환하려면 다음을 수행합니다.
    • 이벤트 처리기에서 작업을 발송합니다.
    • 주어진 상태 및 작업에 대한 다음 상태를 반환하는 변환기 함수를 작성합니다.
    • useStateuseReducer로 바꿉니다.
  • 변환기는 조금 더 많은 코드를 작성해야 하지만 디버깅과 테스트에 도움이 됩니다.
  • 변환기는 순수해야 합니다.
  • 각 작업은 사용자의 상호 작용 하나를 묘사합니다.
  • 변형 스타일로 변환기를 작성하려면 이머를 사용하세요.