리액트와 상태관리 라이브러리 (2)

keemsebeen·2024년 6월 6일
1

모던 리액트 Deep Dive

목록 보기
10/18

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

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

useState와 비슷한 훅인 useReducer는 지역상태를 관리할 수 있는 훅이다.
useState는 useReducer로 구현되었는데, 이를 실제 코드로 작성하면 다음과 같다.

type Initializer<T> = T extends any ? T | ((prev : T) => T) : never;

function useStateWithUseRecducer <T>(initialState: T) {
const [state, dispatch] = useReducer(
  (prev: T, action: Initializer<T>) =>
    typeof action === 'function' ? action(prev) : action,
      initialState
    )

  return [state, dispatch] as const;
}

useReducer의 첫 번째 인수로는 reducer, 즉 state와 action을 어떻게 정의할지를 넘겨줘야 하는데 useState와 동일한 작동, 즉 T를 받거나 (prev : T) ⇒ T를 받아 새로운 값을 설정할 수 있게끔 코드를 작성했다.

그러나 두가지 모두 상태 관리의 모든 필요성과 문제를 해결해 주지 않는다. 지역 상태라는 한계 때문에 여러 컴포넌트에 걸쳐 공유하기 위해서는 컴포넌트 트리를 재설계하는 등의 수고로움이 필요하다.

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

새로운 상태를 사용자의 UI에 보여주기 위해서는 반드시 리렌더링이 필요하다. 함수 컴포넌트에서 리렌더링을 하려면 다음과 같은 작업 중 하나가 일어나야 한다.

  1. useState,useReducer의 반환값 중 두번째 인수가 어떻게든 호출된다. 설령 그것이 컴포넌트 렌더링과 관계없는 직접적인 상태를 관리하지 않아도 상관없다.
  2. 부모함수가 렌더링되거나 해당 함수가 다시 실행되어야 한다.

더나아가 렌더링까지 자연스럽게 일어나려면 다음과 같은 조건을 만족해야 한다.

  1. 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다.
  2. 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아 차릴 수 있어야 하고 상태가 변화될 때마다 리렌더링이 일어나서 컴포넌트를 최신 상태값 기준으로 렌더링해야 한다.
  3. 상태가 원시값이 아닌 객체인 경우에 그 객체에 내가 감지하지 않는 값이 변한다 하더라도 리렌더링이 발생해서는 안된다.

이 3가지를 만족시킬 수 있는 것이 store이다.

type Store<State> = {
	get: () => State
	set: (action: Initializer<State>) => State
  subscribe: (callback: () => void) => () => void
}
  1. get : 항상 최신값을 가져와야 하므로 함수로 구현했다.
  2. set: useState와 동일하게 값 또는 함수를 받을 수 있도록 만들었다.
  3. subscribe : store의 변경을 감지하고 싶은 컴포넌트들이 자신의 callback 함수를 등록해 둔다.
export const createStore = <State extends unknown>(
  initialState: Initialiizer<State>
): Store<State> => {
  let state =
    typeof initialState !== "function" ? initialState : initialState();
  const callbacks = new Set<() => void>();

  const get = () => state;
  const set = (newState: State) => {
    state =
      typeof newState === "function"
        ? (newState as (prev: State) => State)(state)
        : newState;
    callbacks.forEach((callback) => callback());

    return state;
  };
  const subscribe = (callback: () => void) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };
  return {
    get,
    set,
    subscribe,
  };
}

이는 store를 만드는 createStore를 구현한 코드다.

자신이 관리해야 하는 상태를 내부 변수로 가져온 다음, get 함수로 해당 변수의 최신값을 제공하며, set 함수로 내부 변수를 최신화하며, 이 과정에서 등록된 콜백을 모조리 실행하는 구조이다. 해당 store의 값을 참조하고 이 값의 변화에 따라 컴포넌트 렌더링을 유도할 사용자 정의 훅도 필요하다.

export const useStore = <State extends unknown>(store: Store<State>) => {
  const [state, setState] = useState<State>(() => store.get());
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.get());
    });
    return unsubscribe;
  }, [store]);

  return [state, store.set] as const;
}

지금은 값이 바뀌면 무조건 리렌더링이 일어난다. 여기서 한 발 더 나가면, 원하는 값이 바뀌었을 때만 리렌더링 되도록 훅을 재구성할 수 있다. 변경감지가 필요한 값만 setState를 호출해 객체 상태에 대한 불필요한 리렌더링을 막는 것이다.

export const useStoreSelector = <State extends unknown, Value extends unknown>(
  store: Store<State>,
  selector: (state: State) => Value
) => {
  const [state, setState] = useState<State>(() => selector(store.get()));

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      const value = selector(store.get());
      setState(value);
    });
    return unsubscribe;
  }, [store, selector]);

  return state;
};

useStoreSelector처럼 외부에서 관리되는 값에 대한 변경을 추적하고, 이를 리렌더링까지 할 수 있는 훅은 사실 존재한다. useSubscription을 사용하면 외부에 있는 데이터를 가져와 사용하고 리렌더링까지 정상적으로 수행이 가능하다.

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

useState훅과 useStoreSelector와 같이 스토어를 사용하는 구조는 반드시 하나의 스토어만 가지게 된다. 하나의 스토어를 가지면 이 스토어는 마치 전역 변수처럼 작동하게 되어 동일한 형태의 여러 개의 스토어를 가질 수 없게 된다.

해결 방법 1. createStore 사용

const store1 = createStore({ count : 0 })
const store2 = createStore({ count : 0 })
const store3 = createStore({ count : 0 })

그러나 이 방법은 스토어가 필요할 때마다 반복적으로 스토어를 생성하기 때문에 좋은 방법이 아니다.

해결방법 2. Context와 Store 사용

export const CounterStoreContext = createContext<Store<CounterStore>>(
  createStore<CounterStore>({ count:0, text: 'Hello' })
);

export const CounterStoreProvider = ({ initialState,children } : PropsWithChildren<{initialState : CounterStore}>) => {
  const storeRef = useRef<Store<CounterStore>>()

  if (!storeRef.current) {
    storeRef.current = createStore(initialState)
  }
  
  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
);

CounterStoreContext를 통해 먼저 어떠한 Context를 만들지 타입과 함께 정의해 뒀다.

storeRef를 사용하는 이유는 Provider가 넘기는 props가 불필요하게 변경돼서 리렌더링되는 것을 막기 위해서이다. useRef를 사용했기 때문에 CounterStoreProvider는 최초 렌더링에서만 스토어를 만들어 값을 전달하게 된다.

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] as const;
};

useSubscription을 사용해 불필요한 반복을 제거하고 useContext를 사용해 스토어에 접근했다.

이렇게 Context와 Provider를 기반으로 각 store 값을 격리해서 관리하면, 스토어를 사용하는 컴포넌트는 해당 상태가 어느 스토어에서 온 상태인지 신경 쓰지 않아도 된다. Context와 Provider를 관리하는 부모 컴포넌트의 입장에서 자신이 자식 컴포넌트에 따라 보여주고 싶은 데이터를 Context로 잘 격리하기만 하면 된다.

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

페이스북이 만든 상태 관리 라이브러리 Recoil
Recoil의 핵심 API인 RecoilRoot, atom, useRecoilValue, useRecoilState를 살펴보고 Recoil에서는 상태 값을 어디에 어떻게 저장하는 지 알아보자

  1. RecoilRoot
    a. 생성되는 상태값을 저장하기 위한 스토어를 생성하기 때문에 애플리 케이션의 최상단에 선언해 둬야 한다.
    b. 상태값은 RecoilRoot로 생성된 Context의 스토어에 저장된다. (useStoreRef)
    c. 스토어의 상태값에 접근할 수 있는 함수들이 있으며, 이 함수를 활용해 상태값에 접근하거나 상태값을 변경할 수 있다. (replaceState)
    d. 값의 변경이 발생하면 이를 참고하고 있는 하위 컴포넌트에 모두 알린다. (notifyComponents)
  2. atom
    a. 상태를 나타내는 Recoil의 최소 상태 단위다.
    b. key 값을 필수로 가지며, 애플리케이션 내부에서 유일한 값이어야 한다.
    c. defalut는 atom의 초깃값을 의미한다.
  3. useRecoilValue
    a. atom의 값을 읽어오는 훅이다.
  4. useRecoilState
    a. useRecoilValue는 단순히 atom의 값을 가져오기 위한 훅이었다면 useRecoilState는 useState와 유사하게 값을 가져오고, 이 값을 변경할 수 도 있는 훅이다.

아직 정식 버전이 출시되지 않았기 때문에 추가적인 주의가 필요하다!

Recoil에서 영감을 받은, 그러나 조금 더 유연한 Jotai
리덕스와 같이 하나의 큰 상태를 애플리케이션에서 내려주는 방식이 아니라, 작은 단위의 상태를 위로 전파할 수 있는 구조를 취하고 있다.

리액트 Context의 문제점인 불필요한 리렌더링이 일어난다는 문제를 해결하고자 설계돼 있으며, 추가적인 최적화를 거치지 않아도 리렌더링이 발생되지 않도록 설계돼 있다.

  1. atom
    a. 최소 단위의 상태를 의미한다. 파생된 상태까지 만들 수 있다는 점이 장점이다.
    b. 고유한 key를 필요로 했던 Recoil과는 다르게 별도의 key를 내려주지 않아도 된다.

    const counterAtom = atom(0);
    console.log(counterAtom); 
    // 
    // {
    //   init: 0, 
    //   read: (get) => get(config), 
    //   write: (get, set, update) => 
    //     set(config, typeof update === 'function' ? update(get(cnofig)) : update) 
    // }
  2. useAtomValue
    a. 상태를 저장해두는 공간이다.
    b. useReducer을 사용하여 [version, valueFromReducer, atomFromReducer] 이 세가지 상태를 반환한다.

    • store의 버전
    • get을 수행했을때 반환되는 값
    • atom 그 자체

    c. atom 값은 store에 존재한다. atom 객체 그 자체를 키로 활용해 값을 저장한다. WeakMap을 활용하여 자바스크립트에서 객체만을 키로 가질 수 있는 독특한 방식의 Map을 활용한다.
    d. rerenderIfChanged

    • atom 값이 어디서 변경되더라도 useAtomValue로 값을 사용하는 쪽에서는 언제든 최신 값의 atom을 사용해 렌더링할 수 있게 된다.
  3. useAtom
    a. useState와 동일한 형태의 배열을 반환한다.

    • atom의 현재 값을 나타내는 useAtomValue 훅의 결과를 반환한다.
    • useSetAtom 훅을 반환한다. (수정할 수 있는 기능이 있다.)

    b. setAtom

    • 스토어에서 해당 atom을 찾아 직접 값을 업데이트하고, 새로운 값을 작성하고, listener 함수를 실행해 값의 변화가 있으면 전파를 하는 등의 작업을 수행한다.

작고 빠르며 확장에도 유연한 Zustand
Zustand는 리덕스에 영감을 받아 만들어졌다. atom이라는 개념으로 최소 단위의 상태를 관리하는 것이 아니라 하나의 스토어를 중앙 집중형으로 활용해 이 스토어 내부에서 상태를 관리한다.

  1. Zustand의 바닐라 코드
    a. state의 값을 useState 외부에서 관리한다.
    b. setState: partial과 replace를 활용해 state의 값이 객체일 때 필요에 따라 나눠서 사용이 가능하다.

    • partial : state의 일부분만 변경할 때 사용한다.
    • replace : state를 완전히 새로운 값으로 변경하고 싶을 때 사용한다.

    c. getState : 클로저의 최신 값을 가져오는 함수이다.
    d. subscribe : listener를 등록한다. 상태 값이 변경될 때 리렌더링이 필요한 컴포넌트에 전파될 목적으로 사용된다.
    e. destroy : listener를 초기화한다.

  2. Zustand의 리액트 코드
    a. useStore : useSyncExternalStoreWithSeletor를 사용해서 useStore의 subscribe, getState를 넘겨주고, 스토어에서 선택을 원하는 state를 고르는 함수인 selector를 넘겨주고 끝난다.
    b. useSyncExternalStore : 리액트 18에서 새롭게 만들어진 훅으로, 리액트 외부에서 관리되는 상태값을 리액트에서 사용할 수 있도록 도와준다.

  3. 간단한 사용법

    import {create} from 'zustand'
    
    const useCounterStore = create((set) => ({
      count: 0,
      increment: () => set((state) => ({count: state.count + 1})),
      decrement: () => set((state) => ({count: state.count - 1})),
    }))
    
    function Counter() {
      const {count, increment, decrement} = useCounterStore()
    
      return (
        <div class="counter">
          <span>{count}</span>
          <button onClick={increment}>Increment</button>
          <button onClick={decrement}>Decrement</button>
        </div>
      )
    }

특별히 많은 코드를 작성하지 않아도 빠르게 스토어를 만들고 사용할 수 있다는 큰 장점이 있다.

profile
프론트엔드 공부 중인 김세빈입니다. 👩🏻‍💻

0개의 댓글