React에서 Fine-Grained 반응형 시스템 구현하기 ("Reactivity is easy" 요약)

okorion·2025년 7월 16일
post-thumbnail

원문 - Reactivity is easy

React 생태계에서는 여전히 "반응형(Reactivity)"이라는 개념이 혼란스럽게 다뤄지고 있습니다. 이 글에서는 MUI X Data Grid에서 경험한 문제를 바탕으로, React에서도 35줄 미만의 코드로 정밀한(selector-based) 반응형 상태 관리를 구현하는 방법을 소개합니다.

🧩 문제 상황: Context로 인한 불필요한 리렌더링

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>
  );
}
  • 문제: 하나의 Cell을 클릭할 때마다 모든 Cell이 리렌더링
  • 원인: Context 값을 참조하는 모든 컴포넌트가 변경 시 다시 그려짐

✅ 최소한의 Store 패턴으로 해결하기

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 없이도 동작 가능 (상위 컴포넌트가 리렌더되지 않음)

🧠 React의 리렌더 트리거 조건

  1. 부모가 리렌더되면 자식도 리렌더됨
  2. useState, useReducer, useContext 값이 바뀌면 해당 컴포넌트 리렌더
  3. React.memo는 1번을 막는 escape hatch

🔧 개선 포인트

1. useSyncExternalStore로 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),
  );
}

2. .set() 메서드로 편리한 업데이트

class Store<State> {
  // ...
  set = (key, value) => this.update({ ...this.state, [key]: value });
}

🧮 계산된 상태: Reselect와 createSelector

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
  • 성능 최적화 포함 (e.g. args 배열 생략)
  • 정확한 TypeScript 타입 제공

✅ 요약

기능설명
🎯 핵심 문제Context 기반 상태 변경 시 전체 리렌더 발생
💡 해결 방식Store + useSelector 조합으로 필요한 부분만 리렌더
🔧 고급reselect로 계산된 값 추출, useSyncExternalStore로 tear 방지
📦 패키지store-x-selector 로 통합 구현 가능
profile
okorion's Tech Study Blog.

0개의 댓글