13

useState vs useReducer

이 글은 TkDodouseState vs useReducer 포스트를 번역한 글입니다.

useState의 함정


어떤 state 관리 솔루션을 사용해야지에 대한 질문은 아마도 react가 탄생한 시점부터 (어쩌면 그 이전부터) 존재해왔고 그에 대한 답변은 다양합니다. 저에게는 오직 하나의 좋은 답변이 존재하며 어떠한 복잡한 질문에도 동일합니다.

상황에 따라 다릅니다.

TkDodo

state의 유형, 변경 주기 그리고 범위에 따라서 다를 수 있습니다.

클라이언트 state

hook이 탄생하기 이전에 클라이언트 지역 state를 관리하는 방법은 단 하나였습니다: 클래스 컴포너트에서의 this.setState. state는 반드시 객체여야 했으며 변경 함수는 state의 일부분만 받는 것을 허용했습니다.

hook은 근본적인 방식을 바꿨습니다. 함수형 컴포넌트에서 state를 관리할 수 있게 했을 뿐만 아니라 useStateuseReducer라는 두 가지 다른 선택지를 제공했습니다.

클래스 기반에서 hook 기반 state 관리로 바꿀 때 대부분의 사람들의 접근 방법은 하나의 객체를 각각의 필드를 값으로 하는 useState로 변경하는 것입니다.

이전:

class Names extends React.Component {
state = {
firstName: '',
lastName: '',
}
render() {
return (
<div>
<input value={this.state.firstName} onChange={(event) => this.setState({ firstName: event.target.value }) } />
<input value={this.state.lastName} onChange={(event) => this.setState({ lastName: event.target.value }) } />
</div>
)
}
}

이후:

const Names = () => {
const [firstName, setFirstName] = React.useState('')
const [lastName, setLastName] = React.useState('')
return (
<div>
<input value={firstName} onChange={(event) => setFirstName(event.target.value)} />
<input value={lastName} onChange={(event) => setLastName(event.target.value)} />
</div>
)
}

마치 책에 나올 것만 같은 예시지만 여기서는 실제로 말이 됩니다. 두 개의 필드들이 자체적으로 변경되므로 자급자족할 수 있기 때문입니다.

그러나 항상 이런 경우만 있는 건 아닙니다. 때로는 같이 변경되어야 하는 state가 있으며 이런 상황에서는 여러 개의 useState로 분리시키는 것이 적합하다고 생각하지 않습니다.

한 예시로 마우스의 좌표(x, y)를 저장하는 것을 들 수 있습니다. 항상 동시에 변경되는 것에 두 개의 useState를 사용하는 것은 너무 이상하다고 생각하기에 이럴 경우에는 하나의 state 객체를 사용합니다:

const App = () => {
const [{ x, y }, setCoordinates] = React.useState({ x: 0, y: 0 })
return (
<button onClick={(event) => { setCoordinates({ x: event.screenX, y: event.screenY }) }} >
Click, {x} {y}
</button>
)
}

폼 state

사용할 때마다 구조가 다르고 한 번에 한 필드만 변경하는 일반적인 폼의 경우에는 하나의 state 객체를 사용하는 것 또한 잘 동작합니다. 몇 개의 필드가 들어갈지 모르기에 여러 개의 useState를 사용할 수 없으므로 기본적인 커스텀 hook은 다음과 같이 구현할 수 있습니다.

const useForm = <State extends Record<string, unknown>>(
initialState: State
) => {
const [values, setValues] = React.useState(initialState)
const update = <Key extends keyof State>(name: Key, value: State[Key]) =>
setValues((form) => ({ ...form, [name]: value }))
return [values, update] as const
}

그래서 useState의 경우 state를 분리할지 말지 결정할 때 다음 규칙을 적용합니다.

동시에 변경되는 state는 같이 있어야 합니다.

Batching

여러 state를 만들어서 변경 함수를 연달아 호출하지 않고 하나의 state 객체를 만들어 변경 함수를 한 번 호출하는 것을 고려해 보세요. react는 동기 이벤트 핸들러에서 state를 한꺼번에 변경하는 것을 매우 잘 하지만 비동기 함수에서는 문제가 있습니다. 이 이슈는 react 18에서의 자동 batching에 의해 해결됐지만 성능과는 상관없이 어떤 state가 함께 변경되는지, 안 되는지를 추론할 수 있도록 코드를 구조화하는 것은 장기적인 측면에서 가독성과 유지 보수에 많은 도움이 됩니다.

useReducer

useReducer가 너무 적게 사용되고 있다고 생각합니다. 대다수의 사람들이 useReducer를 "복잡한 state"를 관리할 때만 필요로 하다고 생각하고 있지만 이전에 말했듯이 state를 토글하는 상황에서도 활용할 수 있습니다.

const [value, toggleValue] = React.useReducer(previous => !previous, true)
<button onClick={toggleValue}>Toggle</button>

또한 forceUpdate(거의 모든 전역 state 관리 라이브러리는 state가 react 외부에서 관리되고 있다면 구독자에게 state 변경을 알리기 위해 이를 필요로 합니다.)를 구현하는데도 사용할 수 있습니다.

const forceUpdate = React.useReducer((state) => state + 1, 0)[1]

변경사항: React 18부터 useSyncExternalStore hook을 제공하게 돼서 전역 state 관리 라이브러리는 forceUpdate를 사용하지 않기 시작했습니다.

위 구현 사항은 복잡하지 않으며 useReducer의 유연성을 잘 보여준다고 생각합니다. 이와 더불어 state의 여러 부분을 다른 "액션"에 따라 변경할 때도 빛을 발합니다. 예를 들어 여러 단계가 있는 무언가를 구현할 때 두 번째 단계에서는 첫 번째 단계의 데이터를 기반으로 값을 초기화하거나 세 번째 단계의 데이터를 두 번째 단계로 돌아갔을 때 버리기도 합니다.

독립적인 useState가 있을 때(한 단계에 하나씩) 이러한 state 사이의 의존성이 있다면 setState를 여러 차례 연달아 호출해야 합니다.

useReducer 팁

useReducer를 사용할 때면 리덕스 스타일 가이드를 적용하려고 합니다.

완전히 추천할 수 있는 훌륭한 글이며 대부분의 요점이 useReducer에 잘 적용됩니다. 예를 들어

이벤트 주도 reducer

대부분의 사람들은 reducer에서 불변성을 유지하고 부수효과를 가지지 않는 것을 자연스레 적용합니다. (react 자체에서 요구하는 것과 동일하기에)

액션을 이벤트로써 모델링 하는 것은 reducer의 가장 큰 장점이기에 매우 강조하고 싶습니다. 그렇게 함으로써 모든 어플리케이션 로직을 ui의 다양한 부분에 펼쳐놓는 대신 reducer 내부에서 관리할 수 있습니다. 이는 state 변화를 추론하기 쉽게 만들 뿐만 아니라 로직을 테스트하기 매우 쉽게 만들어줍니다. (순수 함수는 테스트하기 가장 쉽기 때문입니다.)

이 컨셉을 보여주기 위해 전형적인 counter 예시를 보여드리겠습니다.

const reducer = (state, action) => {
// ✅ ui는 이벤트만 전달하며 로직은 reducer 내부에 존재하게 됩니다.
switch (action) {
case 'increment':
return state + 1
case 'decrement':
return state - 1
}
}
function App() {
const [count, dispatch] = React.useReducer(reducer, 0)
return (
<div>
Count: {count}
<button onClick={() => dispatch('increment')}>Increment</button>
<button onClick={() => dispatch('decrement')}>Decrement</button>
</div>
)
}

정교하지는 않으나 (1을 추가 또는 1을 빼기) 이 또한 로직입니다. 얼마를 추가하거나 뺄지 결정할 수 있도록 만들 수도 있습니다.

모든 것은 reducer 내부에서 이뤄집니다. 그것을 오직 새로운 숫자만 받는 "어리석은" reducer 예시와 비교해 보세요.

const reducer = (state, action) => {
switch (action.type) {
// 🚨 새로운 숫자만 받는 "어리석은" reducer
case 'set':
return action.value
}
}
function App() {
const [count, dispatch] = React.useReducer(reducer, 0)
return (
<div>
Count: {count}
<button onClick={() => dispatch({ type: 'set', value: count + 1 })}>
Increment
</button>
<button onClick={() => dispatch({ type: 'set', value: count - 1 })}>
Decrement
</button>
</div>
)
}

동작은 하지만 이전 예시처럼 확장 가능하지는 않습니다. 그러니 일반적으로 set이라는 이름을 가진 액션들은 피하도록 하세요.

reducer에 props 전달하기

reducer의 또 다른 좋은 특성은 인라인 하거나 클로져를 통해 props에 접근할 수 있다는 것입니다. 이 방법은 reducer 내부에서 props 또는 서버 state(예. useQuery hook이 리턴하는 state)에 접근할 때 매우 유용합니다. state 초기화를 통해 이러한 값들을 "복사"하지 않고도 reducer 함수에 전달할 수 있습니다.

const reducer = (data) => (state, action) => {
// ✅ 항상 최신 서버 state에 접근할 수 있습니다.
}
function App() {
const { data } = useQuery(key, queryFn)
const [state, dispatch] = React.useReducer(reducer(data))
}

이것은 서버와 클라이언트 state를 분리 시켜야 한다는 컨셉과 매우 잘 맞습니다. 그리고 만약에 data를 초기값으로 전달한다고 해도 reducer가 처음 실행될 때는 data가 undefined이므로 동작하지 않을 것입니다.

그러면 state 동기화를 위해 useEffect 만드려고 할 것이며 이는 background update와 관련된 모든 종류의 문제가 발생할 수 있습니다.

이 접근법으로 이벤트 주도 counter 예시를 서버에서 받아온 amount 만큼 더하거나 빼도록 확장시켜 보겠습니다. 커스텀 hook을 이용해 보겠습니다.

const reducer = (amount) => (state, action) => {
switch (action) {
case 'increment':
return state + amount
case 'decrement':
return state - amount
}
}
const useCounterState = () => {
const { data } = useQuery(['amount'], fetchAmount)
return React.useReducer(reducer(data ?? 1), 0)
}
function App() {
const [count, dispatch] = useCounterState()
return (
<div>
Count: {count}
<button onClick={() => dispatch('increment')}>Increment</button>
<button onClick={() => dispatch('decrement')}>Decrement</button>
</div>
)
}

커스텀 hook으로 로직을 명확하게 분리했기 때문에 ui 쪽에는 변경사항이 없다는 것을 주목해 주세요.

경험에 따른 법칙

경험상 언제 무엇을 써야 할지는 다음과 같습니다:

  • 만약 state 변경이 독릭접으로 이뤄진다면 - 여러 useState로 분리하세요.
  • 동시에 변경되거나 한 번에 하나의 필드만 변경(ex. 폼) 된다면 - 하나의 useState와 객체를 사용하세요.
  • 사용자의 인터렉션이 state의 다른 부분을 변경한다면 (state가 서로 의존한다면) - useReducer를 사용하세요.