홍승아블로그

recoil

recoil이란?

참고코드(redux-toolkit vs recoil): https://github.com/seungahhong/states-todos

리액트 상태 관리 로직의 한계점

  • ContextAPI 부모와 자식간의 커플링에 의존성 발생
    • 특히 리렌더링에 문제점 발생
    • 상태관리는 아니다(최초세팅, read, write)

Recoil은 왜 필요한 걸까??

  • 최대한 React 스러운 API 유지
  • 사용하기 위한 부속 라이브러리 최소화(특히, redux, mobx, redux-toolkit)
    • redux/react 사용을 위해서 react-redux 라이브러리를 사용해야했던 이슈들을 리코일에서는 내부적으로 처리

recoil

  • 부모 자식간의 의존성을 끊어주며, 독립적인 스토리지에서 컴포넌트에 데이터를 전달한다.
  • 특히, 기존로직을 건드리지 않고 atoms, selector를 사용 시 바로 적용이 가능하다.

Recoil 철학

  • 보일러플레이트가 적은 API(React Hooks) 사용하여서 React에 로컬 상태를 관리(useState, useReducer)
redux, reducer, constant

useEffect(() => {
  (async () => {
		await fetch~~
    -> redux 호출
  })();

}, []);

const allMainFetch = selector({
  key: 'allMainFetch',
  get: async ({ get }) => await get(fetch~~)
  })
});

const main = useRecoilValue(allMainFetch());
  • 파생데이터 자동으로 업데이트 처리가 된다.(selector)
    • A, B ⇒ C를 변경한다면 기존에는 A, B 데이터를 가져와서 C를 변경해서 업데이트 했지만 리코일은 selector에 지정해준다면 자동으로 업데이트 된다.(디펜던시가 걸려있는 컴포넌트 자동 업데이트)
    • mobx에 compute와 동일하다고 함
// 파생데이터
const firstAtom = atom({
  key: 'firstAtomKey',
  default: 1,
});

const secondAtom = atom({
  key: 'firstAtomKey',
  default: 1,
});

const cumulatedSelector = selector({
  key: 'cumulatedSelector',
  get: ({ get }) => {
    return get(firstAtom) + get(secondAtom);
  },
});

// atom
import { atom, useRecoilState } from 'recoil';

const counter = atom({
  key: 'myCounter',
  default: 0,
});

function Counter() {
  const [count, setCount] = useRecoilState(counter);
  const incrementByOne = () => setCount(count + 1);

  return (
    <div>
      Count: {count}
      <br />
      <button onClick={incrementByOne}>Increment</button>
    </div>
  );
}
  • atom : 데이터를 보관하는 기본단위

    • redux로 생각해본다면 store 저장 일부분?? 담당하고 있으면 기존에 reducer, action에 대한 처리를 내장 커스텀훅 사용가능
  • selector

    • atom, 다른 selector들을 조합할 수 있음
    • 파생되는 상태(derived state)를 생성한다.
    • dependency에 해당되는 atom이 업데이트되면 같이 업데이트 되기 때문에 관리의 부담이 없음.
  • hooks api(atom, selector 동일한 api 사용)

    • [useRecoilState()](https://recoiljs.org/ko/docs/api-reference/core/useRecoilState): atom을 읽고 쓰려고 할 때 이 Hook을 사용한다. 이 Hook는 atom에 컴포넌트을 등록하도록 한다.
    • [useRecoilValue()](https://recoiljs.org/ko/docs/api-reference/core/useRecoilValue): atom을 읽기만 할 때 이 Hook를 사용한다. 이 Hook는 atom에 컴포넌트를 등록하도록 한다.
    • [useSetRecoilState()](https://recoiljs.org/ko/docs/api-reference/core/useSetRecoilState): atom에 쓰려고만 할 때 이 Hook를 사용한다.
    • [useResetRecoilState()](https://recoiljs.org/ko/docs/api-reference/core/useResetRecoilState): atom을 초깃값으로 초기화할 때 이 Hook을 사용한다.
  • 사용방안

    • 대규모 프로젝트와 단반향 개발일 경우 flux
    • 작은프로젝트면서 여러 상태와 컴포넌트가 엮인경우 리코일
  • 단점

    • 디버깅이 어려울 수 있다(파생데이터 업데이트)
    • 파생데이터 업데이트가 종속적으로 엮여서 무한루프가 발생할 수 있다( b=cselector cselector=b) 개발자의 역량이 필요하다 단 무한루프는 리코일에서 방지하도록 되어 있음

스토어 상세에서 리코일을 적용을 했다면 어떤 점이 좋았을까??

  • 리덕스의 전역 스토어로 인해서 렌더링 최적화를 위해서 React.memo를 처리하면서 코드가 좀 복잡해짐
    • 특히 필요한 데이터에 맞는 화면 컴포넌트를 atom으로 연결했다면 굳이 렌더링 최적화 코드가 불필요했을 것 같음
  • 리덕스의 보일러플레이트 코드에 대해서 최대한 줄일 수 있을 것이고 또한 리액트에서 밀고 있는 함수형 컴포넌트로 작성이 용이했을 것이다.

비동기에 대한 처리

  • Suspend, ErrorBoundary
    • Recoil은 보류중인 데이터를 다루기 위해 React Suspense와 함께 동작하도록 디자인
    • Recoil selector는 컴포넌트에서 특정 값을 사용하려고 할 때에 어떤 에러가 생길지에 대한 에러를 던질 수 있습니다. 이는 React 사용
const currentUserNameQuery = selector({
  key: 'CurrentUserName',
  get: async ({ get }) => {
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    if (response.error) {
      throw response.error;
    }
    return response.name;
  },
});

function CurrentUserInfo() {
  const userName = useRecoilValue(currentUserNameQuery);
  return <div>{userName}</div>;
}

<RecoilRoot>
  <ErrorBoundary>
    <React.Suspense fallback={<div>Loading...</div>}>
      <CurrentUserInfo />
    </React.Suspense>
  </ErrorBoundary>
</RecoilRoot>;
  • class Loadable
    • 이 상태는 사용가능한 값을 가지고 있거나 에러 상태이거나 혹은 여전히 비동기 해결 보류 중일 수 있습니다.
    • state: atom 혹은 selector의 최신 상태입니다. 가능한 값은 ’hasValue’, ’hasError’, 혹은 ’loading’ 입니다.
    • contentsLodable에 의해서 대표되는 값입니다. 만약 상태가 hasValue 라면, 이는 실제 값입니다. 만약 상태가 hasError 라면 이는 던져진 Error 객체입니다
    • useRecoilStateLoadable : 비동기 작업에서 사용되며 쓰기가 가능한 atom, selector
    • useRecoilValueLoadable: 비동기 작업에서 사용되며 읽기만 가능한 atom, selector
function UserInfo({ userID }) {
  const userNameLoadable = useRecoilValueLoadable(currentUserNameQuery());
  const [userNameLoadable, setUserName] = useRecoilStateLoadable(
    currentUserNameQuery(),
  );
  switch (userNameLoadable.state) {
    case 'hasValue':
      return <div>{userNameLoadable.contents}</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'hasError':
      throw userNameLoadable.contents;
  }
}

isRecoilValue

  • value이 atom이나 selector일 경우 true를 반환하고 그렇지 않을 경우 false를 반환한다.
import { atom, isRecoilValue } from 'recoil';

const counter = atom({
  key: 'myCounter',
  default: 0,
});

const strCounter = selector({
  key: 'myCounterStr',
  get: ({ get }) => String(get(counter)),
});

isRecoilValue(counter); // true
isRecoilValue(strCounter); // true

atomFamily/selectorFamily

  • atomFamily는 atom과 동일하지만, 다른 인스턴스와 구분이 가능한 매개변수를 받을 수 있다.
  • atomFamily에 단일 키만을 제공하면, 각 기본 atom에 대해 고유한 키가 생성됩니다
// atom
const itemWithId = memoize(id => atom({
  key: `item-${id}`,
  default: ...
}))

// atomFamily
const itemWithId = atomFamily({
  key: 'item',
  default: ...
});

const getImage = async id => {
  return new Promise(resolve => {
    const url = `http://someplace.com/${id}.png`;
    let image = new Image();
    image.onload = () =>
      resolve({
        id,
        name: `Image ${id}`,
        url,
        metadata: {
          width: `${image.width}px`,
          height: `${image.height}px`
        }
      });
    image.src = url;
  });
};

export const imageState = atomFamily({
  key: "imageState",
  default: id => getImage(id)
});

noWait

  • helper는 useRecoilValueLoadable()와 비슷하지만, hook이 아닌 selector라는 점이 다르다.
  • noWait()은 selector를 반환하기 때문에, 다른 Recoil selector들과 hook에서 함께 사용가능함
const myQuery = selector({
  key: 'MyQuery',
  get: ({ get }) => {
    const loadable = get(noWait(dbQuerySelector));

    return {
      hasValue: { data: loadable.contents },
      hasError: { error: loadable.contents },
      loading: { data: 'placeholder while loading' },
    }[loadable.state];
  },
});

Snapshot

변하지 않는 정적인 데이터를 보관하려고 상태 관리 라이브러리를 사용하지는 않을 것이다. 상태는 끊임없이 변한다. 스냅샷은 계속 변하는 상태의 “한 순간”이다. 상태가 동영상이라면 스냅샷은 동영상의 한 프레임인 것이다.

  • [useRecoilSnapshot()](https://recoiljs.org/ko/docs/api-reference/core/useRecoilSnapshot)
  • 스냅샷은 상태가 변할 때마다 생성된다. 따라서 SnapshotCount 컴포넌트는 상태가 변할 때마다 렌더링된다.
<RecoilRoot>
  <Counter />
  <SnapshotCount />
</RecoilRoot>;

const counter = atom({
  key: 'counter',
  default: 0,
});

export default function Counter() {
  const [count, setCount] = useRecoilState(counter);
  const incrementByOne = () => setCount(count + 1);

  return (
    <div>
      Count: {count}
      <br />
      <button onClick={incrementByOne}>Increment</button>
    </div>
  );
}

import { useRecoilSnapshot } from 'recoil';

function SnapshotCount() {
  const snapshotList = useRef([]);
  const updateSnapshot = useRecoilCallback(({ snapshot }) => () => {
    snapshotList.current = [...snapshotList.current, snapshot];
    console.log('updated:', snapshotList.current);
  });

  return (
    <div>
      <p>Snapshot count: {snapshotList.current.length}</p>
      <button onClick={updateSnapshot}>현재 스냅샷 보관</button>
    </div>
  );
}
  • [useRecoilCallback()](https://recoiljs.org/ko/docs/api-reference/core/useRecoilCallback)
  • useCallback과 같이 의존성에 따라 갱신되는 메모이즈된 함수를 생성한다. 다만, 생성된 함수에 스냅샷과 상태를 다루는 객체 및 함수가 함께 전달된다는 점이 다르다.
function SnapshotCount() {
  const snapshotList = useRef([]);
  const snapshot = useRecoilSnapshot();

  useEffect(() => {
    snapshotList.current = [...snapshotList.current, snapshot];
  }, [snapshot]);

  return <p>Snapshot count: {snapshotList.current.length}</p>;
}
  • useGotoRecoilSnapshot
  • 특정 스냅샷 상태로 돌릴 수 있는 함수
function SnapshotCount() {
  const [snapshotList, setSnapshotList] = useState([]);
  const updateSnapshot = useRecoilCallback(({ snapshot }) => async () => {
    setSnapshotList(prevList => [...prevList, snapshot]);
  });
  const gotoSnapshot = useGotoRecoilSnapshot();

  return (
    <div>
      <p>Snapshot count: {snapshotList.length}</p>
      <button onClick={updateSnapshot}>현재 스냅샷 보관</button>
      <ul>
        {snapshotList.map((snapshot, index) => (
          <li key={index}>
            <button onClick={() => gotoSnapshot(snapshot)}>
              Snapshot #{index + 1}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

리코일 프로젝트 회고

좋은점

  • 기존에는 내가 일일이 action, store에서 업데이트를 시켜줘야해서 불편했지만, 리코일은 set만 할 경우에는 자동으로 get이 호출되서 편했다.
  • 불변성에 대한 고민을 하지 않아서 좋았다.(redux: spread, immer)
  • 캐싱이 되서 좋았다.
  • 꼭 필요한 곳에만 렌더링이 이뤄짐으로 React.memo 같은 처리를 하지 않아서 좋았다.

아쉬운점

  • get에서만 async를 제공하기 때문에 다른상태에 세팅할 시 불편한 점이 많았다.(상태값을 저장할 atom을 생성해야해서 코드량이 많아짐)
  • snapshot에 처리를 위해서 useRecoilCallback을 사용했지만, 해당 함수에서 상태를 업데이트 하여도 기존 스냅샷에 레퍼런스가 걸려있어서 업데이트된 데이터를 가져올 수 없었다.
이전글
redux-toolkit
다음글
storybook