[React] Deep Dive 모던 리액트(17) Recoil 살펴보기
FrontEnd/Deep Dive

[React] Deep Dive 모던 리액트(17) Recoil 살펴보기

728x90

 

저번글까지 전역 상태관리들을 직접 만들어 보았다. 이번에는 리액트 생태계에서 인기있는 라이브러리들에 대해 알아보자.

 

Recoil과 Jotail는 Context과 Provider , 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 데 초점을 맞추고 있다.

Zustand는 리덕스처럼 하나의 큰 스토어를 기반으로 상태를 관리한다.

 

Recil , Jotail과는 다르게 이 하나의 큰 스토어는 Context가 아니라 스토어가 가지는 클로저를 기반으로 생성되며 이 스토어의 상태가 변경되면 이 상태를 구독하는 컴포넌트에 전파한다.

 

 

 

Recoil

리액트를 만든 페이스북에서 만든 상태 관리 라이브러리이다. 훅의 개념으로 상태 관리를 시작한 최초의 라이브러리들중 하나이며 Atom을 처음 리액트 생태계에서 보였다. 20년처음 만들어졌지만 아직 정식으로 출시한 라이브러리는 아니다.

Recoil팀에서는 리액트 18에서 제공되는 동시성 렌더링, 서버 컴포넌트 , Streaming SSR이 지원되기 전까지는 1.0.0을 릴리스하지 않을 것이라고 한다. 따라서 아직 실제 프로덕션에 사용하기에는 안정성,성능, 사용성이 떨어질 수 있다.

 

그럼에도 Recil에서 제공하는 개념,구현방식은 다른 라이브러리에도 많은 영향을 끼쳤다. 먼저 Recoil이 어떻게 작동하는지 소스코드를 통해서 알아보자.

 

RecoilRoot

Recoil을 사용하기 위해서는 RecoilRoot를 애플리케이션의 최상단에 선언해둬야 한다.

 

export default function App(){
	return <RecoilRoot>{내용}</RecoilRoot>
}

 

 

아래 Recoil의 소스코드를 보면 RecoilRoot에서 Recoil에서 생성되는 상태값을 저장하기 위한 스토어를 생성하는 것을 확인해볼 수 있다.

https://github.com/facebookexperimental/Recoil/blob/a0aea0c6075444221bf69b899dd4bb1978af40f6/packages/recoil/core/Recoil_RecoilRoot.js#L561-L572

function RecoilRoot(props: Props): React.Node {
  const {override, ...propsExceptOverride} = props;

  const ancestorStoreRef = useStoreRef();
  if (override === false && ancestorStoreRef.current !== defaultStore) {
    // If ancestorStoreRef.current !== defaultStore, it means that this
    // RecoilRoot is not nested within another.
    return props.children;
  }

  return <RecoilRoot_INTERNAL {...propsExceptOverride} />;
}

 

 

여기서 useStoreReft를 봐야한다. useStoreRef로 ancestorStoreRef의 존재를 확인하는데 이게 Recoil에서 생성되는 atom과 같은 상태값을 저장하는 스토어를 의미한다. 이 useStoreRef는 AppContext가 가진 스토어를 가리킨다.

 

https://github.com/facebookexperimental/Recoil/blob/a0aea0c6075444221bf69b899dd4bb1978af40f6/packages/recoil/core/Recoil_RecoilRoot.js#L121-L122

const AppContext = React.createContext<StoreRef>({current: defaultStore});
const useStoreRef = (): StoreRef => useContext(AppContext);

 

 

그리고 defaultStore은 스토어의 기본값을 나타내며 아래와 같다.

 

https://github.com/facebookexperimental/Recoil/blob/a0aea0c6075444221bf69b899dd4bb1978af40f6/packages/recoil/core/Recoil_RecoilRoot.js#L70-L81

function notInAContext() {
  throw err('This component must be used inside a <RecoilRoot> component.');
}

const defaultStore: Store = Object.freeze({
  storeID: getNextStoreID(),
  getState: notInAContext,
  replaceState: notInAContext,
  getGraph: notInAContext,
  subscribeToTransactions: notInAContext,
  addTransactionMetadata: notInAContext,
});

 

getNextStoreId : 스토어의 다음 아이디 값을 가져오느 ㄴ함수
getState : 스토어의 값을 가져오는 함수
replaceState : 값을 수정하는 함수

 

해당 스토어 ID를 제외하면 모두 에러를 처리하고 있으며 RecoilRoot로 감싸야만 스토어에 접근할 수 있다.

 

replaceState에 대한 구성을 잠깐 보자.

https://github.com/facebookexperimental/Recoil/blob/a0aea0c6075444221bf69b899dd4bb1978af40f6/packages/recoil/core/Recoil_RecoilRoot.js#L437-L464

const replaceState = (replacer: TreeState => TreeState) => {
    startNextTreeIfNeeded(storeRef.current);
    // Use replacer to get the next state:
    const nextTree = nullthrows(storeStateRef.current.nextTree);
    let replaced;
    try {
      stateReplacerIsBeingExecuted = true;
      replaced = replacer(nextTree);
    } finally {
      stateReplacerIsBeingExecuted = false;
    }
    if (replaced === nextTree) {
      return;
    }

    if (__DEV__) {
      if (typeof window !== 'undefined') {
        window.$recoilDebugStates.push(replaced); // TODO this shouldn't happen here because it's not batched
      }
    }

    // Save changes to nextTree and schedule a React update:
    storeStateRef.current.nextTree = replaced;
    if (reactMode().early) {
      notifyComponents(storeRef.current, storeStateRef.current, replaced);
    }
    nullthrows(notifyBatcherOfChange.current)();
  };

 

변화가 감지되면 notifyComponents를 활용하여 값을 변화하게 하는 것을 알 수 있다.

 

아래 notifyComponents를 보면 store와 상태를 전파할 storeState를 인수로 받아서 해당 스토어를 사용하고 있는 하위 의존성을 모두 검색하여 콜백을 실행시킨다. 즉, 값이 변경된 경우 콜백을 실행하여 상태변화를 알린다는 점은 이전 글에서 구현해 본 스토어와 크게 다르지 않다.

function notifyComponents(
  store: Store,
  storeState: StoreState,
  treeState: TreeState,
): void {
  const dependentNodes = getDownstreamNodes(
    store,
    treeState,
    treeState.dirtyAtoms,
  );
  for (const key of dependentNodes) {
    const comps = storeState.nodeToComponentSubscriptions.get(key);
    if (comps) {
      for (const [_subID, [_debugName, callback]] of comps) {
        callback(treeState);
      }
    }
  }
}

 

지금까지 알아본 정보를 요약하면 아래와 같다.

1. Recoil의 상태값은 RecoilRoot로 생성된 Context의 스토어에 저장된다.
2. 스토어의 상태값에 접근할 수 있는 함수들이 있으며 해당 함수들로 상태값에 접근 & 변경을 수행한다.
3. 값의 변경이 일어나면 하위 컴포넌트에게 모두 알린다.

 

 

 

 

 

atom

atom은 Recoil의 핵심 개념이며 Recoil의 최소 상태 단위이다.

 

type Statement = {
	name : string
    amout : number
}

const InitialStatements : Array<Statement> = [
	{name : '과자' , amount : -500},
    {name : '용돈' , amount : 10000},
    {name : '까까' , amount : -5000},
]

//Atom 선언
const statementsAtom = atom<Array<Statement>>({
	key : 'statements',
    default : InitialStatements,
})

 

 

atom은 key 값을 필수로 가지며 이 키로 다른 atom과 구별한다. 이 키는 유일한 값이어야 하므로 atom과 selector를 만들때 겹치지 않도록 해야한다.

 

useRecoilValue

useRecoilValue를 활용하여 atom의 값을 읽어올 수 있다.

function Statements(){
	const statements = useRecoilValue(statementsAtom)
    return (
    	<>{something...}</>
    )
}

 

 

해당 훅은 아래처럼 구현되어 있다.

 

https://github.com/facebookexperimental/Recoil/blob/a0aea0c6075444221bf69b899dd4bb1978af40f6/packages/recoil/hooks/Recoil_Hooks.js#L602-L615

 

function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
  if (__DEV__) {
    validateRecoilValue(recoilValue, 'useRecoilValue');
  }
  const storeRef = useStoreRef();
  const loadable = useRecoilValueLoadable(recoilValue);
  return handleLoadable(loadable, recoilValue, storeRef);
}

 

 

 

 

useRecoilValueLoadable

function useRecoilValueLoadable_LEGACY<T>(
  recoilValue: RecoilValue<T>,
): Loadable<T> {
  const storeRef = useStoreRef();
  const [, forceUpdate] = useState([]);
  const componentName = useComponentName();

  const getLoadable = useCallback(() => {
    if (__DEV__) {
      recoilComponentGetRecoilValueCount_FOR_TESTING.current++;
    }
    const store = storeRef.current;
    const storeState = store.getState();
    const treeState = reactMode().early
      ? storeState.nextTree ?? storeState.currentTree
      : storeState.currentTree;
    return getRecoilValueAsLoadable(store, recoilValue, treeState);
  }, [storeRef, recoilValue]);

  const loadable = getLoadable();
  const prevLoadableRef = useRef(loadable);
  useEffect(() => {
    prevLoadableRef.current = loadable;
  });

  useEffect(() => {
    const store = storeRef.current;
    const storeState = store.getState();
    const subscription = subscribeToRecoilValue(
      store,
      recoilValue,
      _state => {
        if (!gkx('recoil_suppress_rerender_in_callback')) {
          return forceUpdate([]);
        }
        const newLoadable = getLoadable();
        if (!prevLoadableRef.current?.is(newLoadable)) {
          forceUpdate(newLoadable);
        }
        prevLoadableRef.current = newLoadable;
      },
      componentName,
    );

    /**
     * Since we're subscribing in an effect we need to update to the latest
     * value of the atom since it may have changed since we rendered. We can
     * go ahead and do that now, unless we're in the middle of a batch --
     * in which case we should do it at the end of the batch, due to the
     * following edge case: Suppose an atom is updated in another useEffect
     * of this same component. Then the following sequence of events occur:
     * 1. Atom is updated and subs fired (but we may not be subscribed
     *    yet depending on order of effects, so we miss this) Updated value
     *    is now in nextTree, but not currentTree.
     * 2. This effect happens. We subscribe and update.
     * 3. From the update we re-render and read currentTree, with old value.
     * 4. Batcher's effect sets currentTree to nextTree.
     * In this sequence we miss the update. To avoid that, add the update
     * to queuedComponentCallback if a batch is in progress.
     */
    if (storeState.nextTree) {
      store.getState().queuedComponentCallbacks_DEPRECATED.push(() => {
        prevLoadableRef.current = null;
        forceUpdate([]);
      });
    } else {
      if (!gkx('recoil_suppress_rerender_in_callback')) {
        return forceUpdate([]);
      }
      const newLoadable = getLoadable();
      if (!prevLoadableRef.current?.is(newLoadable)) {
        forceUpdate(newLoadable);
      }
      prevLoadableRef.current = newLoadable;
    }

    return subscription.release;
  }, [componentName, getLoadable, recoilValue, storeRef]);

  return loadable;
}

 

getLoadable은 현재 Recoiil이 가지고 있는 상태값을 가지고 있는 클래스인 loadable을 반환한다. 이 값을 이전값과 비교하여 렌더링이 필요한지 확인하기 위해 렌더링을 일으키지 않으며 값을 저장할 수 있는 ref에 매번 저장한다.

 

이후 useEffect를 통해 recoilValue가 변경되었을 때 forceUpdate를 호출하여 렌더링을 강제로 일으킨다. 물론 중간중간 Recoil의 최적화 코드들이 있지만 기본적인 원리는 '외부의 값을 구독하여 렌더링을 강제로 일으킨다' 이다.

 

 

 

useRecoilState

 

useRecoilValue가 단순 atom의 값을 가져오기 위한 값이라면 해당 훅은 useState처럼 값을 가져오고 수정할 수 있게 해주는 훅이다.

https://github.com/facebookexperimental/Recoil/blob/a0aea0c6075444221bf69b899dd4bb1978af40f6/packages/recoil/hooks/Recoil_Hooks.js#L647-L661

/**
  Equivalent to useState(). Allows the value of the RecoilState to be read and written.
  Subsequent updates to the RecoilState will cause the component to re-render. If the
  RecoilState is pending, this will suspend the component and initiate the
  retrieval of the value. If evaluating the RecoilState resulted in an error, this will
  throw the error so that the nearest React error boundary can catch it.
*/
function useRecoilState<T>(
  recoilState: RecoilState<T>,
): [T, SetterOrUpdater<T>] {
  if (__DEV__) {
    validateRecoilValue(recoilState, 'useRecoilState');
  }
  return [useRecoilValue(recoilState), useSetRecoilState(recoilState)];
}

 

 

useRecoilState 자체가 useState와 매우 유사한 구조를 가지고 있다. 값을 가져오기 위해서는 useRecoilValue를 사용하고 있으며 useSetRecoilState훅으로 상태를 변경하고 있다.

 

useSetRecoilState

https://github.com/facebookexperimental/Recoil/blob/a0aea0c6075444221bf69b899dd4bb1978af40f6/packages/recoil/hooks/Recoil_Hooks.js#L617-L632

/**
  Returns a function that allows the value of a RecoilState to be updated, but does
  not subscribe the component to changes to that RecoilState.
*/
function useSetRecoilState<T>(recoilState: RecoilState<T>): SetterOrUpdater<T> {
  if (__DEV__) {
    validateRecoilValue(recoilState, 'useSetRecoilState');
  }
  const storeRef = useStoreRef();
  return useCallback(
    (newValueOrUpdater: (T => T | DefaultValue) | T | DefaultValue) => {
      setRecoilValue(storeRef.current, recoilState, newValueOrUpdater);
    },
    [storeRef, recoilState],
  );
}

 

setRecoilValue내부에는 queueOrPerformStateUpdate함수를 활용하여 상태를 업데이트 하고 있다.

 

 

 

 

간단한 Recoil 예제

 

간단하게 Recoil을 사용하는 예제를 보자.

 

 

import React from "react";
import {
  RecoilRoot,
  atom,
  selector,
  useRecoilState,
  useRecoilValue,
} from "recoil";

const counterState = atom({
  key: "counterState",
  default: 0,
});

function Counter() {
  const [, setCount] = useRecoilState(counterState);

  function handleButtonClick() {
    setCount((count: any) => count + 1);
  }

  return (
    <>
      <button onClick={handleButtonClick}>+</button>
    </>
  );
}

const isBiggerThan10 = selector({
  key: "above10State",
  get: ({ get }) => {
    return get(counterState) >= 10;
  },
});

function Count() {
  const count = useRecoilValue(counterState);
  const biggerThan10 = useRecoilValue(isBiggerThan10);

  return (
    <>
      <h3>{count}</h3>
      <p>count is bigger than 10 : {JSON.stringify(biggerThan10)}</p>
    </>
  );
}

const MyComponent = () => {
  return (
    <RecoilRoot>
      <Counter></Counter>
      <Count></Count>
    </RecoilRoot>
  );
};

/* STYLE */

export default MyComponent;

 

 

 

 

selector을 활용하면 위와같이 한개 이상의 atom을 바탕으로 새로운 값을 조립할 수 있게 도와준다.

 

 

Recoil은 selector을 필두로 다양한 비동기 작업을 지원하고 있기 때문에 redux-thunk , redux-saga 등의 추가적인 미들웨어가 필요가 없다. 또한 자체적인 개발도구를 지원하여 Recoil기반 개발이 편하다는 장점들이 있다.

 

하지만 정식 버전인 1.0.0의 출시 시점이 아직 불확실하여 안정적인 서비스 개발을 원하는 개발자들이 쉽게 선택을 못하고 있다.

728x90