global state 에 대해 알아볼때 useContext
다음에 redux 를 알아보려 했다. 그런데 이게 useReducer
훅과 관련이 있어 보였고 useContext
를 통해 global state 관리시 useReducer
와 함께 사용하여 상태관리를 어떻게 하는지 찾아보려 했기에 useReducer
훅에 대해 먼저 알아보게 되었다.
useReducer
는 컴포넌트에 reducer
를 추가할 수 있게 해주는 React Hook 이다.
그렇다면 reducer
는 뭘까?
많은 이벤트 핸들러에 걸쳐 많은 상태 업데이트가 있는 컴포넌트는 혼란스러울 수 있다. 이럴때 상태 업데이트 로직을 컴포넌트 바깥의 reducer
라 부르는 하나의 함수에 통합할 수 있다.
컴포넌트를 업데이트함에 따라 구조가 복잡해지면 한눈에 상태들이 어떻게 업데이트되는지 알아보기 어려워질 것이다.
react 공식문서 에 할일을 추가, 삭제, 수정하는 TaskApp 컴포넌트를 예시로 소개하고 있다.
각각의 이벤트핸들러는 setTasks 를 통해 상태를 업데이트하는데 컴포넌트 규모가 커지면서 이러한 상태 로직이 전체적으로 뿌려지며 복잡성을 띄게 된다. 이런 문제를 해결하기 위해서 상태 로직을 컴포넌트 바깥의 reducer
라 불리는 하나의 함수로 이동시키자.
useState
를 useReducer
로 migration 하는 방법은 다음과 같다.
상태를 설정하는 대신 액션을 파견 dispatch
하자.
reducer
함수를 작성하자.
컴포넌트에서 reducer
를 사용하자.
이전 코드에서 각 이벤트 핸들러는 상태를 설정함으로써 무엇을 해야하는지 명시하고 있다.
상태 설정 로직을 제거한다.
다음 이벤트 핸들러는 아래와 같은 로직 정도만 남긴다.
handleAddTask(text)
각 이벤트 핸들러는 add
를 클릭할때 호출
handleChangeTask(text)
각 이벤트 핸들러는 change
를 클릭할때, task 를 토글할때 호출
handleDeleteTask(text)
각 이벤트 핸들러는 delete
를 클릭할때 호출
reducer
로 상태를 관리하는 것은 상태를 직접적으로 설정하는 것과 약간 다르다.
reducer
는 React 에게 상태를 설정함으로써 무엇을 할지 알려주는 것보다 event handler 로부터 action 을 dispatch 해서 유저가 무엇을 했는지 명시한다.
그래서 이벤트 핸들러를 통해 상태를 설정하는 대신 할일을 추가
, 할일을 변경
, 할일을 삭제
, 하는 액션을 dispatch 한다. 이는 유저의 의도를 좀더 잘 설명한다.
setting state | dispatch action |
---|---|
![]() | ![]() |
dispatch 를 통해 전달하는 객체를
action
이라고 한다.function handleDeleteTask(taskId) { dispatch( // "action" object: { type: 'deleted', id: taskId, } ); }
액션은 어떤 형태든 될 수 있으나 convention 에 의해 일반적으로 type 이라는 property 에 문자열로 무엇이 발생했는지를 설명한다. 그 외엔 추가적인 정보를 전달할 수 있다.
dispatch({ // specific to component type: 'what_happened', // other fields go here });
앞에서 상태를 설정하는 것을 action 을 dispatch 하는 것으로 바꿨다. 이제 reducer 라는 함수를 만들어야 한다.
reducer
함수는 상태로직을 관리하게 될 함수다. 현재 상태와 action 객체를 인자로 받아서 다음 상태를 반환한다.
function yourReducer(state, action) {
// return next state for React to set
}
그러면 React 는 reducer 가 반환한 대로 state 를 설정한다.
그렇게 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);
}
}
형태를 봤을때는 SOLID 의 Open-Closed 원칙에 위배되는 것으로 보인다. 구조가 좀 불편하지만 지금은 reducer 가 어떻게 돌아가는지 이해한 뒤 이후에 다시 refactoring 해보자.
reducer
함수가 인자로 현재 상태를 받기 때문에 컴포넌트 바깥에서도reducer
함수를 선언할 수 있다. 이는 의존성을 낮추고 코드를 읽기 쉽게 해준다.
일반적으로 reducer 내부에선 if/else 보단 switch 구문을 사용한다고 한다.
use if/else reducer | use switch reducer |
---|---|
![]() | ![]() |
코드가 좀더 늘어난 것 같지만 보통 이런 경우 switch 문의 가독성이 더 좋다.
이전 포스트 에서 reduce 에 대한 내용을 정리했던 부분을 참고했다.
reducer
는 실제로 컴포넌트의 코드 양을 줄이나 실제로는 array 의 연산자 reduce()
로부터 이름이 지어졌다.
reduce()
는 지금까지의 결과와 현재 아이템을 받고 다음 결과를 반환한다. React 의 reducer
도 같은 아이디어를 갖는다. 대신 지금까지의 state 와 action 을 받고 다음 상태를 반환한다.
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
혹은 초기 상태와 액션의 배열을 갖고 최종 상태를 계산할수도 있다.
바람직하진 않지만 React 의 reducer 가 비슷하게 동작하니 참고할만한 예시였다.
이제 만든 reducer
를 컴포넌트에 연결해야한다. react 에서 useReducer
훅을 import 해서 사용하자.
import { useState , useReducer } from 'react';
// const [tasks, setTasks] = useState(initialTasks);
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
이제 useState
를 useReducer
로 refactoring 할 수 있게 되었다. useReducer
는 useState
와 형태가 거의 비슷하나 상태를 설정하는 함수가 dispatch 가 되고 인자로 reducer 와 초기값을 전달한다는 차이점이 있다.
reducer 가 단점이 없는게 아니다. 상황에 따라 useState
와 useReducer
중 무엇을 사용할지 비교해보자.
코드 크기
useState
를 사용하는게 코드의 양이 더 적다. useReducer
는 reducer
와 dispatch action
에 대해 추가로 코드를 짜야 한다. 물론 많은 이벤트 핸들러가 있고 상태를 변경하는 비슷한 상황에선 useReducer
를 사용하는게 코드를 더 줄여준다.가독성
useState
가 상태를 업데이트할때 읽기 편하다. 그런데 구조가 복잡해지면 이해하기 어려워진다. 이런 경우 seReducer
는 이벤트 핸들러에서 무엇이 발생하여 업데이트하는 로직을 깔끔하게 분리할 수 있다.debugging
useState
와 달리 useReducer
는 reducer 에서 상태변경을 모아서 하고 있기 때문에 원인을 파악하기 쉽다. 물론 useState
보다 코드를 더 짜야 한다.testing
만약 상태를 업데이트하는데 오류를 자주 만난다면
reducer
사용을 추천한다. 다만 항상reducer
를 쓸 필요 없다. 심지어useState
와useReducer
를 같은 컴포넌트에서 사용해도 된다.
reducer
는 순수함수여야 한다. state 를 변경하는 함수처럼 reducer
는 렌더링 도중 실행된다. 이는 같은 입력에 대해 항상 같은 출력을 반환해야 함을 의미한다. reducer
는 요청, 스케줄 시간 만료를 보내면 안되고 컴포넌트 바깥에 영향을 끼치는 side effect
를 발생시켜서는 안된다. reducer
는 객체와 배열을 변형없이 업데이트해야 한다.
action
이 데이터에서 많은 변경을 야기해도 각각의 action
은 하나의 유저 상호작용을 나타내야 한다. 예를 들어 reducer
에 의해 다수의 field 를 초기화하는 경우 여러개의 set_field action 을 취하는 것보다 하나의 reset action 을 dispatch 하는게 더 합리적이다.
객체나 배열을 상태로 사용시 업데이트할때 Immer library
를 사용하면 reducer 를 더욱 간결하게 사용할 수 있다. react 에선 배열이나 객체를 업데이트 할 때 직접 수정하면 안되고 불변성을 지켜주며 업데이트해줘야 한다.
그래서 ...spread 문법을 통해 새로운 객체를 만들어주거나
const object = {
a: 1,
b: 2
};
const nextObject = {
...object,
b: 3
};
배열의 경우 push, splice 등의 함수 사용, n 번째 항목의 직접수정은 하면 안되고 concat, filter, map 등의 함수를 사용해야 한다.
const todos = [
{
id: 1,
text: '할 일 #1',
done: true
},
{
id: 2
text: '할 일 #2',
done: false
}
];
const inserted = todos.concat({
id: 3,
text: '할 일 #3',
done: false
});
const filtered = todos.filter(todo => todo.id !== 2);
const toggled = todos.map(
todo => todo.id === 2
? {
...todo,
done: !todo.done,
}
: todo
);
객체의 구조가 복잡해지면 ... spread 문법
을 사용하면서 더욱 보기 어려워질 수 있다. 이때 Immer 라이브러리를 사용하면 변형해도 안전한 특별한 draft
객체를 제공한다. Immer 는 draft
에 대한 변경사항으로 상태의 복사본을 생성한다. 이러한 이유로 불변성을 신경쓰지 않고 업데이트를 해줄 수 있으니 첫 번째 인자로 받는 상태를 직접 수정할 수 있고 상태를 반환힐 필요도 없게 되는 것이다.
밸로퍼트와 함께하는 모던 리액트에 한눈에 변화를 보기 쉬운 코드가 있었다.
기존
const nextState = {
...state,
posts: state.posts.map(post =>
post.id === 1
? {
...post,
comments: post.comments.concat({
id: 3,
text: '새로운 댓글'
})
}
: post
)
};
Immer library 사용시
const nextState = produce(state, draft => {
const post = draft.posts.find(post => post.id === 1);
post.comments.push({
id: 3,
text: '와 정말 쉽다!'
});
});
단, Immer library 를 사용시 성능에 약간 하락이 있으니 무턱대고 항상 사용하면 안되고 상황에 따라 사용해야 할 것 같다.
Immer 를 사용하고자 한다면 다음의 명령어를 통해 설치할 수 있다.
npm install immer use-immer
대표적인 API 로 useImemr
와 useImmerReducer
가 있는데 각각 useState, useReducer 에 Imemr 를 사용한 버전의 변화가 있다.
즉, 구조는 거의 유사한데 기존에 불변성을 위해 직접 state 를 변경하지 않았던 부분을 draft 를 직접 수정하는 것과 state 를 반환하지 않는다는 차이점이 있다.
import React from "react";
import { useImmer } from "use-immer";
function App() {
const [person, updatePerson] = useImmer({
name: "Michel",
age: 33
});
function updateName(name) {
updatePerson(draft => {
draft.name = name;
});
}
function becomeOlder() {
updatePerson(draft => {
draft.age++;
});
}
return (
<div className="App">
<h1>
Hello {person.name} ({person.age})
</h1>
<input
onChange={e => {
updateName(e.target.value);
}}
value={person.name}
/>
<br />
<button onClick={becomeOlder}>Older</button>
</div>
);
}
import React from "react";
import { useImmerReducer } from "use-immer";
const initialState = { count: 0 };
function reducer(draft, action) {
switch (action.type) {
case "reset":
return initialState;
case "increment":
return void draft.count++;
case "decrement":
return void draft.count--;
}
}
function Counter() {
const [state, dispatch] = useImmerReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</>
);
}
Immer 라이브러리를 사용하면
... spread
,concat
,filter
,map
등으로 원본에 영향을 끼치지 않도록 하던 것을 Immer 가 복사본을 통해 draft 를 직접 수정할 수 있게 해주니push
, splice,arr[i] = 할당
등을 사용할 수 있다.
자.. 이제 reducer
가 무엇인지 이해가 좀 된 것 같다. 그럼 useReducer 훅에 대해 알아보자.
const [state, dispatch] = useReducer(reducer, initialArg, init?)
reducer
reducer
함수는 상태를 어떻게 업데이트할지 명시한다. 순수함수여야 하며 인자로 현재 상태와 action 을 받고 다음 상태를 반환한다. 현재 상태와 action 은 어떤 타입도 될 수 있다.initialArg
init
reducer 를 사용해서 상태를 관리하고 싶다면 useReducer 를 컴포넌트 상단에서 호출하자.
import { useReducer } from 'react';
function reducer(state, action) {
// ...
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ...
useReducer
는 2개의 값을 갖는 배열을 반환한다.
현재 상태. 첫 렌더링 동안 init(initialArg) 혹은 initialArg 로 설정된다.
상태를 다른 값으로 업데이트 하고 재렌더링을 유발하는 dispatch 함수
dispatch 함수는 상태를 변경하고 재렌더링을 유발한다. dispatch 함수의 유일한 인자로 action 을 전달해야 한다.
const [state, dispatch] = useReducer(reducer, { age: 42 });
function handleClick() {
dispatch({ type: 'incremented_age' });
// ...
react 는 제공한 현재 상태와 dispatch 로 전달한 action 을 통해
reducer
함수의 호출 결과로 다음 상태를 설정한다.
action
: 유저에 의해 수행되는 동작을 말한다. 어떤 타입이든 될 수 있다.일반적으로 action 은type
프로퍼티를 갖는 객체이고 다른 프로퍼티는 추가적인 정보를 선택적으로 갖는다.
dispatch 는 반환값을 갖지 않는다.
만약 dispatch 를 통해 새로 구한 상태가 현재 상태와 동일하다면 리액트는 컴포넌트를 재렌더링하지 않는다.
React 는 상태 업데이트를 일괄적으로 처리한다. 즉, 모든 이벤트 핸들러가 실행되고 그들의 set 함수를 호출한 뒤에 화면을 업데이트한다. 이런 batch state update 기능이 하나의 이벤트 동안 여러번 재렌더링되는 것을 막아준다.
state 는 읽기 전용이니 객체나 배열의 state 를 직접 바꾸면 안된다.
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// 🚩 Don't mutate an object in state like this:
state.age = state.age + 1;
return state;
}
대신 항상 reducer 를 통해 새로운 객체를 반환하자.
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// ✅ Instead, return a new object
return {
...state,
age: state.age + 1
};
}
// 올바르지 않은 예시
function createInitialState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, createInitialState(username));
// ...
애초에 React 는 첫 렌더링때만 초기 상태를 저장하고 다음 렌더링때 이 초기 상태를 사용한다. 그러는 대신 useReducer
의 세번째 인자로 init 함수를 전달하자.
// 권장되는 예시
function createInitialState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, username, createInitialState);
// ...
createInitialState()
가 아닌 createInitialState
를 세번째 인자로 전달하는 부분에 주목하자. 초기화 이후 초기 상태는 다시 만들어지지 않는다.
dispatch
함수를 호출하는 것은 코드를 실행하는 동안 상태를 바꾸지 않는다.
function handleClick() {
console.log(state.age); // 42
dispatch({ type: 'incremented_age' }); // Request a re-render with 43
console.log(state.age); // Still 42!
setTimeout(() => {
console.log(state.age); // Also 42!
}, 5000);
}
이는 이전에 useEffect
원리 파헤치기 2편 에서 정리했던 것처럼 상태는 스냅샷처럼 행동하기 때문인데 각각의 렌더링은 그때 마다의 상태를 갖는다. 마치 영상중 스크린샷을 찍은 순간의 상태를 각 초마다 다르게 갖는 것처럼 말이다.
dispatch 는 action 을 통해 상태를 바꿔 재렌더링을 유발한다. 이때 새로 갖게 된 상태는 그 렇게 재렌더링이 된 다음 렌더링 시점에서의 상태이지 현재의 상태가 아니다.
만약 다음상태를 알고싶다면 reducer
함수가 action 을 통해 새로운 상태를 반환하므로 이를통해 수동으로 확인할 수는 있다.
const action = { type: 'incremented_age' };
dispatch(action);
const nextState = reducer(state, action);
console.log(state); // { age: 42 }
console.log(nextState); // { age: 43 }
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// 🚩 Wrong: mutating existing object
state.age++;
return state;
}
case 'changed_name': {
// 🚩 Wrong: mutating existing object
state.name = action.nextName;
return state;
}
// ...
}
}
Immer 라이브러리를 사용하지 않는한 불변성을 유지해야 하기에 객체나 배열의 프로퍼티를 직접 수정해서는 안된다.
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// ✅ Correct: creating a new object
return {
...state,
age: state.age + 1
};
}
case 'changed_name': {
// ✅ Correct: creating a new object
return {
...state,
name: action.nextName
};
}
// ...
}
}
대신 ...spread 문법을 통해 새로운 객체를 반환할 수 있다.
docs
react.dev - extracting state logic into a reducer
mdn - reducer()
use-immer library
wiki
벨로퍼트와 함께하는 모던 리액트