Recoil의 쓰기 가능한 셀렉터
Writable selector
파생된 상태를 만드는 단방향 흐름의 selector
Recoil은 selector API를 제공한다. React의 useMemo
, Redux의 selector 처럼 다른 상태를 기반으로 파생된 상태(derived state) 만들어내는 역할을 한다. 간단한 예를 들면 작업 목록에서 완료된 작업만 추려내고 싶을 때 사용할 수 있다.
import { atom, selector } from 'recoil';
type Job = { content: string; isDone: boolean };
const jobListState = atom<Job[]>({
key: 'jobListState',
default: [],
});
const completedJobsSelector = selector({
key: 'completedJobsSelector',
get: ({ get }) => {
const jobList = get(jobListState);
const completed = jobList.filter((v) => v.isDone);
return completed;
},
});
쓰기까지 가능한 양방향 흐름을 가진 selector
그런데 Recoil은 “쓰기 가능한 selector 상태”, writable selector라는 개념을 구현해 두었다. React를 사용해 온 사람이라면 ‘selector’라는 명사는 파생된 상태를 의미하며, 단방향 흐름을 가진 데이터라는 인식을 가지고 있어서 다소 의아하게 느껴질 수 있다. selector인데 양방향 데이터 흐름을 가진 것이라니?
예를 들어 속도 정보를 관리해야 할 때 원본 데이터는 마일(mile)/h인데 화면 상에는 킬로미터(km)/h로 표시해야 하는 경우가 있을 수 있다. 그 관계를 atom과 selector로 표현하면 다음과 같다.
const mphState = atom({
key: 'mphState',
default: 0,
});
const kphState = selector({
key: 'kphState',
get: ({ get }) => {
const mph = get(mphState);
return mph * 1.609; // 1마일은 약 1.609킬로부터
},
});
이렇게 작성한 kphState
상태는 쓰기가 불가능한 상태다. 즉 useSetRecoilState
나 useRecoilState
훅의 파라미터로 넣을 수 없다. 하지만 selector에 set
속성을 추가한다면 쓰기가 가능해진다. 컴포넌트에서 useSetRecoilState
을 사용해 kphState
의 setter 함수를 만들면 킬로미터/h 값을 넘겨서 mphState
상태를 업데이트할 수 있게 되는 것이다.
export const kphState = selector<number>({
key: 'kphState',
get: ({ get }) => {
const mph = get(mphState);
return mph * 1.609;
},
set: ({ set }, newValue) => {
if (typeof newValue === 'number') {
set(mphState, newValue / 1.609); // 킬로미터를 마일로 변환한다
}
},
});
const KPHInput: React.FC<Props> = () => {
const setKph = useSetRecoilState(kphState);
return (
<input type="number" onChange={(e) => setKph(parseInt(e.target.value))} />
);
};
쓰기 가능한 selector의 초기화 기능
kphState
selector의 set
속성의 두번째 파라미터인 newValue
의 타입을 확인해보면 number | DefaultValue
로 되어있음을 알 수 있다. DefaultValue
타입은 set
콜백이 setter(업데이트) 함수를 통해 호출되었는지, resetter(초기화) 함수로 호출되었는지 구분해주는 역할을 한다. 컴포넌트에서 useSetRecoilState
를 사용해서 만들었다면 setter 함수가 되고, useResetRecoilState
를 사용해서 만들었다면 resetter 함수가 된다.
export const kphState = selector<number>({
key: 'kphState',
get: ({ get }) => {
const mph = get(mphState);
return mph * 1.609;
},
set: ({ set, reset }, newValue) => {
// selector의 resetter를 호출하면 newValue는 DefaultValue 인스턴스가 된다.
if (newValue instanceof DefaultValue) {
reset(mphState);
} else if (typeof newValue === 'number') {
set(mphState, newValue / 1.609);
}
},
});
const ResetKPHButton: React.FC<Props> = () => {
const resetKph = useResetRecoilState(kphState); // resetter 함수 생성
return <button onClick={resetKph}>Reset KPH</button>;
};
언제 사용하면 좋은가?
쓰기 가능한 selector에 대한 개념은 파악이 되었다. 그런데 더 중요한 질문이 남았다. 그래서 왜, 언제 사용하면 좋을까이다.
위의 예제에 있는 것처럼 마일-킬로미터 같은 관계를 차치하고 생각하면 쓰기 가능한 selector는 그저 “컴포넌트 외부에 모듈 형태로 선언 가능한 Recoil 상태 업데이트 콜백 함수”라는 개념이 되긴 한다. 하지만 완전히 자유롭게 아무 일이나 할 수는 없다. selector
함수의 타입 선언을 보면 get
콜백이 필수 값이라서 set
만 선언할 수는 없으며, get
콜백이 리턴하는 타입과 set
콜백에 전달되는 newValue
의 타입이 일치해야 한다.
function selector<T>({
key: string,
get: ({
get: GetRecoilValue,
getCallback: GetCallback,
}) => T | Promise<T> | RecoilValue<T>, // 타입 T에 해당하는 값, T를 리턴하는 Promise,
set?: (
{
get: GetRecoilValue,
set: SetRecoilState,
reset: ResetRecoilState,
},
newValue: T | DefaultValue, // setter로 전달하는 값은 T 타입 값이어야 한다.
) => void,
})
Redux의 reducer 함수처럼 전달할 값과 업데이트할 상태를 자유롭게 구현하라는 용도로 제공된 기능은 아님을 알 수 있다. T
타입을 가진 파생된 상태를 만들어야 하고, 동시에 T
타입의 값을 전달해서 다른 상태를 업데이트할 필요가 있을 때 쓰기 가능한 selector를 사용할 필요가 생긴다. 또는 파생된 상태를 초기화시키고 싶을 때 유용하게 사용할 수 있다.
그런데 초기화라면 굳이 selector의 set
콜백을 사용할 필요 없이 selector의 소스가 되는 atom
상태를 직접 초기화시키면 되는 것 아닌가?라는 질문이 나올 수 있다. 물론 그렇게 초기화 로직을 구현해도 상관없다. 하지만 코드의 가독성과 유지 보수성에 차이가 올 것이다. 파생된 상태는 여러 개의 atom
과 selector
상태를 기반으로 복잡한 로직을 거쳐 만들어질 수도 있다. 그렇다면 초기화 시키는 로직 역시 여러 과정이 필요할 수 있다. 그런데 reset이 여러 곳에서 필요하다면 그 로직을 어떻게 모듈화할 것인가? 커스텀 훅 함수를 사용할 수도 있겠지만 selector의 set
콜백을 사용하면 더 간편하다. 상태 업데이트를 위한 기능도 제공하며 자연스럽게 모듈화도 되는 셈이니 말이다.
Redux의 reducer 역할은 쓰기 가능한 selector보다는 useRecoilTransaction
과 useRecoilCallback
에 커스텀 훅 함수를 조합해서 구현하는 편이 좋다.
useRecoilTransaction
을 사용한 Recoil 상태 업데이트
useRecoilTransaction
은 useCallback
처럼 컴포넌트 안에서 사용할 수 있는 함수를 만드는 훅 함수다. 파라미터로 팩토리 함수를 전달하는 방식을 사용해 Recoil의 상태를 관리할 수 있는 인터페이스를 제공한다.
interface TransactionInterface {
get: <T>(RecoilValue<T>) => T; // Recoil 상태를 가져온다
set: <T>(RecoilState<T>, (T => T) | T) => void; // 상태를 업데이트한다
reset: <T>(RecoilState<T>) => void; // 상태를 초기화한다
}
// Args가 실제로 사용할 콜백의 파라미터들이 된다.
function useRecoilTransaction_UNSTABLE<Args>(
callback: TransactionInterface => (...Args) => void,
): (...Args) => void
아래는 useRecoilTransaction을 사용하는 예제다. 만들어진 함수는 goForward(100)
같은 방식으로 호출해서 positionState
상태를 업데이트하는데 쓸 수 있다.
const goForward = useRecoilTransaction_UNSTABLE(
({ get, set }) =>
(distance) => {
const heading = get(headingState);
const position = get(positionState);
set(positionState, {
x: position.x + cos(heading) * distance,
y: position.y + sin(heading) * distance,
});
},
);
useRecoilCallback
이 Recoil 상태에 접근하기 위해 snapshot
을 사용해야 하는 것에 비하면 비교적 간편하게 상태를 관리할 수 있다. 다만 리턴 값을 넘길 수 없으며, selector 상태와 비동기 atom은 사용할 수 없는 등의 한계가 있다. 그리고 상태 업데이트 외의 side effect를 만들고 싶다면 useRecoilTransaction
이 아닌 useRecoilCallback
을 사용하라는 가이드를 공식 문서에서 주고 있다.