상태 하나를 useState로 다루는 건 쉽다. 골치가 아파지는 건 서로 얽힌 상태가 여러 개일 때다. 장바구니를 떠올려 보자. 담긴 상품 목록이 있고, 총 개수가 있고, 지금 불러오는 중인지 여부가 있다. 상품을 하나 담으면 목록도 바뀌고 개수도 바뀐다. 이걸 useState 여러 개로 따로 관리하면, 상품을 담는 함수 안에서 setItems도 부르고 setCount도 부르고, 하나라도 빠뜨리면 화면이 어긋난다. 이럴 때 쓰라고 리액트가 내준 게 useReducer다.


여러 상태가 같이 움직이면 useState가 흩어진다. 관련된 상태를 useState로 쪼개 두면, 그것들을 바꾸는 코드도 여기저기 흩어진다. 담기, 빼기, 비우기 함수마다 여러 set 호출이 조금씩 다르게 들어가고, 나중에 규칙이 하나 바뀌면 그 함수들을 전부 찾아다니며 고쳐야 한다. 상태를 바꾸는 논리가 컴포넌트 곳곳에 번져 있는 것이다.


리듀서는 상태를 바꾸는 규칙을 한곳에 모은다. 리듀서(reducer)는 함수 하나다. 지금 상태와 방금 무슨 일이 일어났는지를 받아서, 다음 상태를 돌려준다. 생김새는 (state, action) => newState로 늘 똑같다. 상태를 어떻게 바꿀지에 대한 모든 경우가 이 함수 한 곳에 모인다. 카운터로 가장 단순한 형태를 보자.


function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
return state;
}
}


이 함수는 컴포넌트 바깥에 있다. 화면도 모르고 이벤트도 모른다. 그저 현재 상태와 행동을 받아 새 상태를 계산하는 순수한 규칙일 뿐이다. 컴포넌트에서는 useReducer로 이 규칙을 연결한다.


function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</div>
);
}


useReducer는 규칙 함수와 처음 상태를 받아, 지금 상태와 dispatch라는 함수를 돌려준다. 버튼을 누르면 dispatch에 행동을 담은 객체를 넘긴다. 그러면 리액트가 그 객체를 들고 리듀서를 부르고, 리듀서가 돌려준 새 상태로 state를 갈아끼운 뒤 화면을 다시 그린다. 플러스 버튼을 누르면 dispatchincrement 액션을 리듀서에 전달하고, state.count가 1 오른 새 객체가 나와 숫자가 올라간다.


액션은 무슨 일이 일어났는지를 적은 객체다. dispatch에 넘기는 그 객체를 액션(action)이라고 한다. 관례상 type이라는 이름표를 달아 어떤 일인지 밝히고, 필요하면 딸린 데이터를 함께 싣는다. 이 딸린 데이터를 흔히 payload(꾸러미)라고 부른다. 예를 들어 할 일 목록에서 항목을 추가한다면 이렇게 생긴 액션을 보낸다.


dispatch({ type: "add", payload: { id: 1, text: "우유 사기" } });


type은 무슨 일인지, payload는 그 일에 필요한 재료다. 이름은 add, remove처럼 벌어진 일 그대로 붙이는 게 요령이다. setThis, setThat처럼 어떻게 바꿀지를 적는 게 아니라, 무엇이 일어났는지를 적는다. 그래야 나중에 액션 이름만 훑어도 이 화면에서 어떤 일들이 벌어지는지 한눈에 읽힌다.


function todoReducer(state, action) {
switch (action.type) {
case "add":
return [...state, action.payload];
case "remove":
return state.filter((t) => t.id !== action.payload);
case "toggle":
return state.map((t) =>
t.id === action.payload ? { ...t, done: !t.done } : t
);
default:
return state;
}
}


세 가지 행동을 한 함수가 다 품고 있다. add는 기존 목록에 새 항목을 붙인 새 배열을, remove는 해당 id만 걸러낸 새 배열을, toggle은 해당 항목의 done만 뒤집은 새 배열을 돌려준다. 리듀서 안에서는 action.payload로 액션에 실려 온 재료를 꺼내 쓴다. 삭제라면 지울 id가, 추가라면 새 항목 객체가 그 안에 들어 있다. 컴포넌트에서는 어떤 버튼을 누르든 dispatch에 알맞은 액션만 실어 보내면 그만이다. 상태를 실제로 어떻게 주무르는지는 전부 리듀서 안에 모여 있다.


리듀서 안에서는 딴짓을 하지 마라. 리듀서에는 두 가지 철칙이 있다. 첫째, 받은 상태를 직접 고치지 말고 언제나 새 값을 만들어 돌려준다. state.push(...)로 원본을 밀어넣고 state를 그대로 반환하면 화면이 안 바뀐다. 둘째, 리듀서 안에서 서버 요청을 보내거나 화면을 직접 만지는 부수 효과(side effect)를 넣지 않는다. 리듀서는 오직 입력으로 출력을 계산하는 순수한 함수여야 한다.


그래서 언제 useReducer를 꺼내나. 상태가 하나뿐이고 그냥 값을 갈아끼우는 정도면 useState로 충분하다. useReducer가 값어치를 하는 건, 서로 얽힌 상태를 여러 방식으로 바꿔야 할 때다. 앞의 할 일 목록처럼 추가, 삭제, 토글 같은 행동이 여럿이고 그 논리가 제법 복잡하면, 흩어진 set 호출을 리듀서 한 곳으로 모으는 편이 훨씬 읽기 좋다. 상태 변경 방식이 서너 가지를 넘어가기 시작하면 useReducer를 떠올릴 때다.


내가 초반에 제일 많이 낸 실수는 default 가지를 빼먹은 것이었다. 처리하지 않는 액션이 들어오면 아무것도 반환하지 않아 상태가 통째로 undefined가 됐고, 다음 렌더에서 state.count를 읽다가 앱이 터졌다. 어떤 액션에도 걸리지 않으면 원래 상태를 그대로 돌려주는 default: return state는 빠뜨리면 안 되는 안전판이다.


정리하면, 리듀서는 상태 변경 규칙을 한 함수에 모은 것이고, 액션은 그 함수에 무슨 일이 일어났는지 알려주는 객체다. dispatch로 액션을 보내면 리듀서가 새 상태를 계산해 낸다. 상태가 여러 개 얽혀 복잡해질수록, 변경 논리를 한곳에 모아 두는 이 방식의 값어치가 올라간다. 그리고 그 새 상태를 반드시 새 값으로 만들어야 한다는 원칙은, 리액트 상태 관리 전체를 관통하는 규칙이기도 하다.