interface ChromeStorage {
reference: ReferenceData[];
autoConverting: boolean;
isDarkMode: boolean;
isContentScriptEnabled: boolean;
isUnAttachedReferenceVisible: boolean;
}
진행하던 사이드 프로젝트에서 최대한 라이브러리를 사용하지 않고 해보고 싶어서 위 타입의 상태를 React.Context
를 이용하여
전역 상태 관리를 시행하고 있었다.
const ChromeStorageContext = createContext<{
chromeStorage: ChromeStorage;
setChromeStorage: (
updater: (prevStorage: ChromeStorage) => ChromeStorage
) => Promise<void>;
} | null>(null);
export const ChromeStorageProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [chromeStorage, _syncChromeStorage] = useState<ChromeStorage>(
chromeStorageInitialValue
);
...
return (
<ChromeStorageContext.Provider
value={{
chromeStorage,
setChromeStorage,
}}
>
{children}
</ChromeStorageContext.Provider>
);
};
export const useChromeStorage = () => {
const context = useContext(ChromeStorageContext)!;
return context;
};
객체 형태의 상태를 Context
로 내려주는 것은 그다지 효율적인 방법은 아니다.
사실 React.Context
를 props drilling
없이 특정 value 값을 내려주는 역할만 할 뿐
효과적으로 상태를 관리하기 위해 만들어진 메소드가 아니기 때문이다.
객체 형태의 state 를 setState 를 통해 업데이트 하게 된다면 새로운 객체가 상태로 선언된다.
이는 리액트에서 state 는 불변하게 사용하기 때문인데 이러한 문제로
상태의 일부 값만 사용하는 컴포넌트는 본인이 사용하지 않는 값이 업데이트 되더라도
구독 하고 있는 상태(객체) 자체가 새롭게 만들어지기 때문에 리렌더링이 일어나게 된다.
export const AutoConvertingToggle = () => {
const {
chromeStorage: { autoConverting, isContentScriptEnabled },
setChromeStorage,
} = useChromeStorage();
...
위 컴포넌트는 실제 chromeStorage.autoConverting
이라는 boolean 원시 값만 이용하고 있지만
다른 컴포넌트에서 setChromeStorage
를 통해 chromeStorage
상태가 업데이트 된다면
chromeStorage.autoConverting
값 변환 유무에 상관 없이 chromeStorage
객체 자체가 새롭게 생성되어 리렌더링이 일어나게 된다.
사진 출처 : Escape React Context Hell. React’s Context API is a handy tool for… | by Ambrose kibet | Medium
만약 그렇게 사용하고 싶지 않다면 React.Context
를 통해 내려주는 값이
객체가 아닌 특정 값 하나만이여야 할 것이다.
그렇다면 본인이 구독하고 있는 상태 외의 것이 수정 되더라도 본인이 구독하고 있지 않기 때문에 리렌더링이 일어나지 않는다.
근데 이게 효율적인가 생각해본다면 그렇지 않을 것이다.
위 코드만 본다 해도 특정 상태 하나가 늘어날 떄 컨텍스트도 하나 더 추가해줘야 할 것이고
만약 useContext
자체를 커스텀 훅으로 만들어 사용한다면 커스텀 훅도 하나 더 만들어줘야 할 것이다.
위에서 말한 문제인 본인이 사용하고 있는 상태의 업데이트 일 때에만 리렌더링이 일어나길 원한다면
선택적 구독을 지원하는 라이브러리를 사용하는 것이 가장 최선이다.
가장 대표적인 예시가 zustand
일텐데 (사실 내가 redux
랑 zustand
밖에 사용해보지 않았다)
zustand
에선 다음과 같이 선택적 구독을 지원한다.
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}))
function BearCounter() {
const bears = useStore((state) => state.bears)
return <h1>{bears} around here...</h1>
}
function Controls() {
const increasePopulation = useStore((state) => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
zustand
의 가장 큰 특징 두 가지를 따라 한 번 구현 해보자
Context
없이 전역상태 관리 지원import { useEffect, useState } from "react";
type Selector<T, R> = (state: T) => R;
export const createStore = <Store extends object>(
initialState: Store | (() => Store)
) => {
let store =
typeof initialState === "function" ? initialState() : initialState;
const callbacks = new Set<() => void>();
const subscribe = (callback: () => void): (() => void) => {
callbacks.add(callback);
return () => callbacks.delete(callback);
};
const getState = () => ({ ...store });
const setState = (
action: Partial<Store> | ((state: Store) => Partial<Store>)
) => {
const newState =
typeof action === "function" ? action({ ...store }) : action;
store = {
...store,
...newState,
};
callbacks.forEach((callback) => callback());
};
const useStore = <R>(selector: Selector<Store, R>) => {
const [state, _setState] = useState(() => selector(store));
useEffect(() => {
const unsubscribe = subscribe(() => {
_setState(selector(store));
});
return unsubscribe;
}, []);
return state;
};
useStore.setState = setState;
useStore.getState = getState;
return useStore;
};
createStore
메소드가 선언 될 때 생성 된 store
객체를 클로저 형태로 바라보는 useStore
커스텀 훅을 이용해 zustand
의 사용 예시와 유사하게 선택적 구독을 구현 할 수 있다.
export const chromeStorageInitialValue: ChromeStorage = {
reference: [],
autoConverting: false,
isDarkMode: false,
isContentScriptEnabled: false,
isUnAttachedReferenceVisible: true,
};
export const useChromeStorage = createStore(chromeStorageInitialValue);
export const ConvertToReferenceButton = () => {
const isContentScriptEnabled = useChromeStorage(
(state) => state.isContentScriptEnabled
);
...
}
컴포넌트에서 use ...
를 통해 store
(이하 globalState
) 객체의 특정 값을 바라보는 state
(이하 localState
) 를 이용하여
컴포넌트들과 globalState
를 공유한 채로 리렌더링을 유발 할 localState
만을 선택적으로 구독 하는 것이 가능하다.
export const createStore = <Store extends object>(
initialState: Store | (() => Store)
) => {
let store =
typeof initialState === "function" ? initialState() : initialState;
...
const useStore = <R>(selector: Selector<Store, R>) => {
const [state, _setState] = useState(() => selector(store));
useEffect(() => {
const unsubscribe = subscribe(() => {
_setState(selector(store));
});
return unsubscribe;
}, []);
return state;
};
useStore.setState = setState;
useStore.getState = getState;
return useStore;
};
코드를 조금만 자세히 들여다보면 setState
를 통해 globalState
값이 변경 되고 나면
subscribe
메소드로 인해 추가 된 콜백 메소드인 _setState(selector(store))
가 매 번 호출 되는 것이다.
이때 {...}
형태의 globalState
가 변경이 일어나 새로운 객체인 globalState
로 만들어진다 하더라도
컴포넌트가 실제 구독 중인 localState
의 상태 변화 (_setState
) 가 비교하는 주체는
globalState
자체가 아닌 selector(globalState)
(state => state.something
) 이기 때문에
selector(globalState)
값만 변하지 않았다면 리렌더링 유발되지 않는다.
만약 selector(globalState)
를 통해 구독 하고 있는 값이 문자나 숫자 같은 원시 값이 아닌 객체더라도 선택적 구독은 여전히 잘 작동한다.
globalState
가 새롭게 선언되더라도 내부에 존재하는 객체들은 메모리 주소에 의한 참조를 하고 있기 때문에
_setState( obj )
가 일어나더라도 비교 과정에서 이전 상태와 같은 객체로 판단되어 리렌더링이 일어나지 않는다.
사실 웬만한 코드는 위 코드를 기반으로 해서 만들어졌다고 볼 수 있다.
아주 좋은글이다.
위 코드에서 Context
없이 createStore
가 커스텀 훅을 반환하도록 하여 실제 zustand
를 사용 할 때와 동일한 형태로 사용 하도록 수정된 버전에 가깝다.
state
자체를 일반 메소드 안에서 호출하는 것이 가능한지 처음 알았다.
심지어 굳이 use .. 로 시작하지 않고
return ()=>{ const state = useState(...) }
처럼 해도 되더라!
이번 프로젝트에서 폼 컴포넌트를 선택적 구독 때문에 zustand
를 사용했다가 리액트 훅 폼으로 마이그레이션 했었는데
선택적 구독을 위처럼 구현 할 수 있다면 복잡한 상태 관리도 비슷한 로직을 이용하여 구현 할 수 있지 않을까 ? 라는 생각이 든다.