[React] Atoms, Selectors, 비동기 데이터 쿼리

js43o·2023년 1월 9일
1

Recoil이란?

Recoil은 React의 상태 관리 라이브러리 중 한 종류이다. React와 기존 상태 관리 라이브러리의 문제점을 개선하기 위해 만들어진 새로운 개념의 라이브러리라 할 수 있겠다.

  • React(Context API)의 단점
    • 컴포넌트 상태를 서로 공유하려면 공통 조상으로부터 내려받는 방법 밖에 없음. 이 경우 state가 갱신되면 해당 state를 필요로 하지 않는 중간의 다른 컴포넌트들까지 리렌더링이 발생함.
    • Context에 여러 값의 집합이 아닌 오직 단일 값(single value)만 저장할 수 있음.
    • state를 정의한 트리 위쪽부터 state를 사용하는 트리 아래쪽까지의 코드 분할이 어려움.
  • Redux의 단점
    • 제대로 사용하기 위해 먼저 만들어두어야 하는 코드들(보일러 플레이트)이 매우 많음. (reducer, action, store 등)
    • 비동기 데이터 처리를 위해 redux-thunk, redux-saga 등의 복잡한 외부 라이브러리가 추가로 필요함.

Atoms과 Selectors

1) Atoms

Atom은 state의 단위이다. 갱신(값 업데이트) 및 구독(컴포넌트에서 불러와 사용)이 가능하며, atom이 갱신되면 그것을 구독하는 모든 컴포넌트가 리렌더링되며 새로운 값을 즉각 반영한다.

atom은 이렇게 정의한다.

const fontSizeState = atom({
  key: 'fontSizeState',
  default: 14,
});

key는 디버깅이나 특정 API를 위해 사용되는 값이므로, 다른 atom과 중복되지 않아야 한다.
리액트의 useState처럼 default value를 정의해줄 수도 있다.

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  return (
    <button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
      Click to Enlarge
    </button>
  );
}

컴포넌트에서 atom을 사용할 땐 useRecoilState hook을 통해 불러오도록 한다. useState와 매우 유사한 형태이지만, 서로 다른 컴포넌트끼리 동일한 atom 값을 공유할 수 있다는 차이가 있다.

위 setFontSize()에서, 단순히 새로운 state 값을 반환하는 대신, Updater 형식으로 state를 어떻게 조작할지 정의해준 것을 볼 수 있다. ((size) => size + 1)
이렇게 작성하면 기존의 state 값(size)를 참조할 수 있다는 장점이 있다.

2) Selectors

Selector는 다른 atom이나 selector를 인자로 받는 순수 함수이다. 인자로 받은 다른 atom 및 selector가 갱신되면 해당 selector도 재평가되며, 이를 구독하고 있는 컴포넌트 역시 리렌더링된다.

const fontSizeLabelState = selector({
  key: 'fontSizeLabelState',
  get: ({get}) => {
    const fontSize = get(fontSizeState);
    const unit = 'px';

    return `${fontSize}${unit}`;
  },
});

selector도 atom처럼 key를 가지며, get 프로퍼티의 값은 계산을 위한 함수이다. 인자 get(프로퍼티 이름과 구별할 것!)을 통해 다른 atom 및 selector의 값을 가져올 수 있으며, 이를 이용해 새로운 값을 만들어 반환할 수 있다.

어떤 state를 담고 있다는 점은 atom과 같지만, 내부적으로 다른 state를 가져와서 써먹을 수 있다는 점이 차이인 것 같다. (= derived state)

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  const fontSizeLabel = useRecoilValue(fontSizeLabelState);

  return (
    <>
      <div>Current font size: {fontSizeLabel}</div>

      <button onClick={() => setFontSize(fontSize + 1)} style={{fontSize}}>
        Click to Enlarge
      </button>
    </>
  );
}

방금 정의한 selector는 atom과 달리 not writable(수정 불가)하므로 useRecoilValue hook을 통해 값을 가져와야 한다. (리턴 값에 setter가 따로 없는 것을 확인할 수 있다)
=> set 프로퍼티가 있는 selector만 writable함! (자기 자신이 아닌 다른 atom의 값을 수정하는 방법을 정의한 프로퍼티)

3) RecoilRoot

그리고 이러한 recoil state를 사용하고자 한다면 해당 컴포넌트들의 공통 조상에 RecoilRoot 컴포넌트가 존재해야 한다.

function App() {
  return (
    <RecoilRoot>
      <CharacterCounter />
    </RecoilRoot>
  );
}

보통은 이렇게 최상단 루트 컴포넌트를 감싸주면 된다.


비동기 데이터 쿼리

앞서 설명한 Recoil의 장점 중 하나는 비동기 데이터 처리가 간편하다는 것이다.
Recoil은 데이터 흐름 그래프를 통해 상태를 컴포넌트에 매핑하는데, 이 그래프 내의 함수를 쉽게 비동기화할 수 있다고 한다.

1) selector에서 비동기 함수 호출하기

첫 번째 방법은 그냥 selector의 get 프로퍼티가 값 자체가 아닌 프로미스를 리턴하도록 정의하면 된다!

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>;
}

위 예제의 selector를 보면 get 프로퍼티가 async 함수로 정의되었으며, myDBQuery라는 비동기 함수를 await 키워드와 함께 호출하여 데이터를 가져온 후 리턴하고 있다. 인자인 get은 역시 다른 atom(currentUserIDState)을 불러오는 데에 쓰였다.

selector는 기본적으로 캐싱 기능을 갖고 있으므로, 이전과 동일한 입력이 들어오면 다시 요청을 보내지 않고 기억해둔 이전의 값을 그대로 반환한다.

2) pending과 에러 처리

function MyApp() {
  return (
    <RecoilRoot>
      <ErrorBoundary>
        <React.Suspense fallback={<div>Loading...</div>}>
          <CurrentUserInfo />
        </React.Suspense>
      </ErrorBoundary>
    </RecoilRoot>
  );
}

단, 리액트의 렌더 함수는 동기적으로 작동하므로 Recoil이 비동기 데이터를 처리할 동안 React.Suspense 컴포넌트를 통해 fallback UI를 보여주도록 해야 한다.
또한 에러가 발생했을 때 전체 앱을 중단시키지 않도록 ErrorBoundary 컴포넌트를 감싸주었다.

function UserInfo({userID}) {
  const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
  switch (userNameLoadable.state) {
    case 'hasValue':
      return <div>{userNameLoadable.contents}</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'hasError':
      throw userNameLoadable.contents;
  }
}

만약 React.Suspense를 쓰기 싫다면 대신 useRecoilValueLoadable hook을 이용해 현재 state의 상태에 따라 적절하게 UI 처리를 해줄 수도 있다. (= try-catch문 대체!)

class Loadable

atom 및 selector의 현재 상태를 나타내며, statecontents라는 두 가지 프로퍼티를 가짐.

  • state: 'hasValue', 'hasError', 'loading' 중 하나.
  • contents: 위의 state 상태에 따라 다른 값을 담고 있음.
    • 1) state == 'hasValue'일 경우 실제 값
    • 2) state == 'hasError'일 경우 Error 객체
    • 3) state == 'loading'일 경우 Promise

3) 인자와 함께 호출 (selectorFamily)

만약 비동기 함수를 호출할 때 따로 인자를 넣어주고 싶다면 selector 대신 selectorFamily를 사용한다.

const userNameQuery = selectorFamily({
  key: 'UserName',
  get: userID => async () => {
    const response = await myDBQuery({userID});
    if (response.error) {
      throw response.error;
    }
    return response.name;
  },
});

function UserInfo({userID}) {
  const userName = useRecoilValue(userNameQuery(userID));	// 인자와 함께 호출
  return <div>{userName}</div>;
}

selector와 매우 유사하지만, get 프로퍼티가 인자(userID)를 받는 부분이 추가되어 async 함수 자체를 반환하도록 정의된다.

4) 동시 요청 (waitForAll, waitForNone)

비동기 호출을 여러 번 하는 경우, 순서대로 처리하다보면 성능에 영향을 줄 수 있다. 이때는 비동기 작업으로 얻는 배열 또는 객체를 get(waitForAll(...))으로 감싸줘서 병렬적으로 요청을 보내도록 할 수 있다.

const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    const friends = get(waitForAll(
      friendList.map(friendID => userInfoQuery(friendID))
    ));
    return friends;
  },
});

위 코드에서 friends 배열의 각 항목은 동시에 병렬적으로 처리가 진행되며, 모든 항목이 처리된 후 업데이트된 상태를 반환한다.

반면, waitForNone()이라는 비슷한 함수도 있는데, 이는 호출 즉시 Loadable 객체를 리턴한다. 실시간으로 요청에 대한 완료 여부를 파악할 수 있는 것이 차이점이다.


그동안 리액트 상태 관리 라이브러리로 Redux를 사용하면서 종종 불편한 점을 느끼곤 했었는데, Recoil은 정말 '이게 끝이라고?' 하는 생각이 들 정도로 가볍고 깔끔하게 느껴졌다.

좀 더 구체적인 장단점은 직접 사용해 봐야 알겠지만, 일단 첫인상은 굉장히 좋았다. 어서 새 프로젝트에 써보고 싶다.

Recoil 공식 문서

profile
공부용 블로그

0개의 댓글