너무 많은 이벤트 핸들러들과 너무 많은 State updater 함수가 있는 컴포넌트는 복잡해지기 쉽상이다. 이럴 때 모든 state upater 로직을 컴포넌트 밖에 있는 reducer라는 단 하나의 함수에 때려박을 수 있다!
이 문서에서는
- reducer 함수란 무엇인지
useState
를useReducer
로 리팩토링하는 법- reducer는 언제 쓸지
- reducer 잘 쓰기
를 배워봅시다.
컴포넌트가 더 복잡해질 수록, 그 컴포넌트의 state가 어떻게 업데이트되는지 한번에 보기가 어려워진다. 예를 들어, 아래의 TaskApp
컴포넌트는 tasks
라는 state를 가지고 있고, 이벤트 핸들러 3개를 사용해서 task들을 추가, 제거, 수정한다.
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
를 불러서 state를 업데이트한다. 이 컴포넌트가 커지면, state 로직도 막 퍼질 것이다. 이 복잡도를 줄이고 로직을 한 곳에 몰아넣어서 접근을 조금 편하게 하고 싶다! 그렇다면 이 state 로직들을 컴포넌트 밖의 reducer라는 함수에 빼놓으면 된다.
Reducer는 state를 관리하는 또 다른 방식이다. 아래 3단계만 따라하면 useState
에서 useReducer
로 이사갈 수 있다.
set state
→ dispatch actions
위 코드에서 이벤트 핸들러들을 살펴보자.
setState
를 사용해서 무엇을 할지 를 정해준다.
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));
}
setState
들을 다 없애자! 그럼 남은 건 호출밖에 없다.. :
handleAddTask(text)
: 사용자가 "Add" 누르면 호출handleChangeTask(task)
: 사용자가 task를 토글하거나 "Save" 누르면 호출handleDeleteTask(taskId)
: 사용자가 "Delete" 누르면 호출state를 reducer로 관리하는 것은 직접 setState
를 하는 것과는 조금 다르다. 리액트한테 "이것을 해줘"라고 직접 말하는 게 아니고, 이벤트 핸들러로부터 "actions"를 보내줘서 (=dispatch) "사용자가 방금 무엇을 했는지"를 지정하는 것이다.
state 업데이트 로직은 다른 곳에 살고 있을 것임!
그니깐 이벤트 핸들러를 통해 tasks
state를 설정 (=setState) 하는 것이 아니라, "task를 추가/수정/삭제 했음!" 이라는 액션을 보내주는(=dispatch) 것이다! 이게 보다 더 사용자의 의도를 잘 설명해준다.
아래는 setState
→ dispatch
로 바꾼 코드
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,
}
);
}
걍 JS 객체다. 무엇을 넣든 상관 없지만, 보통은 방금 무엇이 일어났는지 에 대한 최소한의 정보를 포함해야 함. (다음 문서에서 dispatch
함수 그 자체를 추가해보자)
action 객체
는 아무 shape 여도 상관 없다.
컨벤션 :
- 보통
type
에 무슨 일인지 설명하는 string 부여.- 다른 필드들에 모든 추가 정보 넣음
type
는 컴포넌트마다 다를 수 있다. 이 예시에서는added
,added_task
모두 ㄱㅊ.
이름으로 무슨 일이 일어났는지를 잘 골라보자!dispatch({ // specific to component type : '무슨_일인고', // 다른건 다 이 밑에...
reducer 함수에 state 로직을 넣는다.
현재 state
, action 객체
. 바뀐 state
.function yourReducer(state, action) {
return 이렇게 바꿔줘잉 state
}
이 예시에서는 이렇게!
tasks
) 선언action
객체 선언예시의 reducer 함수는 이렇다 :
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);
}
}
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
문에return
넣는 것을 잊지 마라! 안쓰면 다음 case로 빠질 수 있다.
reducer 함수는 컴포넌트 밖에 선언하자.
state를 인자로 받기 때문에, 굳이 state가 살고 있는 컴포넌트 안에서 선언될 필요가 없음. 가독성을 위해 따로 파일을 빼서 선언하자.
왜 reducer라고 부름 ?
사실 이거 엄청 궁금했던건데 쌔로운 문서 최고얌
코드 양을 줄여주기도 하지만,
"reduce", 사실은 array의reduce()
메서드에서 따온 것.reduce() ?
const arr = [1, 2, 3, 4, 5]; const sum = arr.reduce( (result, number) => result + number ); // 1 + 2 + 3 + 4 + 5
reduce()
는 array를 받아서 여러 값 중 하나의 값을 "쌓아서" 돌려준다.이
reduce()
메서드가 받는 인자를 바로 reducer 함수라고 부름. 여태까지의 결과값 과 현재 값 을 받아서 다음 값 을 돌려준다.
리액트의 reducer 도 같은 논리 : 여태까지의 state 값 과 action 을 받아서 다음 state 를 돌려줌. 이러면 state를 state로 다시 축적할 수 있다!
실제로array.reduce()
메서드를 사용하여 reducer를 거의 그대로 구현할 수 있다고 한다. 여기서는 생략!
마지막 단계! 아까 만든 tasksReducer
reducer 함수를 컴포넌트에서 써보자. 임포트부터 해야 함. React에서 useReducer
훅을 임포트 해옵시다.
import { useReducer } from 'react'
이제 useState
를 대체해보자.
const [tasks, setTasks] = useState(initialTasks);
이 코드를 useReducer
로 바꾸면...
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
이렇게 된다.
useReducer
는 useState
랑 비슷하게 생겼다 - state 초기값을 인자로 넘겨주고, state 값이랑 state를 설정하는 함수를 돌려준다. (useReducer
에서는 dispatch 함수다. 요것이 차이점)
useReducer
의 인자 2개 :
리턴하는 것 :
아래는 완성본.
import {useReducer} from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); // 인자 1 : reducer 함수(아래있음, 보통은 다른 파일에). 인자 2 : 초기 state값
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},
];
관심사의 분리
이제
- 이벤트 핸들러는 무슨일인고 만 관여함
어떻게? action을 dispatch 했기 때문.- reducer 함수는 state 업데이트 만 관여
useState
- useReducer
비교console.log()
찍으면 모든 state 업데이트의 이유를 볼 수 있음. (action 있으니깐!)useReducer
를 추천한다 := 모든 곳에 useReducer
를 쓸 필요는 없다!
이 두가지를 잘 기억해라
reset_form
action을 dispatch 해주는게 맞지, 5개의 set_field
action을 보내주면 복잡하고 읽기 어려워진다는 것이다. 디버깅 꿀팁!도 있다. 객체나 배열을 업데이트 할 때, 원래는 shallow copy 만들어서 새로 상태를 만들어줘야 함. Immer는 보다 직관적으로 업데이트 하게 해준다. 어떻게? 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': {
// push 사용할 수 있다는 말씀이다
draft.push({
id: action.id,
text: action.text,
done: false,
});
break;
}
case 'changed': {
const index = draft.findIndex((t) => t.id === action.task.id);
// arr[index] = 요러케 assign 할당해줄 수 있다는 말이다
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() {
// useReducer 대신 useImmerReducer 사용
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},
];
useState
에서 useReducer
로 바꾸려면 :useState
를 useReducer
로 바꾸기useImmerReducer
훅을 사용할 것.