| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- Biconnected_Component
- template
- Articulation_Point
- 백준
- data_structure
- class_template
- deletion
- 13305
- 구현
- 문법
- Critical_Path_Analysis
- 총정리
- 알고리즘
- connected_component
- Algorithm
- '0'
- Heap
- c++
- singly Linked List
- red-black tree
- 5397
- function_template
- Pair
- sort
- 자료구조
- 예제
- STL
- list
- sstream
- qsort
- Today
- Total
- Today
- Total
- 방명록
어제의 나보다 성장한 오늘의 나
Recoil를 대체할 전역 상태 관리 라이브러리(HIState) 제작기 본문
이전에 프로젝트에서 클라이언트 애플리케이션 내의 모든 전역 상태를 Recoil로 통합 관리하고 있었습니다.
하지만 프로젝트의 구조가 점점 바뀌면서, Recoil의 역할이 줄어들게 되었습니다.
서버 상태는 React Query로 대체
우선, React Query의 도입으로 서버 상태 관리 방식이 완전히 바뀌었습니다.
데이터 fetching, 캐싱, 로딩 상태 관리 등을 React Query로 손 쉽게 처리하면서 Recoil은 더 이상 서버 상태를 다루지 않게 되었습니다.
Recoil은 여전히 클라이언트 전역 상태(UI 상태, Role)를 관리하는 용도로 일부 사용되고 있었습니다.
제한적인 역할만 수행하면서도 비교적 무거운 Recoil을 계속 유지하는 것은 부담스럽다고 판단했습니다.
Context API는 ??
context API도 여러 장점이 있습니다.
React 내장 기능이므로 추가 설치 없이 사용 가능하고 props drilling을 해결 해줄 수 있습니다.
그러나.. 두 가지 치명적인 단점이 있습니다.
1. 불필요한 리렌더링
Context의 value가 변경되면 해당 Context를 구독(useContext)하고 있는 모든 Consumer 컴포넌트가 강제로 리렌더링 됩니다.
이는 개별 필드의 변경 여부와 무관하게 발생하며, 구체적인 구독 범위를 분리할 수 없어 성능 최적화에 취약합니다.
MyProvider.tsx
import { createContext, useContext, useMemo, useState } from 'react';
// Context
interface AppState {
count: number;
message: string;
}
const AppContext = createContext<AppState | null>(null);
// Provider
function AppProvider({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
const [message, setMessage] = useState('hello');
const value = useMemo(() => ({ count, message }), [count, message]);
return (
<AppContext.Provider value={value}>
<button onClick={() => setCount((c) => c + 1)}>count +</button>
<button onClick={() => setMessage((m) => m + '!')}>message +</button>
{children}
</AppContext.Provider>
);
}
// count만 사용하는 컴포넌트
function CountDisplay() {
const { count } = useContext(AppContext)!;
console.log('CountDisplay가 렌더링됨');
return <div />;
}
// message만 사용하는 컴포넌트
function MessageDisplay() {
const { message } = useContext(AppContext)!;
console.log('MessageDisplay가 렌더링됨');
return <div />;
}
// App
export default function App() {
return (
<AppProvider>
<CountDisplay />
<MessageDisplay />
</AppProvider>
);
}
count 버튼을 누르면 MessageDisplay도 함께 렌더링됩니다.
message 버튼을 누르면 CountDisplay도 함께 렌더링됩니다.
이는 Context의 value 전체 객체가 변경 되면서, 이를 사용하는 모든 Consumer가 리렌더링되기 때문입니다.
2. Provider-Consumer의 강결합 구조
Consumer들은 반드시 해당 Context의 Provider 하위 트리 내에 존재해야 하며, 이는 컴포넌트 재사용성을 떨어뜨립니다.
외부 스토어 + useSyncExternalStore를 활용하여 직접 만들자
React 외부에서 상태를 관리하고, 필요한 컴포넌트만 구독할 수 있는 외부 스토어 기반 상태 관리 도구를 직접 구현했습니다.
이름은 서비스 이름인 HIStudy를 따서 HIState 라고 명명했습니다 :)
구조는 아래와 같습니다.

HIStateManager.ts
export class HIStateManager<T> {
private state: T;
private listeners: StateListener[] = [];
constructor(initialState: T) {
this.state = initialState;
}
setState = (param: SetStateParam<T>) => {
if (param instanceof Function) {
const newState = param(this.state);
this.state = newState;
} else {
this.state = param;
}
this.emitChange();
};
getState = () => {
return this.state;
};
subscribe = (listener: StateListener) => {
this.listeners = [...this.listeners, listener];
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
};
emitChange = () => {
for (let listener of this.listeners) {
listener();
}
};
}
이 HIStateManager 클래스는 React 외부에서 상태를 관리하는 외부 스토어입니다.
setState는 새로운 상태를 직접 전달하거나, 이전 상태를 기반으로 한 콜백 함수를 받아 상태를 업데이트할 수 있습니다.
상태가 변경되면 등록된 listeners(예: handleStoreChange 같은 구독 함수)들에게 emitChange를 통해 변화를 알림으로써, React 컴포넌트가 이를 감지하고 리렌더링할 수 있도록 합니다.
hooks/HIState.ts
interface Atom<T> {
key: string;
default: T;
}
interface GlobalStateMapValue<T> {
atom: Atom<T>;
stateManager: HIStateManager<T>;
}
type SetterOrUpdater<T> = (valOrUpdater: ((currVal: T) => T) | T) => void;
const globalStateMap = new Map<string, GlobalStateMapValue<any>>();
export function useHIState<T>(atom: Atom<T>): [T, SetterOrUpdater<T>] {
const store = globalStateMap.get(atom.key)!.stateManager;
const value = useSyncExternalStore(store.subscribe, store.getState);
return [value, store.setState];
}
export function useHIStateValue<T>(atom: Atom<T>): T {
const store = globalStateMap.get(atom.key)!.stateManager;
const value = useSyncExternalStore(store.subscribe, store.getState);
return value;
}
export function useSetHiState<T>(atom: Atom<T>): SetterOrUpdater<T> {
const globalStateMapValue = globalStateMap.get(atom.key)!;
return globalStateMapValue.stateManager.setState;
}
이 훅들은 HIStateManager로 관리되는 외부 상태를 React 컴포넌트와 안전하게 동기화(sync) 해주는 커스텀 훅들입니다.
useSyncExternalStore(store.subscribe, store.getState)를 사용하여 외부 스토어의 현재 상태를 구독하고, 상태 변경 시 자동으로 컴포넌트를 리렌더링합니다. hook들은 recoil에서 제공하는 기능을 본따서 만들었습니다.
export function createHISAtom<T>(atom: Atom<T>): Atom<T> {
if (globalStateMap.has(atom.key)) {
console.warn(`[HIState] Duplicate key "${atom.key}" ignored.`);
return globalStateMap.get(atom.key)!.atom;
}
const stateManager = new HIStateManager<T>(atom.default);
globalStateMap.set(atom.key, {
atom,
stateManager,
});
return atom;
}
외부 스토어를 생성하는 유틸 함수입니다.
Page.tsx
export default function Page() {
const [counter, setCounter] = useHIState(counterState);
}
간단하게 쓸 수 있습니다 :)
결과
Recoil 사용

제작 라이브러리 사용

gzip 기준 718.76 KB → 695.15 KB (약 3.28% 감소)
작지만 소중한 개선이 이루어졌습니다 :)
아래 PR에서 모든 코드를 확인하실 수 있습니다!
https://github.com/HandongSF/histudy-fe/pull/132
Feat/131 global state lib by ohinhyuk · Pull Request #132 · HandongSF/histudy-fe
작업 사항 Recoil 제거 전 Recoil 제거 후 gzip 기준 718.76 KB → 695.15 KB → 약 3.28% 감소 관련 이슈 close #131
github.com
'React' 카테고리의 다른 글
| [React] 클래스 인스턴스의 효율적인 렌더링과 접근 범위 제어 방법 (0) | 2024.03.24 |
|---|---|
| [React] useSyncExternalStore 사용 방법과 사용 이유 (6) | 2024.03.14 |