Please enable JavaScript to view the comments powered by Disqus.

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 상태는 쓰기가 불가능한 상태다. 즉 useSetRecoilStateuseRecoilState 훅의 파라미터로 넣을 수 없다. 하지만 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상태를 직접 초기화시키면 되는 것 아닌가?라는 질문이 나올 수 있다. 물론 그렇게 초기화 로직을 구현해도 상관없다. 하지만 코드의 가독성과 유지 보수성에 차이가 올 것이다. 파생된 상태는 여러 개의 atomselector 상태를 기반으로 복잡한 로직을 거쳐 만들어질 수도 있다. 그렇다면 초기화 시키는 로직 역시 여러 과정이 필요할 수 있다. 그런데 reset이 여러 곳에서 필요하다면 그 로직을 어떻게 모듈화할 것인가? 커스텀 훅 함수를 사용할 수도 있겠지만 selector의 set 콜백을 사용하면 더 간편하다. 상태 업데이트를 위한 기능도 제공하며 자연스럽게 모듈화도 되는 셈이니 말이다.

Redux의 reducer 역할은 쓰기 가능한 selector보다는 useRecoilTransactionuseRecoilCallback에 커스텀 훅 함수를 조합해서 구현하는 편이 좋다.

useRecoilTransaction을 사용한 Recoil 상태 업데이트

useRecoilTransactionuseCallback처럼 컴포넌트 안에서 사용할 수 있는 함수를 만드는 훅 함수다. 파라미터로 팩토리 함수를 전달하는 방식을 사용해 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을 사용하라는 가이드를 공식 문서에서 주고 있다.

참고 자료