useState와 비슷한 훅인 useReducer는 지역상태를 관리할 수 있는 훅이다.
useState는 useReducer로 구현되었는데, 이를 실제 코드로 작성하면 다음과 같다.
type Initializer<T> = T extends any ? T | ((prev : T) => T) : never;
function useStateWithUseRecducer <T>(initialState: T) {
const [state, dispatch] = useReducer(
(prev: T, action: Initializer<T>) =>
typeof action === 'function' ? action(prev) : action,
initialState
)
return [state, dispatch] as const;
}
useReducer의 첫 번째 인수로는 reducer, 즉 state와 action을 어떻게 정의할지를 넘겨줘야 하는데 useState와 동일한 작동, 즉 T를 받거나 (prev : T) ⇒ T를 받아 새로운 값을 설정할 수 있게끔 코드를 작성했다.
그러나 두가지 모두 상태 관리의 모든 필요성과 문제를 해결해 주지 않는다. 지역 상태라는 한계 때문에 여러 컴포넌트에 걸쳐 공유하기 위해서는 컴포넌트 트리를 재설계하는 등의 수고로움이 필요하다.
새로운 상태를 사용자의 UI에 보여주기 위해서는 반드시 리렌더링이 필요하다. 함수 컴포넌트에서 리렌더링을 하려면 다음과 같은 작업 중 하나가 일어나야 한다.
더나아가 렌더링까지 자연스럽게 일어나려면 다음과 같은 조건을 만족해야 한다.
이 3가지를 만족시킬 수 있는 것이 store이다.
type Store<State> = {
get: () => State
set: (action: Initializer<State>) => State
subscribe: (callback: () => void) => () => void
}
export const createStore = <State extends unknown>(
initialState: Initialiizer<State>
): Store<State> => {
let state =
typeof initialState !== "function" ? initialState : initialState();
const callbacks = new Set<() => void>();
const get = () => state;
const set = (newState: State) => {
state =
typeof newState === "function"
? (newState as (prev: State) => State)(state)
: newState;
callbacks.forEach((callback) => callback());
return state;
};
const subscribe = (callback: () => void) => {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
};
return {
get,
set,
subscribe,
};
}
이는 store를 만드는 createStore를 구현한 코드다.
자신이 관리해야 하는 상태를 내부 변수로 가져온 다음, get 함수로 해당 변수의 최신값을 제공하며, set 함수로 내부 변수를 최신화하며, 이 과정에서 등록된 콜백을 모조리 실행하는 구조이다. 해당 store의 값을 참조하고 이 값의 변화에 따라 컴포넌트 렌더링을 유도할 사용자 정의 훅도 필요하다.
export const useStore = <State extends unknown>(store: Store<State>) => {
const [state, setState] = useState<State>(() => store.get());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.get());
});
return unsubscribe;
}, [store]);
return [state, store.set] as const;
}
지금은 값이 바뀌면 무조건 리렌더링이 일어난다. 여기서 한 발 더 나가면, 원하는 값이 바뀌었을 때만 리렌더링 되도록 훅을 재구성할 수 있다. 변경감지가 필요한 값만 setState를 호출해 객체 상태에 대한 불필요한 리렌더링을 막는 것이다.
export const useStoreSelector = <State extends unknown, Value extends unknown>(
store: Store<State>,
selector: (state: State) => Value
) => {
const [state, setState] = useState<State>(() => selector(store.get()));
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const value = selector(store.get());
setState(value);
});
return unsubscribe;
}, [store, selector]);
return state;
};
useStoreSelector처럼 외부에서 관리되는 값에 대한 변경을 추적하고, 이를 리렌더링까지 할 수 있는 훅은 사실 존재한다. useSubscription을 사용하면 외부에 있는 데이터를 가져와 사용하고 리렌더링까지 정상적으로 수행이 가능하다.
useState훅과 useStoreSelector와 같이 스토어를 사용하는 구조는 반드시 하나의 스토어만 가지게 된다. 하나의 스토어를 가지면 이 스토어는 마치 전역 변수처럼 작동하게 되어 동일한 형태의 여러 개의 스토어를 가질 수 없게 된다.
해결 방법 1. createStore 사용
const store1 = createStore({ count : 0 })
const store2 = createStore({ count : 0 })
const store3 = createStore({ count : 0 })
그러나 이 방법은 스토어가 필요할 때마다 반복적으로 스토어를 생성하기 때문에 좋은 방법이 아니다.
해결방법 2. Context와 Store 사용
export const CounterStoreContext = createContext<Store<CounterStore>>(
createStore<CounterStore>({ count:0, text: 'Hello' })
);
export const CounterStoreProvider = ({ initialState,children } : PropsWithChildren<{initialState : CounterStore}>) => {
const storeRef = useRef<Store<CounterStore>>()
if (!storeRef.current) {
storeRef.current = createStore(initialState)
}
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
);
CounterStoreContext를 통해 먼저 어떠한 Context를 만들지 타입과 함께 정의해 뒀다.
storeRef를 사용하는 이유는 Provider가 넘기는 props가 불필요하게 변경돼서 리렌더링되는 것을 막기 위해서이다. useRef를 사용했기 때문에 CounterStoreProvider는 최초 렌더링에서만 스토어를 만들어 값을 전달하게 된다.
export const useCounterContextSelector = <State extends unknown>(
selector: (state: CounterStore) => State
) => {
const store = useContext(CounterStoreContext);
const subscription = useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.get()),
subscribe: store.subscribe,
}),
[store, selector]
)
);
return [subscription, store] as const;
};
useSubscription을 사용해 불필요한 반복을 제거하고 useContext를 사용해 스토어에 접근했다.
이렇게 Context와 Provider를 기반으로 각 store 값을 격리해서 관리하면, 스토어를 사용하는 컴포넌트는 해당 상태가 어느 스토어에서 온 상태인지 신경 쓰지 않아도 된다. Context와 Provider를 관리하는 부모 컴포넌트의 입장에서 자신이 자식 컴포넌트에 따라 보여주고 싶은 데이터를 Context로 잘 격리하기만 하면 된다.
페이스북이 만든 상태 관리 라이브러리 Recoil
Recoil의 핵심 API인 RecoilRoot, atom, useRecoilValue, useRecoilState를 살펴보고 Recoil에서는 상태 값을 어디에 어떻게 저장하는 지 알아보자
아직 정식 버전이 출시되지 않았기 때문에 추가적인 주의가 필요하다!
Recoil에서 영감을 받은, 그러나 조금 더 유연한 Jotai
리덕스와 같이 하나의 큰 상태를 애플리케이션에서 내려주는 방식이 아니라, 작은 단위의 상태를 위로 전파할 수 있는 구조를 취하고 있다.
리액트 Context의 문제점인 불필요한 리렌더링이 일어난다는 문제를 해결하고자 설계돼 있으며, 추가적인 최적화를 거치지 않아도 리렌더링이 발생되지 않도록 설계돼 있다.
atom
a. 최소 단위의 상태를 의미한다. 파생된 상태까지 만들 수 있다는 점이 장점이다.
b. 고유한 key를 필요로 했던 Recoil과는 다르게 별도의 key를 내려주지 않아도 된다.
const counterAtom = atom(0);
console.log(counterAtom);
//
// {
// init: 0,
// read: (get) => get(config),
// write: (get, set, update) =>
// set(config, typeof update === 'function' ? update(get(cnofig)) : update)
// }
useAtomValue
a. 상태를 저장해두는 공간이다.
b. useReducer을 사용하여 [version, valueFromReducer, atomFromReducer] 이 세가지 상태를 반환한다.
c. atom 값은 store에 존재한다. atom 객체 그 자체를 키로 활용해 값을 저장한다. WeakMap을 활용하여 자바스크립트에서 객체만을 키로 가질 수 있는 독특한 방식의 Map을 활용한다.
d. rerenderIfChanged
useAtom
a. useState와 동일한 형태의 배열을 반환한다.
b. setAtom
작고 빠르며 확장에도 유연한 Zustand
Zustand는 리덕스에 영감을 받아 만들어졌다. atom이라는 개념으로 최소 단위의 상태를 관리하는 것이 아니라 하나의 스토어를 중앙 집중형으로 활용해 이 스토어 내부에서 상태를 관리한다.
Zustand의 바닐라 코드
a. state의 값을 useState 외부에서 관리한다.
b. setState: partial과 replace를 활용해 state의 값이 객체일 때 필요에 따라 나눠서 사용이 가능하다.
c. getState : 클로저의 최신 값을 가져오는 함수이다.
d. subscribe : listener를 등록한다. 상태 값이 변경될 때 리렌더링이 필요한 컴포넌트에 전파될 목적으로 사용된다.
e. destroy : listener를 초기화한다.
Zustand의 리액트 코드
a. useStore : useSyncExternalStoreWithSeletor를 사용해서 useStore의 subscribe, getState를 넘겨주고, 스토어에서 선택을 원하는 state를 고르는 함수인 selector를 넘겨주고 끝난다.
b. useSyncExternalStore : 리액트 18에서 새롭게 만들어진 훅으로, 리액트 외부에서 관리되는 상태값을 리액트에서 사용할 수 있도록 도와준다.
간단한 사용법
import {create} from 'zustand'
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({count: state.count + 1})),
decrement: () => set((state) => ({count: state.count - 1})),
}))
function Counter() {
const {count, increment, decrement} = useCounterStore()
return (
<div class="counter">
<span>{count}</span>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
)
}
특별히 많은 코드를 작성하지 않아도 빠르게 스토어를 만들고 사용할 수 있다는 큰 장점이 있다.