
원문 - Reactivity is easy
React 생태계에서는 여전히 "반응형(Reactivity)"이라는 개념이 혼란스럽게 다뤄지고 있습니다. 이 글에서는 MUI X Data Grid에서 경험한 문제를 바탕으로, React에서도 35줄 미만의 코드로 정밀한(selector-based) 반응형 상태 관리를 구현하는 방법을 소개합니다.
const Context = createContext();
function Grid() {
const [focus, setFocus] = useState(0);
const context = useMemo(() => ({ focus, setFocus }), [focus]);
return (
<Context.Provider value={context}>
{Array.from({ length: 50 }).map((_, i) => (
<Cell index={i} />
))}
</Context.Provider>
);
}
function Cell({ index }) {
const { focus, setFocus } = useContext(Context);
const isFocused = focus === index;
return (
<button
onClick={() => setFocus(index)}
className={clsx({ focus: isFocused })}
>
{index}
</button>
);
}
class Store<State> {
state: State;
private listeners = new Set<(s: State) => void>();
constructor(state: State) {
this.state = state;
}
subscribe = (fn) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
update = (newState: State) => {
this.state = newState;
this.listeners.forEach((l) => l(newState));
};
}
function useSelector(store, selector, ...args) {
const [value, setValue] = useState(() => selector(store.state, ...args));
useEffect(() => store.subscribe((s) => setValue(selector(s, ...args))), []);
return value;
}
const Context = createContext();
function Grid() {
const [store] = useState(() => new Store({ focus: 0 }));
return (
<Context.Provider value={store}>
{Array.from({ length: 50 }).map((_, i) => (
<Cell index={i} />
))}
</Context.Provider>
);
}
const selectors = {
isFocus: (state, index) => state.focus === index,
};
function Cell({ index }) {
const store = useContext(Context);
const isFocused = useSelector(store, selectors.isFocus, index);
return (
<button
onClick={() => store.update({ ...store.state, focus: index })}
className={clsx({ focus: isFocused })}
>
{index}
</button>
);
}
React.memo 없이도 동작 가능 (상위 컴포넌트가 리렌더되지 않음)useState, useReducer, useContext 값이 바뀌면 해당 컴포넌트 리렌더React.memo는 1번을 막는 escape hatchuseSyncExternalStore로 React 18 이상 대응import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector';
function useSelector(store, selector, ...args) {
return useSyncExternalStoreWithSelector(
store.subscribe,
() => store.state,
() => store.state,
(s) => selector(s, ...args),
);
}
.set() 메서드로 편리한 업데이트class Store<State> {
// ...
set = (key, value) => this.update({ ...this.state, [key]: value });
}
import { createSelector as createSelectorMemoized } from 'reselect';
const rows = state => state.rows;
const sortBy = state => state.sortBy;
const sortedRows = createSelectorMemoized(
rows,
sortBy,
(rows, sortBy) => rows.toSorted((a, b) => compare(a[sortBy], b[sortBy]))
);
function Component() {
const store = useContext(Context);
const result = useSelector(store, sortedRows);
}
store-x-selector직접 만들기 귀찮다면 NPM 패키지를 사용하세요:
npm i store-x-selector
| 기능 | 설명 |
|---|---|
| 🎯 핵심 문제 | Context 기반 상태 변경 시 전체 리렌더 발생 |
| 💡 해결 방식 | Store + useSelector 조합으로 필요한 부분만 리렌더 |
| 🔧 고급 | reselect로 계산된 값 추출, useSyncExternalStore로 tear 방지 |
| 📦 패키지 | store-x-selector 로 통합 구현 가능 |