리액트의 useContext를 State managing하는데 쓸 수 없을까? 에 대한 질문을 해결하기 위한 방법을 정리한다.
저번에 이어서.. react docs에서는 useContext의 리렌더를 막기 위해
1.하나의 context provider는 오브젝트가 아니어야 한다. 만약 하나 이상의 value를 사용하려면 Provider를 선언하는 것을 권장한다.
2.Provider 하위의 서브 트리가 모두 리렌더되는 것을 막기 위해선 해당 밸류를 사용하지 않는 컴포넌트일때 React.memo를 사용하면 그 해당 컴포넌트만 리렌더되는 것을 막을 수 있다.
까지를 알아보았는데, 하지만 그럼에도 불구하고, Zustand나 Jotai와 같은 외부 스토어 라이브러리를 사용하기는 뭔가 규모가 작고 그렇다고 무조건 리렌더되는 특성을 알고서도 뭔가 그대로 쓰기에는 아쉬운 그런 상황이 있을 수 있다.
여기서 얻고자 하는 것은, 리액트에서 제공하는 API 만으로 뭔가 간단한 State manager를 만들 수 있을까 이다.
Jack Harrington 선생님의 Fast Context
여러 방법이 있었지만 가장 이해하기 쉬운 위의 레퍼런스를 참고했다.
import React, { useState, createContext, useContext, memo } from "react";
function useStoreData() {
const store = useState({
first: "",
last: "",
});
return store;
}
type UseStoreDataReturnType = ReturnType<typeof useStoreData>;
const StoreContext = createContext<UseStoreDataReturnType | null>(null);
const TextInput = ({ value }: { value: "first" | "last" }) => {
const [store, setStore] = useContext(StoreContext)!;
return (
<div className="field">
{value}:{" "}
<input
value={store[value]}
onChange={(e) => setStore({ ...store, [value]: e.target.value })}
/>
</div>
);
};
const Display = ({ value }: { value: "first" | "last" }) => {
const [store] = useContext(StoreContext)!;
return (
<div className="value">
{value}: {store[value]}
</div>
);
};
const FormContainer = memo(() => {
return (
<div className="container">
<h5>FormContainer</h5>
<TextInput value="first" />
<TextInput value="last" />
</div>
);
});
const DisplayContainer = (() => {
return (
<div className="container">
<h5>DisplayContainer</h5>
<Display value="first" />
<Display value="last" />
</div>
);
});
const ContentContainer = (() => {
return (
<div className="container">
<h5>ContentContainer</h5>
<FormContainer />
<DisplayContainer />
</div>
);
});
function App() {
const store = useState({
first: "",
last: "",
});
return (
<StoreContext.Provider value={store}>
<div className="container">
<h5>App</h5>
<ContentContainer />
</div>
</StoreContext.Provider>
);
}
export default App;

일반적인 context의 사용 예시이다. 역시, 스토어에서는 두 가지 first와 last 값을 가지고 있고 자식에서는 이중 하나만을 사용하고 있지만, 둘 중 어떤 밸류가 변하든 간에 스크린샷의 모든 자식이 리렌더 될것이다.
이전글에서 확인한 것 처럼, memo를 주의깊게 사용하는 것으로 컨테이너 컴포넌트의 리렌더를 막을 수는 있다. 하지만 그게 큰 효과를 발휘하지는 않을 것이다.
드디어 글의 주제에 다다랐다. 위 영상에서 fast context라고 정의하는데,
위 ref의 first 와 last만 각각 구독해서, 전체의 자식을 리렌더시키는 것을 막을 수 있고, 최상위 provider 구독자 측의 자식 컴포넌트는 이를 전달받아 받는형식으로 구현한 것을 확인할 수 있었다. 개념 자체는 Django/Channels로 해본 적이 있었지만 이 이벤트 개념을 어떻게 useRef로 만들 수 있을까?
function useStoreData(): {
const store = useRef({
first: '',
last: '',
});
return store;
}
=>
type Store = { first: string; last: string };
function useStoreData(): {
get: () => Store;
set: (value: Partial<Store>) => void;
subscribe: (callback: () => void) => () => void;
// callback 을 param으로 받아 실행하고, cleanup 펑션까지를 가지는 타입
} {
const store = useRef({
first: '',
last: '',
});
const get = useCallback(() => store.current, []);
// 커스텀 훅을 만들때는, 내려보내는 펑션이 계속해서 재정의되는 것을 막기 위해 useCallback을 사용해 해당 펑션을 메모이즈해 두자.
const subscribers = useRef(new Set<() => void>());
// 해당 ref에는 컴포넌트가 해당 store의 값을 구독한다는 뜻인 callback이 담기게 되고, 일반 배열을 사용할 경우 한 번 이상 callback이 담길 수 있으므로 중복값을 제거하기 위해 new Set을 사용했다.
const set = useCallback((value: Partial<Store>) => {
store.current = { ...store.current, ...value };
subscribers.current.forEach((callback) => callback());
// 스토어의 밸류가 변경될 때 아래 callback을 파괴하고 새롭게 호출한다.
}, []);
const subscribe = useCallback((callback: () => void) => {
subscribers.current.add(callback);
// new Set에 콜백을 추가한다.
return () => subscribers.current.delete(callback);
// 컴포넌트가 파괴될때, 구독 콜백 역시도 파괴된다.
}, []);
return {
get,
set,
subscribe,
};
function Provider({ children }: { children: React.ReactNode }) {
// 해당 Provider의 밸류를 따로 펑션형태로 분리한 이유는, 오브젝트 형태로 내부에서 정하게 될 경우, 오브젝트 역시도 계속 재정의되서 리렌더를 유발하기 때문이다!
return (
<StoreContext.Provider value={useStoreData()}>
{children}
</StoreContext.Provider>
);
}
}
그렇다면 여기서 의문점이 생긴다. callback에는 무엇이 담기지?
// callback에는 컴포넌트가 사용하게 될 store을 사용하는 커스텀 훅이 담긴다.
function useStore(): [
Store,
(value: Partial<Store>) => void,
] {
const [state, setState] = useState(store.get());
useEffect(() => {
return store.subscribe(() => setState(store.get());
// 해당 훅이 재정의될 때에도 state값은 남아있으므로 cleanup만을 사용해서 store의 최신화된 밸류를 사용할 수 있도록 해준다.
}, [])
if(!store) {
throw new Error("store not found");
}
return [state, store.set];
}
여기까지
1. 최상위 useStoreData에서는 ref인 useStore와 subscribers를 담고 있고,
2. subscribers에 useStore 훅 그 자체가 담겨서 set이 일어날 경우 3. useStore를 재정의하면서 최신화 된 밸류로 useStore의 스테이트를 재정의한다.
여기서 한 단계 더 나아가서
function useStore<SelectorOutput>(
selector: (store: Store) => SelectorOutput
): [SelectorOutput, (value: Partial<Store>) => void] {
const store = useContext(StoreContext);
if (!store) {
throw new Error('Store not found');
}
const state = useSyncExternalStore(store.subscribe, () =>
selector(store.get())
);
return [state, store.set];
}
위 useState를 React 18에서 추가된 useSyncExternalStore로 바꾸어 주었다.
해당 hook은 위의 useState와 동일한 것으로, 실제 자세한 작동 방식은 추후에 파악해도 늦지 않을 것이다.

실제 위 subscribers Set을 확인해 본다면 저러한 형태인 것을 확인할 수가 있었다.

여기까지의 작업으로 직접적으로 훅을 사용하는 컴포넌트 6개(FormContainer + 두개의 Form, DisplayContainer + 두 개의 Display 컴포넌트)로 리렌더의 대상을 축소할 수 있었다.
마지막으로 set이 일어날 때, 둘 중 하나의 폼만 업데이트 되게 하면 될 것이다.
function useStore<SelectorOutput>(
selector: (store: Store) => SelectorOutput
): [SelectorOutput, (value: Partial<Store>) => void] {
const store = useContext(StoreContext);
if (!store) {
throw new Error('Store not found');
}
const state = useSyncExternalStore(store.subscribe, () =>
selector(store.get()) // 스토어에서 해당 값만을 받기 위한 선언.
);
return [state, store.set];
}
...
const TextInput = ({ value }: { value: 'first' | 'last' }) => {
console.log('rerender input', value);
const [fieldValue, setStore] = useStore((store) => store[value]);
return (
<div className="field">
{value}:{' '}
<input
value={fieldValue}
onChange={(e) => setStore({ [value]: e.target.value })}
/>
</div>
);
};

위와 같은 코드로 변경해주면 드디어 우리가 원하는 방식으로 rerender 사이드 이펙트를 최소화해서 실행되는 것을 알 수 있다!
물론, 위의 코드는 재사용성이 굉장히 떨어진다.
1. store를 고정해 주었기 때문에 재사용하기 위해선 따로 wrapper가 하나 더 필요하다.
2. wrapper는 generic한 타입을 받아서 사용하는 측에서 지정해주도록 타입을 연결해야 한다.
이 부분은 추후 기회가 있다면 블로그에 정리할 만 한데, 실제 작업하고 있는 사이드 프로젝트
my-pomodoro-timer
에서 적용하고 있기 때문에다.
이것으로 실제 리액트에서 사용되는 데이터 스토어 라이브러리들이 어떤 원리로 작동하는지? 에 대해 실제 react에서 제공하는 api로 적용해 보는 아주 유익한 시간이었다.