리액트 16.8에서 등장한 훅과 함수 컴포넌트의 패러다임에서 애플리케이션 내부 상태 관리는 어떻게 할 수 있고, 이러한 새로운 방법을 채택한 라이브러리는 무엇이 있고 어떻게 작동하는지 알아보자.
useState의 등장으로 여러 컴포넌트에 걸쳐 손쉽게 동일한 인터페이스의 상태를 생성하고 관리할 수 있게 됐다.
function useCounter(initcounter: number = 0) {
const [counter, setCounter] = useState(initcount)
function inc() {
setCount((prev) => prev + 1)
}
return { counter, inc}
}
이렇게 쉽게 counter 값을 변경시키고 관리할 수 있다.
useReducer 또한 마찬가지로 지역 상태를 관리할 수 있는 훅이다.
실제 app을 작성해 보면 알겠지만 훅을 사용할 때마다 컴포넌트별로 초기화되므로 컴포넌트에 따라 서로 다른 상태를 가질 수밖에 없다.
같은 상태를 가지기 위해서 생각할 수 있는 가장 쉬운 방법은 한 단계 위의 컴포넌트를 쓰고 내려주는 것이다. 다만 이것은 번거롭다.
useState의 한계는 명확하기 때문에 리액트 클로저가 아닌 다른 js 실행 문맥 어디에선가 완전히 다른 곳에서 초기화돼서 관리되면 어떨까? 라고 생각할 수 있다.
//counter.ts
export type State = { counter: number}
// 상태를 아예 컴포넌트 바깥에서 선언
let state: State = {
counter: 0,
}
// getter
export function get(): State {
return state
}
// useState와 동일하게 구현하기 위해 게으른 초기화 함수나 값을 받을 수 있게 했다.
type Initializer<T> = T extends any ? T | ((prev:T) => T) : never
// setter
export function set<T>(nextState: Initializer<T>) {
state = typeof nextState === 'function' ? nextState(state) : nextState
}
// Counter
function Counter() {
const state = get()
function handleClick() {
set((prev: State) => ({ counter: prev.counter + 1 }))
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
)
}
이것은 해보면 다른것은 잘 관리되지만 컴포넌트가 리렌더링 되지 않는다는 문제가 있다.
컴포넌트 리렌더링을 하려면 다음과 가은 작업 중 하나가 일어나야 한다.
useState를 자식 컴포넌트 내부에 존재시켜 리렌더링 시키는 방법이 있다. 다만 이것은 굉장히 비효율적이고 문제점도 가지고 있다. 외부에 상태가 있음에도 동일한 상태 관리를 하는 useState가 존재한다는 점, 여러 컴포넌트를 동시에 리렌더링 할 수 없다는 점 등이 있다.
따라서 다음과 같은 조건을 만족해야 한다는 결론에 도달한다.
이런 조건을 모두 만족하는 훅이 이미 있으니 이를 useSubscription이다.
Recoil, Jotai : Context와 Provider, 그리고 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 데 초점을 맞추고 있다.
Zustand : 리덕스와 비슷하게 하나의 큰 스토어를 기반으로 상태를 관리하는 라이브러리다. Recoil, Jotai와 다르게 Context가 아니라 스토어가 가지는 클로저를 기반으로 생성되며, 이 스토어의 상태가 변경되면 이 상태를 구독하고 잇는 컴포넌트에 전파해 리렌더링을 알리는 방식이다.
RecoilRoot는 Recopil을 사용하기 위해 app 최상단에 선언해 둬야 한다.
export dfault function App() {
return <RecoilRoot>{/* some components */}</RecoilRoot>
}
Recoil의 소스코드를 살펴보면 RecoilRoot에서 Recoil에서 생성되는 상태값을 저장하기 위한 스토어를 생성하는 것을 확인 할 수 있다.
function RecoilRoot(props: Props}: React.Node {
const { overide, ...propsExceptOverride } = props
const ancestorStoreRef = useStoreRef()
if (override === false && ancestorStoreRef.current !== defaultStore) {
// If ancestorStoreRef.current !== defaultStore, it means that this
// RecoilRoot is not nested within another.
return props.children
}
return <RecoilRoot_INTERNAL {...propsExceptOverride} />
}
여기서 주목할 것은 useStoreRef다. useStoreRef로 ancestorStoreRef의 존재를 확인하는데, 이는 Recoil에서 생성되는 atom과 같은 상태값을 저장하는 스토어를 의미한다. 그리고 이 useStoreRef가 가리키는 것은 다름 아닌 AppContext가 가지고 있는 스토어다.
// useStoreRef 코드
const AppContext = React.createContext<StoreRef>({ current: defaultStore })
const useStoreRef = (): StoreRef => useContext(AppContext)
defaultStore는 다음과 같다.
//defaultStore 코드
function notInAContext() {
throw err('This component must be used inside a <RecoilRoot> component.')
}
const defaultStore: Store = Object.freeze({
storeID: getNextStoreID(),
getState: notInAContext,
replaceState: notInAContext,
getGraph: notInAContext,
subscribeToTranscations : notInAContext,
addTransactionMetadata: notInAContext,
})
스토어를 살펴보면
또 흥미로운 것은 replaceState에 대한 구현이다.
// Recoil의 replaceState 코드
const replaceState = (replacer: (TreeState) => TreeState) => {
startNextTreeIfNeeded(storeRef.current)
//Use replacer to get the next state:
const nextTree = nullthrows(storeStateRef.current.nextTree)
let replaced
try {
stateReplacerIsBeingExecuted = true
replaced = replacer(nextTree)
} finally {
stateReplacerIsBeingExecuted = false
}
if (replaced === nextTree) {
return
}
// 생략
// Save changes to nextTree and schedule a React update:
storeStateRef.current.nextTree = replaced
if (reactMode().early) {
notifyComponents(storeRef.current, storeStateRef.current, replaced)
}
// ...
}
상태 변경 시 하위 컴포넌트로 전파해 컴포넌트의 리 렌더링을 일으키는 notifyComponents가 있는 것을 확인할 수 있다.
// notifycomponents의 구조
function notifyComponents {
store: Store,
storeState: StoreState,
treeState: TreeState,
): void {
const dependentNodes = getDownstreamNodes(
store,
treeState,
treeState.dirtyAtoms,
)
for (const key of dependentNodes) {
const comps = storeState.nodeToComponentsubscriptions.get(key)
if (comps) {
for (const [_subID, [_debugName, callback]] of comps) {
callback(treeState)
}
}
}
}
notifyComponets는 store, 그리고 상태를 전파할 storeState를 인수로 받아 이 스토어를 사용하고 있는 하위 의존성을 모두 검색한 다음, 여기에 있는 컴포넌트들을 모두 확인해 콜백을 실행한다.
지금까지 알게 된 것은 다음과 같다.
Recoil의 핵심 개념인 atom을 살펴보자. atom은 상태를 나타내는 Recoil의 최소 상태 단위다.
다음과 같은 구조로 선언할 수 있다.
type Statement = {
name: string
amount: number
}
const InitialStatements: Array<Statement> = [
{ name: '과자', amount: -500 },
{ name: '용돈', amount: 1000 },
{ name: '네이버페이충전', amount: -5000 },
]
// Atom 선언
const statementsAtom = atom<Array<Statement>>({
key: 'statements',
default: InitialStatements,
})
atom은 key 값을 필수로 가지며, 이 키는 다른 atom과 구별하는 식별자가 되는 필수 값이다.
이 키는 app 내부에서 유일한 값이어야 한다. default는 atom의 초기값을 나타낸다.
atom의 값을 읽어호는 훅이다. atom 값을 가져올 수 있다.
function Statements() {
const statemnens = useRecoilValue(statementsAtom)
return (
<>{/* something */}</>
// ...
)
}
useRecoilValue 훅 내부를 살펴보자.
//useRecoilValue
function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
if (__DEV__) {
validateRecoilValue(recoilValue, 'useRecoilValue')
}
const storeRef = useStoreRef()
const loadable = useRecoilValueLoadable(recoilValue)
return handleLoadable(loadable, recoilValue, storeRef)
}
// ...
// useRecoilValueLoadable
function useRecoilValueLoadable_LEGACY<T>(
recoilValue: RecoilValue<T>,
): Loadable<T> {
const storeRef = useStoreRef()
const [, forceUpdate] = useState([])
const componentName = usecomponentName()
const getLoadable = useCallback(() => {
if (__DEV__) {
recoilComponetGetRecoilValueCount_FOR_TESTING.current++
}
const store = storeRef.current
const storeState = store.getState()
const treeState = reactMode().early
? storeState.nextTree ?? storeState.currentTree
: storeState.currentTree
return getRecoilvalueAsLoadable(store, recoilValue treeState)
}, [storeref, recoilValue])
const lodable = getLoadable()
const prevLoadableRef = useRef(loadable)
useEffect(() => {
prevLoadableRef.current = loadable
})
useEffect(() => {
const store = storeRef.current
const storeState = store.getState()
//현재 recoil의 값을 구독하는 함수다.
const subscription = subscribeToRecoilValue(
store,
recoilValue,
(_state) => {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([])
}
const newLoadable = getLoadable()
// is는 두 객체가 같은지 비교하고, 다르다면 렌더링을 유도한다.
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable)
}
prevLoadableRef.current = newLoadable
},
componentName,
)
if (storeState.nextTree) {
store.getState().queuedComp0onentCallbacks_DEPRECATED.push(() => {
prevLoadableRef.current = null
forceUpdate([])
})
} else {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([])
}
const newLoadable = getLoadable()
// 값을 비교해서 값이 다르다면 forceUpdate를 실행
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable)
}
prevLoadableRef.current = newLoadable
}
// 클린업 함수에 subscribe를 해제하는 함수를 반환한다
return subscription.release
}, [componentName, getLoadable, recoilValue, storeRef]
return loadable
}
useRecoilValue와 useRecoilValueLoadable 코드다. 코드를 직관적으로 이해할 수 있게 useRecoilValueLoadable_LEGACY를 가져왔다.
먼저 getLoadable은 현재 Recoil이 가지고 있는 상태값을 가지고 있는 클래스인 loadable을 반환하는 함수. 렌더링이 필요하지 않으면 ref에 매번 저장.
useEffect를 통해 recoilValue가 변경됐을 때 forceUpdate를 호출해 렌더링을 강제로 일으킨다.
useRecoilValue는 단순히 atom 값을 가져오기 위한 훅이엇다면 useRecoilstate는 좀 더 useSatte와 유사하게 값을 가져오고, 또 이 값을 변경할 수도 있는 훅이다. useRecoilState를 살펴보자.
// useRecoilState
function useRecoilState<T>(
recoilState: RecoilState<T>,
): [T, SetterOrUpdater<T>] {
if (__DEV__) {
validateRecoilValue(recoilState, 'useRecoilState')
}
return [useRecoilValue(recoilState), useSetRecoilState(recoilState)]
}
먼저 useRecoilState는 useState와 매우 유사한 구조. 이 훅은 내부에서 먼저 스토어를 가져온 다음에 setRecoilValue를 호출해 값을 업데이트.
const counterState = atom({
key: 'cunterState',
default: 0,
})
function Counter() {
const [, setCount] = useRecoilState(counterState)
function handleButtonClick)() {
setCount((count) => count + 1)
}
return (
<>
<button onClick={handleButtonClick}>+</button>
</>
)
}
// atom을 기반으로 또 다른 상태를 만들 수 있다.
const isBiggerThan10 = selector({
key: 'above10State',
get: ({ get }) => {
return get(counterState) >= 10
},
})
function count() {
const count = useRecoilValue(counterState)
const biggerThan10 = useRecoilValue(isBiggerThan10)
return (
<>
<h3>{count}</h3>
<p>count is bigger than 10: {JSON.stringfy(biggerThan10)}</p>
</>
)
}
export default function App() {
return (
<RecoilRoot>
<Counter />
<Count />
</RecoilRoot>
)
}
Recoil은 메타 팀에서 주도적으로 개발하고 있기 때문에 앞으로도 리액트에서 새롭게 만들어지는 기능을 더 잘 지원할 것으로 기대된다.
또한 redux와 달리 redux-saga나 redux-thunk 등 추가적인 미들웨어를 사용하지 않더라도 비동기 작업을 수월하게 처리할 수 있다.
Recoil의 불확실한 점은 정식 버전인 1.0.0의 출시 시점이다.