준영
리액트 컨텍스트와 구독을 이용한 컴포넌트 상태 공유
- 컨텍스트는 하위 트리에 전역 상태를 제공할 수 있고, 컨텍스트 공급자를 중첩하는 것이 가능하다. 컨텍스트를 사용하면 리액트 컴포넌트 생명 주기 내에서 useState와 같은 훅으로 전역 상태를 제어할 수 있다.
- 반면 구독을 사용하면 단일 컨텍스트로 불가능한 리렌더링 문제를 해결할 수 있다.
모듈 상태의 한계
4장에서 사용했던 useStore
방식은 같은 데이터지만 값이 달라야하는 경우 컴포넌트를 위해 새로운 store
를 정의해야하기 때문에 코드 재사용성이 떨어진다.
컴포넌트는 재사용 가능해야하지만 모듈 상태는 리액트 외부에 정의되기 때문에 불가능하다는 것이 모듈 상태의 한계이다.
물론 컴포넌트의 props
로 store
를 받으면 재사용이 가능하지만, 이는 props drilling
이 발생할 수 있다.
모듈 상태를 사용하는 이유중 하나가
props drilling
을 피하기 위함인 것을 잊지 말자!
따라서 재사용성을 높이기 위한 방법 중 하나가 컨텍스트의 사용이다.
컨텍스트 사용이 필요한 시점
컨텍스트에 적절한 기본값을 설정하는 것은 중요하다.
컨텍스트 공급자는 기본 컨텍스트 값 혹은 부모 공급자가 제공하는 값이 있는 경우, 이 값을 재정의하는 메서드라고 볼 수 있다.
적절한 기본값이 있다면 공급자를 사용할 필요가 없다.
하지만 전체 컴포넌트 트리의 하위 트리에 다른 값을 제공할 필요가 있다면 공급자로 감싸줘야 한다.
✅ Recoil
이나 Jotai
, React Query
와 같은 친구들은 Root에서 컴포넌트 트리 전체를 프로바이더로 감싸준다. 사실 지역적으로 사용해도 되긴 하지만, 편의성과 개발자들이 충분히 최적화를 해주었기 때문에 이렇게 하는 것이 아닌가싶다.
앞에 4장에서도 봤듯이 선택자와 같은 개념을 이용하여 특정 부분을 구독하고 구독한 컴포넌트들만 리렌더링시켜준다면 이것만으로도 충분히 불필요한 리렌더링을 줄일 수 있다고 생각하기 때문에 대부분의 상태 관리 라이브러리들이 이런 식으로 구현되어있지 않을까 생각한다.
그럼 프로바이더를 사용하지 않는 zustand
는 어떻게 구현되어있는걸까?!
(뒤에 나오더라… To be continue….☠️ )
컨텍스트와 구독 패턴 사용하기
컨텍스트로 전역 상태 값을 전파하면 해당 상태 값을 사용하고 있는 모든 컴포넌트에서 리렌더링이 발생하고, 하위 컴포넌트들까지 리렌더링되어 불필요한 렌더링이 발생하는 문제가 있다.
하지만 앞에서 알아보았던 구독을 통한 모듈 상태는 이러한 리렌더링 문제는 없지만, 컴포넌트 트리에 대해 하나의 값만을 제공할 수 있는 문제가 있다.
따라서 이 둘을 합쳐 해결할 수 있다.
이전에 4장에서 사용한 createStore
이다.
const createStore = <T extends unknown>(
initialState: T
): Store<T> => {
let state = initialState;
const callbacks = new Set<() => void>();
const getState = () => state;
const setState = (nextState: T | ((prev: T) => T) => {
state =
typeof nextState === "function"
? (nextState as (prev: T) => T)(state)
: nextState;
callbacks.forEach((callback) => callback());
};
const subscribe = (callback: () => void) => {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
};
return { getState, setState, subscribe };
};
추가적으로 이제는 store를 컨텍스트에 저장해보자.
const StoreContext = createContext<Store<State>>(createStore<State>({ count: 0, text: 'hello' }));
그리고 하위트리에 서로 다른 Store를 사용하기 위한 공급자 컴포넌트는 다음과 같다.
const StoreProvider = ({ initialState, children }: { initialState: State; children: ReactNode }) => {
const storeRef = useRef<Store<State>>();
if (!storeRef.current) {
storeRef.current = createStore(initialState);
}
return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
};
위에서 사용된 useRef
는 Store 객체가 첫 렌더링에서 한번만 초기화될 수 있도록 하는 장치이다.
✅ useRef
는 비제어 컴포넌트에 사용되는 훅으로 heap 영역에 저장되는 일반 자바스크립트 객체이다.
자바스크립트의 객체는 immutable
하기 때문에 값이 변하더라도 메모리 주소는 변하지 않는다.
따라서 매번 렌더링시 동일한 객체를 제공하기 때문에 변경을 감지할 수 없어 리렌더링이 일어나지 않는다.
이후 4장과 마찬가지로 useSelector
훅을 구현한다.
const useSelector = <S extends unknown>(
selector: (state: State) => S)
) => {
const store = useContext(StoreContext); // 컨텍스트에서 Store를 불러와 파라미터로 받을 필요없음!
return useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.getState()),
subscribe: store.subscribe,
}),
[store, selector],
),
);
};
이 훅을 통해 해당 컴포넌트에서 컨텍스트 공급자가 제공하는 Store의 어떤 속성을 상태로 사용할지 명시하고 구독한다.
✅ 따라서 이를 통해 앞서 언급한 문제들을 해결할 수 있다.
- 컨텍스트 공급자로 서브 트리를 묶어 해당 부분에 독립적인 Store 값을 사용할 수 있다.
- 컨텍스트 공급자가 제공하는 Store의 속성 중, selector로 지정한 상태를 구독하는 컴포넌트들만 리렌더링이 일어나 불필요한 리렌더링을 줄일 수 있다.
✅ 위 내용과 크게 상관은 없지만, 사실 맨처음에는 ‘그냥 전역으로 공유하면 쉬우니까’ 라는 생각으로 전역 상태 관리 라이브러리를 사용했다.
하지만 토스뱅크 리드님과 커피챗을 하면서 리드님께서 하고 계신 고민에 대해 다음과 같이 말씀해주셨다.
- 어떻게 하면 컴포넌트를 최대한 수평적으로 만들 수 있을까?
- 어떻게 하면 컴포넌트를 재사용할 수 있을까?
나 또한 이후 이를 의식하다보니 props drilling
이 DX를 해치는 것을 느꼈고, 물론 이거 하나 때문에는 아니지만 그래도 이러한 불편함이 있기 때문에 리드님께서 그런 고민을 하셨다고 생각했다.
그 이후로는 상태 관리 라이브러리를 도입할 때는 props drilling
을 가장 많이 고려하여 사용했고, 최대한 전역 상태 관리를 하지 않고 수평적으로 컴포넌트를 만들려고 노력했던 것 같다.
하지만 정말 쉽지 않다는 것을 느꼈던게 디자인에 따라 아무리 내가 노력을 하더라도 포기할 수 밖에 없는 상황이 존재했고, 포기하지 않으면 하나의 컴포넌트가 너무 커져 가독성이 저하되고 관리가 어려워지는 tradeoff가 존재하였다.
디프만에서 반디부디를 진행하면서는 사실 이 부분을 크게 고민하지 않았던 것 같다.
기획 이후 정신없이 개발이 진행되기도 했고, 디자인을 반영하기 바쁘다는 핑계와 처음 사용해보는 패턴, 기술을 공부한다는 핑계로 이 부분을 간과했던 것 같다.
그러다보니 재사용성도 떨어지고 무지성으로 개발을 했던 것 같다.
이 책을 읽으면서 프론트엔드 개발자들이 좋은 코드를 만들어가기 위해 정말 많은 노력을 했다는 것이 느껴져 본인 스스로 반성하게 되었다.
앞으로 다시 프로젝트를 하면 이 부분을 꼭 다시 한번 상기시키면서 개발을 해야겠다.
아직까지도 좋은 컴포넌트가 무엇인가라는 질문에는 항상 대답하기가 너무 어렵다…
도은
- 컨텐스트는 하위 트리에 전역 상태를 제공할 수 있고 컨텍스트 Provider를 중첩하는 것이 가능하다. 컨텍스트를 사용하면 리액트 생명 주기 내에서 useState와 같은 훅으로 전역 상태를 제어할 수 있다.
- 반면 구독을 사용하면 단일 컨텍스트로는 불가능한 리렌더링 문제를 해결할 수 있다.
모듈 상태의 한계
- 모듈 상태는 리액트 컴포넌트 외부에 존재
- 때문에 컴포넌트 트리나 하위 트리마다 다른 상태를 가질 수 없다는 한계
🤔 Context를 생각해본다면?
Context
의 경우Provider
를 통해 그 하위 컴포넌트들끼리만 공유하는 것이 가능- 모듈 상태의 경우는
- 컴포넌트 트리와는 무관한 아이이므로.. 트리마다 관리하는 것이 불가능
const Counter = () => {
const [state, setState] = useStore(store);
...
};
Context와 구독 패턴 사용하기
- Context를 사용해 전역 상태 값을 전파 → 불필요한 리렌더링
- 구독을 이용한 모듈 상태 → 전체 컴포넌트 트리에 대해 하나의 값만 제공
이 둘을 함께 사용한다면 두 가지 문제점을 극복할 수 있다.
모듈 상태를 만들 때 사용한 createStore
const createStore = (initialState) => {
let state = initalState;
const callbacks = new Set();
const getState = () => state;
const setState = (nextState) => {
state = typeof nextState === 'function' ? nextState(state) : nextState;
};
const subscribe = (callback) => {
callbacks.add(callback);
return () => {
callbacks.delte(callback);
};
};
return { getState, setState, subscribe };
};
Context를 생성하는 createContext
로, 기본값을 createStore
로 전달한다.
const StoreContext = createContext(createStore({ count: 0, text: 'hello' }));
스토어 객체를 사용하기 위해 useSelector
라는 훅을 구현한다.
const useSelector = (selector) => {
const store = useContext(StoreContext);
return useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.getState()),
subscribe: store.subscribe,
}),
[store, selector],
),
);
};
🤔 useSelector
useContext
를 사용하여 스토어 값을 컴포넌트 트리 내부에서 사용할 수 있도록 제공- 그렇지만,
selector
를 이용하여 필요한 부분만 사용 및 업데이트할 수 있도록
다만, 모듈 상태와 다르게 Context를 사용해 상태를 갱신하는 방법을 제공할 필요가 있다.
const useSetState = () => {
const store = useContext(StoreContext);
return store.setState;
};
🤔 팩토리 패턴로 싸매보자~
export const createSelectContext = (contextName, defaultValues) => {
const Context = createContext(defaultValues);
const Provider = ({ children, ...contextValues }) => {
const value = useMemo(() => contextValues, [...Object.values(contextValues)]);
return <Context.Provider value={value}>{children}</Context.Provider>;
};
const useSelector = (selector) => {
const context = useContext(Context);
if (context == null) {
throw new Error(`${contextName} must be used within ${contextName}.Provider`);
}
return useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.getState()),
subscribe: store.subscribe,
}),
[store, selector],
),
);
};
const useSetState = () => {
const store = useContext(StoreContext);
return store.setState;
};
return [Provider, useSelector, useSetState];
};
사용할 때는 다음과 같다.
export const ComponentParent = ({ count, text }) => {
const FooContext = createContext('FooContext');
export const [FooProvider, useFooContextSelector] = createSelectContext();
return (
<FooProvider count={count} text={text}>
{children}
</FooProvider>
);
};
export const ComponentChild = () => {
const textFromCtx = useFooContextSelector(useCallback((state) => state.text, [])); // text 외의 프로퍼티들하고는 무관해짐
return <div>{textFromCtx}</div>;
};