[모던 리액트 Deep Dive] 리액트 훅으로 시작하는 상태 관리
- 비교적 오랜 기간 리액트 생태계에서는 리액트 애플리케이션의 상태 관리를 위해 리덕스에 의존했다.
과거 리액트 코드를 보면 리액트와 리덕스가 함께 설치돼 있는 것을 흔히 볼 수 있었고,
일부 개발자들은 리액트와 리덕스를 마치 하나의 프레임워크 내지는 업계 표준(de factor)으로 여기기도 했다.
- 그러나 현재는 새로운 Context API, useReducer, useState의 등장으로 컴포넌트에 걸쳐서 재사용하거나
혹은 컴포넌트 내부에 걸쳐서 상태를 관리할 수 있는 방법들이 점차 등장하기 시작했고,
덕분에 리덕스 외의 다른 상태 관리 라이브러리를 선택하는 경우도 많아지고 있다.
리액트 16.8에서 등장한 훅과 함수 컴포넌트의 패러다임에서 애플리케이션 내부 상태 관리는 어떻게 할 수 있고,
이러한 새로운 방법을 채택한 라이브러리는 무엇이 있고 어떻게 작동하는지 알아보자.
1) 가장 기본적인 방법: useState와 useReducer
- useState의 등장으로 리액트에서는 여러 컴포넌트에 걸쳐 손쉽게 동일한 인터페이스의 상태를 생성하고
관리할 수 잇게 됐다.
다음 예제 훅을 살펴보자.
function useCounter(initCount: number = 0) {
const [counter, setCounter] = useState(initCount)
function inc() {
setCounter((prev) => prev + 1)
}
return { counter, inc }
}
- 이 예제는 useCounter라는 훅을 만들어서 함수 컴포넌트 어디에서든 사용할 수 있게 구현한 사례다.
이 훅은 외부에서 받은 숫자 혹은 0을 초깃값으로 상태를 관리하며, inc라는 함수를 선언해 이 숫자를 1씩 증가시킬 수 있게
구현했다.
그리고 상태값인 counter와 inc 함수를 객체로 반환한다.
- 다음 코드와 같이 useCounter를 사용하는 함수 컴포넌트는 이 훅을 사용해 각자의 counter 변수를 관리하며,
중복되는 로직 없이 숫자를 1씩 증가시키는 기능을 손쉽게 이용할 수 있다.
function useCount(initCount: number = 0) {
const [counter, setCounter] = useState(initCount)
function inc() {
setCounter((prev) = prev + 1)
}
return { counter, inc }
}
function Counter1() {
const { counter, inc } = useCounter()
return (
<>
<h3>Counter1: {counter}</h3>
<button onClick={inc}>+</button>
</>
)
}
function Counter2() {
const { counter, inc } = useCounter()
return (
<>
<h3>Counter2: {counter}</h3>
<button onClick={inc}>+</button>
</>
)
}
- useCounter라는 훅이 없었다면 이러한 기능이 필요한 각각의 컴포넌트에서 모두 위와 같은 내용을 구현해야만 했을 것이다.
더 나아가 훅 내부에서 관리해야 하는 상태가 복잡하거나 상태를 변경할 수 있는 시나리오가 다양해진다면,
훅으로 코드를 격리해 제공할 수 있다는 장점이 더욱 크게 드러날 것이다.
이처럼 리액트의 훅을 기반으로 만든 사용자 정의 혹은 함수 컴포넌트라면 어디서든 손쉽게 재사용 가능하다는 장점이 있다.
- useState와 비슷한 훅은 useReducer 또한 마찬가지로 지역 상태를 관리할 수 있는 훅이다.
앞서 2장에서 useReducer를 다루면서 잠깐 다뤘던 내용 중 하나는, 실제로 useState는 useReducer로 구현됐다는 사실이다.
이러한 사실을 증며하기 위해 react와 preact의 소스코드를 발췌했었는데, 이를 실제 코드로 작성하면
다음과 비슷한 코드로 예상해볼 수 있다.
type Initializer<T> = T extends any ? T | ((prev: T) => T) : never
function useStateWithUseReducer<T>(initialState: T) {
const [state, dispatch] = useReducer(
(prev: T, action: Initializer<T>) =>
typeof action === 'function' ? action(prev), action,
initialState,
)
return [state, dispatch]
- 먼저 useState를 useReducer로 구현하는 예제다.
useReducer의 첫 번째 인수로는 reducer, 즉 state와 action을 어떻게 정의할지를 넘겨줘야 하는데
useState와 동일한 작동, 즉 T를 받거나 (prev: T) => T를 받아 새로운 값을 설정할 수 있게끔 코드를 작성했다.
- 이와 반대로, useReducer 또한 useState로 작성할 수 있다.
function useReducerWithUseState(reducer, initialState, initializer) {
const [state, setState] = useState(
initializer ? () => initializer(initialState) : initialState,
)
const dispatch = useCallback(
(action) => setState((prev) => reducer(prev, action)),
[reducer],
)
return [state, dispatch]
}
- useReducer를 타입스크립트로 작성하려면 다양한 형태의 오버로딩이 필요한데
코드의 대략적인 구성만 간단하게 설명하기 위해 자바스크립트로 작성했다.
useState나 useReducer 모두 약간의 구현상의 차이만 있을 뿐,
두 훅 모두 지역 상태 관리를 위해 만들어졌다는 것을 알 수 있다.
- 지금까지 일반적으로 사용되는 useState와 useReducer로 컴포넌트 내부의 상태를 관리하는 방법에 대해 알아봤다.
그러나 실제 애플리케이션을 작성해 보면 알겠지만, useState와 useReducer가 상태 관리의 모든 필요성과 문제를
해결해 주지는 않는다.
- useState와 useReducer를 기반으로 하는 사용자 지정 훅읜 한계는 명확하다.
훅을 사용할 때마다 컴포넌트별로 초기화되므로 컴포넌트에 따라 서로 다른 상태를 가질 수밖에 없다.
위 예제의 경우 counter는 useCounter가 선언될 때마다 새롭게 초기화되어,
결론적으로 컴포넌트별로 상태의 파편화를 만들어 버린다.
- 이렇게 기본적인 useState를 기반으로 한 상태를 지역 상태(local state)라고 하며,
이 지역 상태는 해당 컴포넌트 내에서만 유효하다는 한계가 있다.