React | Recoil Selector, Selector에서 비동기 처리하기

이동욱·2024년 1월 18일
0
post-thumbnail

Recoil에서 Selector는?

Recoil에서 제공하는 selector는 atom의 상태값을 기반으로 다른 상태값을 효과적으로 관리할 수 있는 순수 함수이다.

바로 번역기 서비스를 만들어 selector를 사용해보자

💡 이 예제는 RN 환경에서 작성되었습니다.
Recoil 코드 중심이기 때문에 RN에 익숙하지 않아도 충분히 이해할 수 있습니다!


Selector 사용해보기

selector는 keyget을 필수로 받지만, set은 선택사항이다.

번역기의 경우 유저의 input을 기반으로 값을 최신화 하고 직접 값을 설정하는 경우는 없기 때문에 예제에서는 set을 사용하지 않았다.

const userInputAtom = atom({
  key: 'userText',
  default: '',
});

const translatedTextSelector = selector<string>({
  key: 'translatedText',
  get: ({get}) => {
    const userInput = get(userInputAtom).toLocaleLowerCase();
    if (userInput.includes('hello world')) {
      return '안녕 세상';
    } else if (userInput.includes('hello')) {
      return '안녕';
    } else {
      return userInput;
    }
  },
});

예제의 첫 번째 단계에서는 비동기 작업이 아닌 Atom에 의해 Selector 값이 최신화되는 것을 확인하기 위해, 유저가 'hello'를 입력하면 '안녕', 'hello world'를 입력하면 '안녕 세상' 값을 반환했다.

selector의 값을 사용하는 방법은 Atom과 동일하게 useRecoilState, useRecoilValue, useRecoilSetValue를 사용하여 값을 가져올 수 있다.

const [userText, setUserText] = useRecoilState(userInputAtom);
const translatedText = useRecoilValue(translatedTextSelector);

단, selector의 값을 가져올때는 selector의 set프로퍼티를 선언했는지 여부에 따라 사용할 수 있는 hook이 달라진다.

만약 get만 선언한 경우, Selector의 상태값은 읽기 전용(read-only)이므로 useRecoilValue를 사용하여 값을 불러와야 한다. 반면 set을 설정했다면 모든 훅을 사용할 수 있다.

위 코드를 실행하면 아래와 같은 화면이 나타난다.

입력값(atom)에 따라 번역된 결과(selector)가 나타나는 것을 확인할 수 있다.


Selector에서 비동기 처리하기

Atom 값에 따라 Selector의 값이 어떻게 변하는지 확인했고.
이제 Selector에 번역 API(파파고 API 사용)를 연동하여 Selector에서 비동기 처리를 어떻게 하는지 확인해보자.

const translatedTextSelector = selector<string>({
  key: 'translatedText',
  get: async ({get}) => {
    const userInput = get(userInputAtom).toLocaleLowerCase();
    const translatedText = await translatFromKoToEng(userInput);
    return translatedText;
  },
});

translatFromKoToEng 함수에서 번역작업을 하고 이를 비동기로 받아 return해 주었다.

그리고 실행하면 아래와 같은 화면이 나타났다.

입력할 때마다 비동기 통신으로 번역 결과를 가져오지만, 깜빡이는 현상이 생겼다.

이유를 찾아보니 selector에는 두 가지 특징이 있음을 알 수 있었다.


1. selector는 Suspense와 함께 작동하도록 설계되어 있다.

나는 App쪽에서 번역기 컴포넌트를 Suspense로 아래와 같이 랩핑해 쓰고 있었는데

<RecoilRoot>
    <Suspense
       fallback={<ActivityIndicator />}>
       <Translator />
    </Suspense>
</RecoilRoot>

Selector에서 비동기 작업 중에는 Promise를 throw하게 되어, 새로운 입력값이 들어올 때마다 fallback <ActivityIndicator/>을 렌더링하는 Suspense가 작동하고 있었다. 때문에 입력할 때마다 깜빡이는 현상이 발생했다.

하지만 처음에 쓴걸 지우고 다시 'Hello World'를 입력할 때는 fallback 화면을 보여주지 않고 바로 번역 결과를 보여주는데 이 이유는


2. Selector는 캐싱 기능이 있다.

selector는 캐싱 기능을 내재하고 있기 때문이다.

Selector는 memoization(메모이제이션)을 사용하여 결과 값을 캐싱하고, 같은 인자에 대한 재계산을 피한다. 그렇기 때문에 두번째 Hello World 값을 입력했을 때 Selector는 다시 비동기 작업을 거치지 않고 캐싱된 값을 반환해 깜빡이는 현상이 없었다.


그럼 위와 같은 깜빡이 없이 어떻게 작업할까?

Recoil에서 제공하는 useRecoilStateLoadableuseRecoilValueLoadable 훅을 활용하여 비동기 작업을 처리했다. 이 훅들은 Promise를 throw하지 않고 대신 Loadable 객체를 반환한다.

Loadable 객체의 속성

  • state : selector의 상태를 hasValue, hasError, loading으로 나타낸다.
  • contents :
    • state가 hasValue인 경우: 비동기 작업이 완료된 값
    • state가 hadError인 경우: Error 객체
    • state가 loading인 경우: Promise


Doc에서 제공하는 useRecoilValueLoadable를 활용한 예시 코드를 보면 이해하기 쉽다.

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


이를 활용하여 기존의 Translator 컴포넌트를 수정했다.

function Translator(): JSX.Element {
  const [userText, setUserText] = useRecoilState(userInputAtom);

	// 기존 selector를 부르기 위해 사용했던 hook
  // const translatedText = useRecoilValue(translatedTextSelector);
  const translatedText = useRecoilValueLoadable(translatedTextSelector);

	// 최신 비동기 처리가 완료시 value값을 담기 위한 ref
  const latestTranslatedText = useRef('');

  useEffect(() => {
    if (translatedText.state === 'hasValue') {
      latestTranslatedText.current = translatedText.contents;
    }
  }, [translatedText.state]);

  const onChangeText = async (inputValue: string) => {
    setUserText(inputValue);
  };

  return (
      <View style={styles.screen}>
        <TextInput
          value={userText}
          onChangeText={onChangeText}
          style={styles.input}
        />
        <Text>결과 :</Text>
        <Text>
          {translatedText.state === 'hasValue'
            ? translatedText.contents
            : latestTranslatedText.current}
        </Text>
      </View>
  );
}

여기서는 useRecoilValueLoadable를 통해 Selector의 상태를 확인하고, 'hasValue'인 경우에는 Selector의 값을 사용하고, 그 외의 경우에는 이전에 저장한 값을 표시하도록 수정했다.

이로써 번역 결과가 업데이트되는 동안 깜빡임을 제거할수 있었다.

흠 근데 api요청 횟수가 너무 많네........



참고
recoil doc

profile
프론트엔드

0개의 댓글