반디북
모던 리액트 Deep Dive
Ch5
준영
도은

5.1 상태 관리는 왜 필요한가?

🤔 '상태'는 무엇일까?

  • 어떠한 의미를 지닌 값
  • 애플리케이션에서 지속적으로 변경될 수 있는 값
  • 웹에서 상태로 분류될 수 있는 것들은
    • UI: 상호 작용이 가능한 모든 요소의 현재 값을 의미
    • URL: 브라우저에서 관리되고 있는 상태
    • form: loading중인지? submit되었는지? disabled인지? 모두 상태로 관리
    • 서버에서 가져온 값: 클라이언트에서 서버로 요청을 통해 가져온 값도 상태로 볼 수 있다.

🫢 웹 서비스에서 점차 다양한 기능이 제공됨에 따라

  • 웹 내부에서 관리해야 할 상태도 점차 비례해서 증가
  • 상태를 효과적으로 관리하는 방법을 계속 고민해야 한다.

5.1.1 리액트 상태 관리의 역사

😳 Flux 패턴의 등장

  • 단방향으로 데이터를 변경하는 것

  • 간단하게 리액트 코드로 보면 다음과 같다.

    type StoreState = { count: number };
    type Action = { type: 'add'; payload: number };
     
    const reducer = (prevState: StoreState, action: Action) => {
      const { type } = action;
      if (type === 'add') {
        return {
          count: prevState.count + action.payload,
        };
      }
    };
     
    export const App = () => {
      const [state, dispatcher] = useReducer(reducer, { count: 0 });
     
      const handleClick = () => {
        dispatcher({ type: 'add', payload: 1 });
      };
     
      return ...
    };

😌 시장 지배자 리덕스의 등장

  • Flux 구조 + Elm 아키텍처 도입
    • Elm은 웹페이지를 선언적으로 작성하기 위한 언어
    • Elm은 Flux와 마찬가지로 데이터 흐름을 3가지로 분류, 이를 단방향으로 강제하여 상태를 안정적으로 관리하고자 노력
  • 리덕스는 하나의 상태 객체를 스토어에 저장 → 객체를 업데이트하는 작업을 디스패치 → 상태 업데이트
  • 리덕스의 등장은, props drilling 문제를 해결할 수 있었다.
  • 하지만, 하나의 상태를 바꾸고 싶어도 해야 할 일이 너무 많았다.
    • 어떠한 액션인지 타입을 선언해야 하고
    • 이 액션을 수행할 creator 함수를 만들어야 한다.
    • dispatcher와 selector도 필요하고
    • 새로운 상태가 어떻게 기존의 리듀서 내부에서 어떤 식으로 변경되어야 할지
    • 혹은 새로 만들어야 할지도 새로 정의해야 했다.
    • 🤢 보일러 플레이트가 너무 많다는 비판..

🥳 Context API와 useContext

  • 리액트 팀은 리액트 16.3에서 전역 상태를 하위 컴포넌트에 주입할 수 있는 새로운 Context API를 출시
  • props로 상태를 넘겨주지 않더라도 Context API를 사용하면 원하는 곳에서 사용 가능
  • 🧐 16.3 버전 이전에도 context가 존재했으며, getChildContext()를 제공했었다.

😎 훅의 탄생, 그리고 React Query와 SWR

  • 두 라이브러리 모두 외부에서 데이터를 불러오는 fetch를 관리하는 데 특화된 라이브러리
  • API 호출에 대한 상태를 관리
  • 서버 상태 관리 라이브러리로, 상태를 관리하는 라이브러리의 일종

😇 Recoil, Zustand, Jotai, Valtio에 이르기까지

  • 작은 크기의 상태를 효율적으로 관리
  • 위 라이브러리르들은 peerDependencies로 리액트 16.3 버전 이상을 요구 (→ 훅을 사용하기 때문)
  • 개발자가 원하는 만큼의 상태를 전역적으로 관리하는 것을 가능하도록

5.2 리액트 훅으로 시작하는 상태 관리

  • 비교적 오랜 기간 리액트 생태계에서는 리덕스에 의존
  • 그러나 현재는 Context API, useReducer, useState의 등장으로 재사용하거나 내부에 걸쳐서 상태를 관리할 수 있는 방법들이 점차 등장
  • 리액트 16.8에서 등장한 훅과 함수 컴포넌트의 패러다임에서
    • 내부 상태 관리는 어떻게 할 수 있고
    • 이러한 새로운 방법을 채택한 라이브러리는 무엇이 있고 어떻게 동작하는지 알아보자

5.2.1 가장 기본적인 방법: useState와 useReducer

  • useState를 통해, 여러 컴포넌트에 걸쳐 동일한 인터페이스의 상태를 생성하고 관리할 수 있게 되었다.
const useCounter = (initCount = 0) => {
  const [counter, setCounter] = useState(initCount);
 
  return {
    inc: () => setCounter((prev) => prev + 1),
    counter,
  };
};
  • 훅으로 코드를 격리해 제공할 수도 있다.
  • 이처럼, 리액트의 훅을 기반으로 만든 사용자 정의 훅은 함수 컴포넌트라면 어디서든 손쉽게 재사용 가능!!
  • 재사용할 수 있는 지역 상태를 만들어 주지만, 지역 상태라는 한계 → 여러 컴포넌트에 걸쳐 공유하기 위해서는 컴포넌트 트리 재설계 필요

5.2.2 지역 상태의 한계를 벗어나보자: useState의 상태를 바깥으로 분리하기

🙀 useState의 한계는 명확하다

  • 리액트가 만든 클로저 내부에서 관리 → 지역 상태로 생성 → 해당 컴포넌트에서만 사용 가능
  • 🤔 그 상태를 참조하는 유효한 스코프 내부에서는 해당 값을 공유해서 사용할 수도 있지 않을까?

아래와 같은 코드는 리액트에서 동작할까?

// counter.ts
export type State = { counter: number };
 
// 상태를 아예 컴포넌트 밖에 선언했다. 각 컴포넌트가 이 상태를 바라보게 할 것이다.
let state: State = {
  counter: 0,
};
 
// getter
export function get(): State {
  return state;
}
 
// useState와 동일하게 구현하기 위해 게으른 초기화 함수나 값을 받을 수 있게 했다.
type Initializer<T> = T extends any ? T | ((prev: T) => T) : never;
 
// setter
export const set = <T>(nextState: Initializer<T>) => {
    state = typeof nextState === 'function' ? nextState(state) : nextState
}
 
// Counter
const Counter() {
    const state = get()
 
    const handleClick = () => {
        set(prev: State => ({counter: prev.counter + 1}))
    }
 
    return ...
}
  • 위 방식은 리액트 환경에서 작동 X
  • 🙀 가장 큰 문제는, 리렌더링 되지 않는다는 것
  • 업데이트되는 값을 가져오려면 상태 업데이트 + 리렌더링 필요
    • useState의 두 번째 인수로 업데이트하는 것으로 리렌더링 발생
  • 외부에서 상태를 참조 & 렌더링까지 자연스럽게 일어나기 위해서는
    1. 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다.
    2. 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아챌 수 있어야 한다. 상태 변화 시마다 리렌더링 → 최신 상태값 기준으로 렌더링
    3. 원시값이 아닌 객체인 경우, 내가 감지하지 않는 값이 변한다 하더라도 리렌더링 발생 X

🫢 컴포넌트 레벨의 지역 상태를 벗어나느 새로운 상태 관리 코드를 만들어보자

type Initializer<T> = T extends any ? T | (prev: T => T):never
 
type Store<State> = {
    get: () => State // 최신값을 가져오는 함수
    set: (action: Initializer<State>) => State // useState와 동일하게 값 또는 함수를 받을 수 있도록
    subscribe: (callback: () => void) => () => void // store의 변경을 감지하고 싶은 컴포넌트들이 자신의 callback을 등록해두는 곳
}
/**
 * 이 스토어를 참조하는 컴포넌트는 subscribe에 컴포넌트 자기 자신을 렌더링하는 코드를 추가해서
 * 컴포넌트가 리렌더링을 실행할 수 있게 만들 것
 */
export const createStore = <State extends unknown>(initialState: Initializer<State>) => {
  // state는 스토어 내부에서 보관해야 하므로 변수로 선언
  let state = typeof initialState !== 'function' ? initialState : initialState();
 
  // callbacks는 자료형에 관계없이 유일한 값을 저장할 수 있는 Set을 사용
  const callbacks = new Set<() => void>();
  // 언제든 get이 호출되면 최신값을 가져올 수 있도록 함수로 만든다.
  const get = () => state
  const set = (nextState: State | (prev: State => State)) => {
    state = typeof nextState === 'function' ? (nextState as(prev: State => State))(state) : nextState
    // 값의 설정이 발생하면 콜백 목록ㅇ르 순회하면서 모든 콜백을 실행한다.
    callbacks.forEach(callback => callback())
    return state
  }
 
  const subscribe = (callback: () => void) => {
    // 받은 함수를 콜백 목록에 추가
    callbacks.add(callback)
    // 클린업 실행 시 이를 삭제해서 반복적으로 추가되는 것을 막는다.
    return () => {
        callbacks.delete(callback)
    }
  }
  return { get, set, subscribe }
};
  • createStore로 만들어진 store의 값을 참조하고
  • 이 값의 변화에 따라 컴포넌트를 렌더링을 유도할 사용자 정의 훅이 필요
export const useStore = <State extends unknown>(store: Store<State>) => {
  const [state, setState] = useState<State>(() => store.get()); // 컴포넌트의 렌더링을 유도하기 위해 useState 사용
 
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      // store의 값이 변경될 때 setState할 수 있도록
      setState(store.get());
    });
    return unsubscribe;
  }, [store]);
  return [state, store.set] as const;
};
  • useStore의 경우, 객체인 경우 프로퍼티 일부가 변경될 경우 모든 컴포넌트가 렌더링될 것
export const useStoreSelector = <State extends unknown, Value extends unknown>(
  store: Store<State>,
  selector: (state: State) => Value,
) => {
  const [state, setState] = useState(() => selector(store.get()));
 
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      const value = selector(store.get());
      setState(value);
    });
    return unsubscribe;
  }, [store, selector]);
 
  return state;
};
  • 객체로 구성되어 있다 하더라도, 컴포넌트에서 필요한 값만 그대로 select해서 사용 + 업데이트 수행
  • 🚨 주의할 점은 selector는 함수 외부 혹은 useCallback으로 참조를 고정해야 한다.

5.2.3 useState와 Context를 동시에 사용해 보기

  • 위처럼 구현하는 것은, 반드시 하나의 스토어만 가지게 되는 것
    • 마치 전역 변수처럼 작동

🤔 서로 다른 스코프에서 스토어의 구조는 동일하되, 여러 개의 서로 다른 데이터를 공유하고 싶다면?

// Context를 생성하면, 자동으로 스토어도 함께 생성한다.
export const CounterStoreContext = createContext<Store<CounterStore>>(
  createStore<CounterStore>({ count: 0, text: 'hello' }),
);
 
type CounterStoreProviderProps = PropsWithChildren<{ initialState: CounterStore }>;
 
export const CounterStoreProvider = ({ initialState, children }: CounterStoreProviderProps) => {
  const storeRef = useRef<Store<CounterStore>>();
 
  // 스토어를 생성한 적이 없다면 최초에 한 번 생성한다.
  if (!storeRef.current) {
    storeRef.current = createStore(initialState);
  }
 
  return <CounterStoreContext.Provider value={storeRef.current}>{children}</CounterStoreContext.Provider>;
};
  • Context에서 제공하는 스토어에 접근할 수 있는 새로운 훅도 필요하다
export const useCounterContextSelector = <State extends unknown>(selector: (state: CounterStore) => State) => {
  const store = useContext(CounterStoreContext);
  // 리액트에서 제공하는 훅 사용 ..-> 없음..?
  const subscription = useSubscription(
    useMemo(
      () => ({
        getCurrentValue: () => selector(store.get()),
        subscribe: store.subscribe,
      }),
      [store, selector],
    ),
  );
  return [subscription, store.set] as const;
};

5.2.4 상태 관리 라이브러리 Recoil, Jotai, Zustand 살펴보기

  • Recoil, Jotai: Context, Provider 그리고 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 것에 초점
  • Zustand: 하나의 큰 스토어를 기반으로 상태를 관리

1️⃣ Recoil

  • 최상단에 RecoilRoot를 감싸줘야 한다.
    • 생성되는 상태값을 저장하기 위한 스토어를 생성하는 역할
const RecoilRoot = (props: Props): React.Node => {
  const { override, ...restProps } = props;
 
  const ancestorStoreRef = useStoreRef(); // 상태값을 저장하는 스토어
  if (override === flase && ancestorStoreRef.current !== defaultStore) {
    return props.children;
  }
  return <RecoilRoot_INTERNAL {...restProps} />;
};
 
// useStoreRef
const AppContext = React.createContext<StoreRef>({ current: defaultStore });
const useStoreRef = (): StoreRef => useContext(AppContext);
  • RecoilRoot의 구조를 대락 파악해보면
    1. Recoil의 상태값은 RecoilRoot로 생성된 Context의 스토어에 저장
    2. 스토어의 상태값에 접근할 수 있는 함수 존재, 이 함수를 통해 접근 또는 업데이트 가능
    3. 값의 변경이 발생하면, 이를 참조하고 있는 하위 컴포넌트에 모두 알린다.

2️⃣ Jotai

  • Recoilatom 모델에 영감을 받아 만들어진 상태 관리 라이브러리
  • Context의 불필요한 리렌더링 문제를 해결하고자 설계
  • jotaiatom 구현을 살펴보자
export function atom<Value, Update, Result extends void | Promise<void>>(
  read: Value | Read<Value>,
  write?: Write<Update, Result>,
) {
  const key = `atom${++keyCount}`;
  const config = {
    toString: () => key,
  } as WriableAtom<Value, Update, Result> & { init?: Value };
 
  if (typeof read === 'function') {
    config.read = read as Read<Value>;
  } else {
    config.init = read;
    config.read = (get) => get(config);
    config.write = (get, set, update) => set(config, typeof update === 'function' ? update(get(config)) : update);
  }
 
  if (write) {
    config.write = write;
  }
  return config;
}
  • Recoil과 다르게 루트 레벨에서 Context가 존재X
    • 기본 스토어를 루트에 생성하고 이를 활용해 값을 저장
    • ⭐️ Provider 별로 다른 atom 값을 관리 가능
  • selector 없이 atom만으로 또 다른 파생된 상태 만드릭 가능
  • Recoil에 비해 간결!

3️⃣ Zustand

  • Zustand는 리덕스에 영감을 받아 제작
  • 하나의 스토어를 중앙 집중형으로 활용 → 이 스토어 내부에서 상태 관리
  • state를 useState 외부에서 관리
  • partial은 일부분만 변경하고 싶을 때, replace는 state를 완전히 새로운 값으로 변경하고 싶을 때
  • 여기까지는 react와는 별개로 값을 관리하는 것이였고 useStore를 통해 리렌더링을 수행