Recoil에서 Selector는?
Recoil에서 제공하는 selector는 atom의 상태값을 기반으로 다른 상태값을 효과적으로 관리할 수 있는 순수 함수이다.
바로 번역기 서비스를 만들어 selector를 사용해보자
💡 이 예제는 RN 환경에서 작성되었습니다.
Recoil 코드 중심이기 때문에 RN에 익숙하지 않아도 충분히 이해할 수 있습니다!
selector는 key
와 get
을 필수로 받지만, 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)가 나타나는 것을 확인할 수 있다.
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에서 제공하는 useRecoilStateLoadable
와 useRecoilValueLoadable
훅을 활용하여 비동기 작업을 처리했다. 이 훅들은 Promise를 throw하지 않고 대신 Loadable 객체를 반환한다.
Loadable 객체의 속성
state
: selector의 상태를 hasValue, hasError, loading으로 나타낸다.contents
:
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