useReducer
는 단일 상태를 관리하는 useState
보다 더 복잡한 state를 관리할 때 유용하게 사용할 수 있는 React Hook이다.
간단한 카운트 앱으로 useState
와 useReducer
의 차이를 알아보자.
import { useState } from "react";
const App = () => {
const [age, setAge] = useState(42);
// 이전의 state 값을 기반하여 증가/감소
const plusAge = () => {
setState(prevState => prevState + 1)
};
const minusAge = () => {
setState(prevState => prevState - 1)
};
return (
<>
<h2>{age}</h2>
<button onClick={minusAge}>한살 빼기</button>
<button onClick={plusAge}>한살 더하기</button>
</>
);
};
export default App;
이렇게 useState
를 이용하여 카운트 앱을 만들었다.
여기서 useState
의 두번째 set 함수에 인자로 콜백 함수를 사용하여 현재 state의 값(화면에 출력된 값)을 기반하여 1씩 증가/감소하게 구현하였다.
그럼 이제 useReducer
를 이용한 카운트 앱을 보자.
import { useReducer } from "react";
const reducer = (state, action) => {
switch (action.type) {
case "PLUS_AGE":
return { ...state, age: state.age + 1 };
case "MINUS_AGE":
return { ...state, age: state.age - 1 };
default:
return state;
}
};
const App = () => {
const [age, dispatch] = useReducer(reducer, { age: 42 });
const plusAge = () => {
dispatch({ type: "PLUS_AGE" });
};
const minusAge = () => {
dispatch({ type: "MINUS_AGE" });
};
return (
<>
<h2>{age.age}</h2>
<button onClick={minusAge}>한살 빼기</button>
<button onClick={plusAge}>한살 더하기</button>
</>
);
};
export default App;
모두 아래 결과처럼 동일한 기능을 한다.
근데 useState
의 카운트 앱보다 훨씬 복잡해보인다.
하지만 useReducer
는 이런 간단한 앱보단 다수의 복합적인 state를 다루는 앱(예를 들면, 회원가입 폼 등)에 더 효과적으로 사용될 수 있다.
나는 useReducer
를 개인적으로 사용해보고 정리를 하기 위해 간단한 카운트 앱을 했지만 복잡한 상태 관리가 필요하다면 직접 사용해보길 바란다.
우선 하나씩 보도록 하자.
Hook을 사용하기 위해 import가 필요하다.
import { useReducer } from "react";
import 후 사용하기 위해 아래처럼 작성한다.
const [state, dispatchFunc] = useReducer(ReducerFunc, initialState);
여기서 하나씩 살펴보면,
useReducer
도 useState
처럼 반환값이 배열인데 첫번째 요소가 state
, 두번째 요소가 dispatchFunc
이다.
이것은 위의 useReducer
의 카운트 앱에 사용된 예시이다.
const [age, dispatch] = useReducer(reducer, { age: 42 });
여기서 state
는 말 그대로 useState
와 동일한 역할의 현재의 state 변수이다.
useReducer
의 두번째 인자인 initialState
가 최초로 할당된다.
dispatchFunc
는 '상호작용에 따라 변경할 수 있는 디스패치 함수'이다.
쉽게 말하자면 화면에 있는 state
를 업데이트하기 위한 함수이다.
중요한 점은 dispatchFunc
가 호출이 되면 state
를 다른 값으로 업데이트하고 사용된 컴포넌트의 렌더링을 발생시킨다.
useReducer
의 첫번째 인자 ReducerFunc
는 "dispatchFunc
가 전달한 action
객체
의 type
프로퍼티에 따라 state
를 어떻게 업데이트할지 결정하는 함수이다.
type
프로퍼티가 아닌 다른 것으로도 업데이트 작업을 결정할 수 있다.
단, 보편적으로 어떻게 업데이트를 할지 식별하기 위해type
프로퍼티를 포함한action
객체를 사용한다.
useReducer
의 두번째 인자 initialState
는 반환하는 배열의 첫번째 요소인 state
의 초기값을 설정하는 인자이다.
물론 useState
처럼 해당 컴포넌트가 처음 렌더링되었을 때 최초로 state
에 할당되며 이후의 렌더링에는 무시되는 값이다.
카운트 앱의 dispatchFunc
를 보도록 하자.
// age의 증가를 위한 함수
const plusAge = () => {
dispatch({ type: "PLUS_AGE" });
};
// age의 감소를 위한 함수
const minusAge = () => {
dispatch({ type: "MINUS_AGE" });
};
dispatchFunc
를 보면 뭔가 당황스러울 것이다.
왜냐하면 dispatchFunc
는 state
를 업데이트하는 함수인데 왜 useState
처럼 증가/감소하는 로직이 없기 때문이다. 또한 반환값도 없다.
useReducer
는 useState
와 달리 복잡한 state를 관리하기 때문에 인자로 ReducerFunc
에 전달되는 action
객체의 type
프로퍼티에 따라 state 업데이트 동작(action)을 결정하게 된다.
쉽게 말하자면 dispatchFunc
는 그저 state를 어떻게 업데이트할 것인지 결정하기 위해 조건을 ReducerFunc
에 전달한다고 생각하면 된다.
그래서 위처럼 { type: "PLUS_AGE" }
와 { type: "PLUS_AGE" }
인 action
객체를 ReducerFunc
에 전달한다.
여기서 type
의 값이 모두 대문자인 것을 볼 수 있는데 이것은 관례라고 하니 만약 다른 컨벤션을 원한다면 변경해도 된다.
React Docs Beta 공식 문서에는 소문자로 표기되어 있다.
// ReducerFunc
const reducer = (state, action) => {
switch (action.type) {
case "PLUS_AGE":
return { ...state, age: state.age + 1 };
case "MINUS_AGE":
return { ...state, age: state.age - 1 };
default:
return state;
}
};
const App = () => {
// ...
const plusAge = () => {
dispatch({ type: "PLUS_AGE" });
};
const minusAge = () => {
dispatch({ type: "MINUS_AGE" });
};
// ...
}
ReducerFunc
는 대체적으로 컴포넌트 외부에 위치한다. 왜냐하면 컴포넌트가 재렌더링이 되면서 새로 생성될 필요가 없기 때문이다.
이제 dispatchFunc
호출로 인해 type
프로퍼티가 있는 action
객체를 전달 받았다고 가정해보자.
예를 들어 plusAge
함수가 버튼 클릭으로 호출이 되어 dispatchFunc
가 호출되어 action
객체가 ReducerFunc
에 전달되었다.
그럼 ReducerFunc
의 내부의 switch
문에 의해 해당되는 action.type
에 따라 state의 업데이트 방식이 결정된다.
여기서 궁금한 점이 있다면 ReducerFunc
의 매개변수 state
와 action
일 것이다.
state
는 화면에 있는 기존의 값을 가리키며action
는 dispatchFunc
로 부터 전달받은 action
객체이다.이것을 로그로 출력해보면 아래와 같다.
// plusAge 함수를 클릭 이벤트로 호출했을 때
const reducer = (state, action) => {
console.log(state, action) // { age : 42 }, { type : "PLUS_AGE" }
switch (action.type) {
case "PLUS_AGE":
return { ...state, age: state.age + 1 };
case "MINUS_AGE":
return { ...state, age: state.age - 1 };
default:
return state;
}
};
처음 클릭 이벤트로 인해 호출되었기 때문에 첫번째 매개변수 state
에는 useReducer
호출 단계에서 설정했던 initialState
가 출력되었다.
👇
const [age, dispatch] = useReducer(reducer, { age: 42 });
그리고 두번째 매개변수 action
은 dispatchFunc
로부터 전달받은 action
객체가 출력되었다.
const plusAge = () => {
dispatch({ type: "PLUS_AGE" });
👆
};
여기선 action
객체의 type
이 "PLUS_AGE"
이기 때문에 switch문의 첫번째 case의 반환값이 새로운 state 값으로 업데이트된다.
스프레드 연산자(...)를 사용해서 기존의 state을 전개한 이유는? 🤔
이 카운트 앱에는 굳이 필요가 없어보인다. 왜냐하면 기존에
initialState
로 설정한 값의 프로퍼티는age
만 존재하기 때문에 굳이 스프레드 연산자를 사용할 필요없이{ age : state.age + 1 }
만 해주면 된다.
다만, 만약 age 말고도 다른 프로퍼티가 존재할 때 그냥{ age : state.age + 1 }
만 작성한다면 "age와 다른 프로퍼티를 포함한 객체"를 "age 프로퍼티만 가진 객체"로 완전히 교체해버리는 문제가 생긴다.
그래서 이를 방지하기 위해 기존의 state를 전개하고 이후에 업데이트할 값을 작성해서 기존의 state에 오버라이드 해주는 것이다.
기존의 state의 age
값에 1
을 더하는 것을 알 수 있다.
useReducer
호출은 반드시 사용할 컴포넌트의 최상위 레벨에서 해야한다. 절대 루프나 조건문에서 호출해서는 안된다.state
, action
)의 불변성을 유지해야한다.// React의 불변성을 위해 아래처럼 직접 업데이트해서는 안된다.
case "PLUS_AGE":
return state.age++;
case "MINUS_AGE":
return state.age--;
취업하셨는지 궁금합니다.