준영
도은
5.1 상태 관리는 왜 필요한가?
🤔 '상태'는 무엇일까?
- 어떠한 의미를 지닌 값
- 애플리케이션에서 지속적으로 변경될 수 있는 값
- 웹에서 상태로 분류될 수 있는 것들은
- UI: 상호 작용이 가능한 모든 요소의 현재 값을 의미
- URL: 브라우저에서 관리되고 있는 상태
- form: loading중인지? submit되었는지? disabled인지? 모두 상태로 관리
- 서버에서 가져온 값: 클라이언트에서 서버로 요청을 통해 가져온 값도 상태로 볼 수 있다.
🫢 웹 서비스에서 점차 다양한 기능이 제공됨에 따라
- 웹 내부에서 관리해야 할 상태도 점차 비례해서 증가
- 상태를 효과적으로 관리하는 방법을 계속 고민해야 한다.
5.1.1 리액트 상태 관리의 역사
😳 Flux 패턴의 등장
-
단방향으로 데이터를 변경하는 것
-
간단하게 리액트 코드로 보면 다음과 같다.
type StoreState = { count: number }; type Action = { type: 'add'; payload: number }; const reducer = (prevState: StoreState, action: Action) => { const { type } = action; if (type === 'add') { return { count: prevState.count + action.payload, }; } }; export const App = () => { const [state, dispatcher] = useReducer(reducer, { count: 0 }); const handleClick = () => { dispatcher({ type: 'add', payload: 1 }); }; return ... };
😌 시장 지배자 리덕스의 등장
- Flux 구조 + Elm 아키텍처 도입
- Elm은 웹페이지를 선언적으로 작성하기 위한 언어
- Elm은 Flux와 마찬가지로 데이터 흐름을 3가지로 분류, 이를 단방향으로 강제하여 상태를 안정적으로 관리하고자 노력
- 리덕스는 하나의 상태 객체를 스토어에 저장 → 객체를 업데이트하는 작업을 디스패치 → 상태 업데이트
- 리덕스의 등장은, props drilling 문제를 해결할 수 있었다.
- 하지만, 하나의 상태를 바꾸고 싶어도 해야 할 일이 너무 많았다.
- 어떠한 액션인지 타입을 선언해야 하고
- 이 액션을 수행할 creator 함수를 만들어야 한다.
- dispatcher와 selector도 필요하고
- 새로운 상태가 어떻게 기존의 리듀서 내부에서 어떤 식으로 변경되어야 할지
- 혹은 새로 만들어야 할지도 새로 정의해야 했다.
- 🤢 보일러 플레이트가 너무 많다는 비판..
🥳 Context API와 useContext
- 리액트 팀은 리액트
16.3
에서 전역 상태를 하위 컴포넌트에 주입할 수 있는 새로운Context API
를 출시 - props로 상태를 넘겨주지 않더라도
Context API
를 사용하면 원하는 곳에서 사용 가능 - 🧐
16.3
버전 이전에도 context가 존재했으며,getChildContext()
를 제공했었다.
😎 훅의 탄생, 그리고 React Query와 SWR
- 두 라이브러리 모두 외부에서 데이터를 불러오는 fetch를 관리하는 데 특화된 라이브러리
- API 호출에 대한 상태를 관리
- 서버 상태 관리 라이브러리로, 상태를 관리하는 라이브러리의 일종
😇 Recoil, Zustand, Jotai, Valtio에 이르기까지
- 작은 크기의 상태를 효율적으로 관리
- 위 라이브러리르들은 peerDependencies로 리액트 16.3 버전 이상을 요구 (→ 훅을 사용하기 때문)
- 개발자가 원하는 만큼의 상태를 전역적으로 관리하는 것을 가능하도록
5.2 리액트 훅으로 시작하는 상태 관리
- 비교적 오랜 기간 리액트 생태계에서는 리덕스에 의존
- 그러나 현재는
Context API
,useReducer
,useState
의 등장으로 재사용하거나 내부에 걸쳐서 상태를 관리할 수 있는 방법들이 점차 등장 - 리액트
16.8
에서 등장한 훅과 함수 컴포넌트의 패러다임에서- 내부 상태 관리는 어떻게 할 수 있고
- 이러한 새로운 방법을 채택한 라이브러리는 무엇이 있고 어떻게 동작하는지 알아보자
5.2.1 가장 기본적인 방법: useState와 useReducer
- useState를 통해, 여러 컴포넌트에 걸쳐 동일한 인터페이스의 상태를 생성하고 관리할 수 있게 되었다.
const useCounter = (initCount = 0) => {
const [counter, setCounter] = useState(initCount);
return {
inc: () => setCounter((prev) => prev + 1),
counter,
};
};
- 훅으로 코드를 격리해 제공할 수도 있다.
- 이처럼, 리액트의 훅을 기반으로 만든 사용자 정의 훅은 함수 컴포넌트라면 어디서든 손쉽게 재사용 가능!!
- 재사용할 수 있는 지역 상태를 만들어 주지만, 지역 상태라는 한계 → 여러 컴포넌트에 걸쳐 공유하기 위해서는 컴포넌트 트리 재설계 필요
5.2.2 지역 상태의 한계를 벗어나보자: useState의 상태를 바깥으로 분리하기
🙀 useState의 한계는 명확하다
- 리액트가 만든 클로저 내부에서 관리 → 지역 상태로 생성 → 해당 컴포넌트에서만 사용 가능
- 🤔 그 상태를 참조하는 유효한 스코프 내부에서는 해당 값을 공유해서 사용할 수도 있지 않을까?
아래와 같은 코드는 리액트에서 동작할까?
// 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 const set = <T>(nextState: Initializer<T>) => {
state = typeof nextState === 'function' ? nextState(state) : nextState
}
// Counter
const Counter() {
const state = get()
const handleClick = () => {
set(prev: State => ({counter: prev.counter + 1}))
}
return ...
}
- 위 방식은 리액트 환경에서 작동 X
- 🙀 가장 큰 문제는, 리렌더링 되지 않는다는 것
- 업데이트되는 값을 가져오려면 상태 업데이트 + 리렌더링 필요
useState
의 두 번째 인수로 업데이트하는 것으로 리렌더링 발생
- 외부에서 상태를 참조 & 렌더링까지 자연스럽게 일어나기 위해서는
- 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다.
- 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아챌 수 있어야 한다. 상태 변화 시마다 리렌더링 → 최신 상태값 기준으로 렌더링
- 원시값이 아닌 객체인 경우, 내가 감지하지 않는 값이 변한다 하더라도 리렌더링 발생 X
🫢 컴포넌트 레벨의 지역 상태를 벗어나느 새로운 상태 관리 코드를 만들어보자
type Initializer<T> = T extends any ? T | (prev: T => T):never
type Store<State> = {
get: () => State // 최신값을 가져오는 함수
set: (action: Initializer<State>) => State // useState와 동일하게 값 또는 함수를 받을 수 있도록
subscribe: (callback: () => void) => () => void // store의 변경을 감지하고 싶은 컴포넌트들이 자신의 callback을 등록해두는 곳
}
/**
* 이 스토어를 참조하는 컴포넌트는 subscribe에 컴포넌트 자기 자신을 렌더링하는 코드를 추가해서
* 컴포넌트가 리렌더링을 실행할 수 있게 만들 것
*/
export const createStore = <State extends unknown>(initialState: Initializer<State>) => {
// state는 스토어 내부에서 보관해야 하므로 변수로 선언
let state = typeof initialState !== 'function' ? initialState : initialState();
// callbacks는 자료형에 관계없이 유일한 값을 저장할 수 있는 Set을 사용
const callbacks = new Set<() => void>();
// 언제든 get이 호출되면 최신값을 가져올 수 있도록 함수로 만든다.
const get = () => state
const set = (nextState: State | (prev: State => State)) => {
state = typeof nextState === 'function' ? (nextState as(prev: State => State))(state) : nextState
// 값의 설정이 발생하면 콜백 목록ㅇ르 순회하면서 모든 콜백을 실행한다.
callbacks.forEach(callback => callback())
return state
}
const subscribe = (callback: () => void) => {
// 받은 함수를 콜백 목록에 추가
callbacks.add(callback)
// 클린업 실행 시 이를 삭제해서 반복적으로 추가되는 것을 막는다.
return () => {
callbacks.delete(callback)
}
}
return { get, set, subscribe }
};
- createStore로 만들어진 store의 값을 참조하고
- 이 값의 변화에 따라 컴포넌트를 렌더링을 유도할 사용자 정의 훅이 필요
export const useStore = <State extends unknown>(store: Store<State>) => {
const [state, setState] = useState<State>(() => store.get()); // 컴포넌트의 렌더링을 유도하기 위해 useState 사용
useEffect(() => {
const unsubscribe = store.subscribe(() => {
// store의 값이 변경될 때 setState할 수 있도록
setState(store.get());
});
return unsubscribe;
}, [store]);
return [state, store.set] as const;
};
- 위
useStore
의 경우, 객체인 경우 프로퍼티 일부가 변경될 경우 모든 컴포넌트가 렌더링될 것
export const useStoreSelector = <State extends unknown, Value extends unknown>(
store: Store<State>,
selector: (state: State) => Value,
) => {
const [state, setState] = useState(() => selector(store.get()));
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const value = selector(store.get());
setState(value);
});
return unsubscribe;
}, [store, selector]);
return state;
};
- 객체로 구성되어 있다 하더라도, 컴포넌트에서 필요한 값만 그대로 select해서 사용 + 업데이트 수행
- 🚨 주의할 점은 selector는 함수 외부 혹은 useCallback으로 참조를 고정해야 한다.
5.2.3 useState와 Context를 동시에 사용해 보기
- 위처럼 구현하는 것은, 반드시 하나의 스토어만 가지게 되는 것
- 마치 전역 변수처럼 작동
🤔 서로 다른 스코프에서 스토어의 구조는 동일하되, 여러 개의 서로 다른 데이터를 공유하고 싶다면?
// Context를 생성하면, 자동으로 스토어도 함께 생성한다.
export const CounterStoreContext = createContext<Store<CounterStore>>(
createStore<CounterStore>({ count: 0, text: 'hello' }),
);
type CounterStoreProviderProps = PropsWithChildren<{ initialState: CounterStore }>;
export const CounterStoreProvider = ({ initialState, children }: CounterStoreProviderProps) => {
const storeRef = useRef<Store<CounterStore>>();
// 스토어를 생성한 적이 없다면 최초에 한 번 생성한다.
if (!storeRef.current) {
storeRef.current = createStore(initialState);
}
return <CounterStoreContext.Provider value={storeRef.current}>{children}</CounterStoreContext.Provider>;
};
- Context에서 제공하는 스토어에 접근할 수 있는 새로운 훅도 필요하다
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.set] as const;
};
5.2.4 상태 관리 라이브러리 Recoil, Jotai, Zustand 살펴보기
- Recoil, Jotai: Context, Provider 그리고 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 것에 초점
- Zustand: 하나의 큰 스토어를 기반으로 상태를 관리
1️⃣ Recoil
- 최상단에
RecoilRoot
를 감싸줘야 한다.- 생성되는 상태값을 저장하기 위한 스토어를 생성하는 역할
const RecoilRoot = (props: Props): React.Node => {
const { override, ...restProps } = props;
const ancestorStoreRef = useStoreRef(); // 상태값을 저장하는 스토어
if (override === flase && ancestorStoreRef.current !== defaultStore) {
return props.children;
}
return <RecoilRoot_INTERNAL {...restProps} />;
};
// useStoreRef
const AppContext = React.createContext<StoreRef>({ current: defaultStore });
const useStoreRef = (): StoreRef => useContext(AppContext);
- RecoilRoot의 구조를 대락 파악해보면
- Recoil의 상태값은 RecoilRoot로 생성된 Context의 스토어에 저장
- 스토어의 상태값에 접근할 수 있는 함수 존재, 이 함수를 통해 접근 또는 업데이트 가능
- 값의 변경이 발생하면, 이를 참조하고 있는 하위 컴포넌트에 모두 알린다.
2️⃣ Jotai
Recoil
의atom
모델에 영감을 받아 만들어진 상태 관리 라이브러리Context
의 불필요한 리렌더링 문제를 해결하고자 설계jotai
의atom
구현을 살펴보자
export function atom<Value, Update, Result extends void | Promise<void>>(
read: Value | Read<Value>,
write?: Write<Update, Result>,
) {
const key = `atom${++keyCount}`;
const config = {
toString: () => key,
} as WriableAtom<Value, Update, Result> & { init?: Value };
if (typeof read === 'function') {
config.read = read as Read<Value>;
} else {
config.init = read;
config.read = (get) => get(config);
config.write = (get, set, update) => set(config, typeof update === 'function' ? update(get(config)) : update);
}
if (write) {
config.write = write;
}
return config;
}
- Recoil과 다르게 루트 레벨에서 Context가 존재X
- 기본 스토어를 루트에 생성하고 이를 활용해 값을 저장
- ⭐️ Provider 별로 다른 atom 값을 관리 가능
- selector 없이 atom만으로 또 다른 파생된 상태 만드릭 가능
- Recoil에 비해 간결!
3️⃣ Zustand
Zustand
는 리덕스에 영감을 받아 제작- 하나의 스토어를 중앙 집중형으로 활용 → 이 스토어 내부에서 상태 관리
- state를 useState 외부에서 관리
partial
은 일부분만 변경하고 싶을 때,replace
는 state를 완전히 새로운 값으로 변경하고 싶을 때- 여기까지는 react와는 별개로 값을 관리하는 것이였고
useStore
를 통해 리렌더링을 수행