Recoil로 비동기 작업 수행하기

시소·2024년 4월 11일
0
post-thumbnail

들어가며

상태 관리를 하다 보면 비동기 작업이 필요해지는 순간이 발생하곤 한다.
예를 들면 서버와 같은 외부로부터 데이터를 가져와서 상태로 저장해야 할 때, 비동기 요청을 보내고 데이터가 로드되는 동안 기다렸다가, 그 결과가 준비되는 대로 컴포넌트를 업데이트 하고 싶은 경우가 그렇다.

사실 Recoil 말고도 React-QuerySWR와 같은 서버 상태 관리에 특화된 라이브러리가 있긴 하다. 이들은 비동기 작업부터 데이터 캐싱, 인터셉트 및 중단, 페이지네이션 및 무한 스크롤 등 데이터 요청과 관련해 여러 편리한 기능을 제공하기 때문에 주된 작업 목적에 따라 적절한 도구를 활용하는 것이 좋을 수 있다.

이에 반해 Recoil은 좀 더 클라이언트 측 상태 관리에 특화되었다고 할 수 있는데, 비동기 작업을 지원하고 있긴 하지만 앞선 라이브러리들과 비교하면 비동기 데이터 작업에 특화되었다고 부르긴 어렵다.

오늘 나는 Recoil을 공부하다가 이러한 비동기 작업이 필요한 시나리오가 있을 때 Recoil 만을 사용해 해결할 수 있는가 라는 단순한 궁금증으로 시작해 알아보게 된 내용에 대해 정리해 보았다.


Recoil에서 제공하는 비동기 작업과 관련된 기능들

리코일은 리액트에서 사용되는 hook과 같은 방식으로 상태 관리를 할 수 있도록 도와주는 여러 함수를 제공한다.

기본적으로는 atom을 선언한 다음 그 뒤로 주로 useRecoilState()useRecoilValue() 같은 훅으로 상태를 읽어오거나 수정하게 되는데, 자주 사용되는 이 훅 외에도 비동기 작업에 도움이 될만한 몇 가지 기능들이 있어 찾아보게 되었다.

1. atom()

API Reference: atom()

다음은 Atom을 선언하는 함수의 정의이다.

function atom<T>({
  key: string,
  default?: T | Promise<T> | Loadable<T> | WrappedValue<T> | RecoilValue<T>,
  effects?: $ReadOnlyArray<AtomEffect<T>>,
  dangerouslyAllowMutability?: boolean,
}): RecoilState<T>

2번째 매개변수인 default는 Atom의 초기값을 지정하기 위한 부분인데, 가능한 타입 중 Promise 와 Loadable 이 있다는 점을 활용해 볼 것이다.

2. Loadable

API Reference: class Loadable

리코일의 Atom 혹은 Selector의 현재 상태를 나타내는 클래스이다.

statecontents 라는 2가지 프로퍼티를 가지며, Loadable 이 나타내는 값인 contents의 상태에 따라 state는 아래와 같은 값 중 하나를 가진다.

  • "hasValue": contents가 실제 값을 가지고 있음
  • "hasError": contents를 가져오는 데 Error가 발생하였음
  • "loading": contents를 불러오는 중임

3. useRecoilStateLoadable()

API Reference: useRecoilStateLoadable()

비동기 Selector의 값을 읽는 데 사용되는 훅이다.

이름이 유사한 useRecoilState() 훅과는 다르게, 비동기 Selector에서 값을 읽어도 ErrorPromise를 throw 하지 않고 대신 리코일에서 제공하는 Loadable 객체를 반환한다.

4. useRecoilCallback()

API Reference: useRecoilCallback()

이 훅은 React의 useCallback() 훅과 비슷하지만, 여기에 리코일 상태를 다룰 수 있는 snapshot 과 setter 함수에 접근하여 콜백을 작성할 수 있는 기능이 추가되었다.

비동기 로직이나 복잡한 상태 업데이트 패턴이 필요할 때, 이 함수를 활용하면 좀 더 상태 관리 로직을 컴포넌트 렌더링 로직과 분리하여 해당 작업만 캡슐화 한다던가 여러곳에서 재사용 하는 등의 이점을 가져올 수 있다.


이제 위에서 소개한 기능을 활용해 지금부터 리코일에서 비동기 작업을 수행하는 과정에 대해서 알아보도록 하자. 🏃‍♀️🏃‍♂️💨


예제 코드

이번에는 앞서 알아본 훅들의 사용법을 익혀 보는게 주 목적이라 시나리오는 최대한 간소화 하여 진행하였다.

1. Atom 생성

테스트를 위해 CRA를 이용해 리액트 프로젝트를 하나 새로 생성한 뒤, App.js 및 index.js 만 남기고 필요 없는 파일은 모두 삭제하였다. 그런 다음 npm i recoil 명령으로 리코일만 설치한 다음 아래와 같이 새로운 Atom을 만들어 주었다.

// src/states/index.js

export const asyncCounter = atom({
  key: "asyncCounter",
  default: selector({
    key: "asyncCounter/Default",
    get: async ({ get }) =>
      await new Promise((resolve) => {
        setTimeout(() => {
          resolve(0);
        }, 2000);
      }),
  }),
});

(실전에서 이런 코드가 사용될 일이 있을까 싶지만..) 위에서는 Promise를 활용해 2초 뒤에 0으로 초기화 되는 asyncCounter 라는 이름의 카운터 변수 하나를 선언하였다. 이제 이 카운터 상태를 컴포넌트에서 사용하려면 어떻게 해야 하는지 알아보자.

2. useRecoilState()로 비동기 상태 읽고 쓰기

기존 방법 그대로, useRecoilState() 훅을 갖고서도 비동기 상태를 읽거나 쓰거나 할 수 있다.

// src/components/Counter.jsx

const Counter = () => {
  const [count, setCount] = useRecoilState(asyncCounter);

  const handleAdd = async () => {
    await new Promise((resolve) => {
      setTimeout(() => {
        setCount((c) => c + 1);
      }, 2000);
    });
  };

  return (
    <>
      <p>Current count: {count}</p>
      <button onClick={() => handleAdd()}>+</button>
    </>
  );
};

export default Counter;

그런 다음, 해당 상태를 구독하는 컴포넌트를 렌더링 하기 위해 App.js에서 <Counter> 컴포넌트를 불러온다. 여기서 React 18의 <Suspense>로 컴포넌트를 래핑하고 있다. 이는 상태의 디폴트 값이 Promise로 전달되는데 이 값이 resolve 되기 전에 pending 중이라는 상태를 나타내기 위함이다.

// src/App.js

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Counter />
    </Suspense>
  );
}

export default App;

이제 브라우저에서 확인해 보면, 처음 접근했을 때는 아직 초기값이 resolve 되기 전이므로 로딩 메시지가 표시되었다가 2초가 지나면 <Counter> 컴포넌트의 내용이 제대로 표시된다. 카운트 값을 증가시키기 위해 "+" 버튼을 클릭했을 때도 2초가 지난 뒤에 값이 업데이트된다.

3. Loadable 활용

useRecoilStateLodable() 훅을 사용하면 Loadable 타입의 상태가 반환되어 비동기 작업의 현재 진행 상태를 추적하기가 좀 더 수월하다.

이전에 작성한 Counter 컴포넌트를 약간 수정해 주었다. 또한 App.js 에서 사용했던 <Suspense>는 이제 코드에서 제거해도 상관 없다.

const Counter = () => {
  const [count, setCount] = useRecoilStateLoadable(asyncCounter);

  const handleAdd = async () => {
    await new Promise((resolve) => {
      setTimeout(() => {
        setCount((c) => c + 1);
      }, 2000);
    });
  };

  return (
    <>
      <p>Current state: {count.state}</p>
      {count.state === "hasValue" && (
        <>
          <p>Current count: {count.contents}</p>
          <button onClick={() => handleAdd()}>+</button>
        </>
      )}
    </>
  );
};

export default Counter;

이제 Loadable 객체에서 state 프로퍼티를 통해 비동기 상태의 진행 상황을 확인할 수 있고, 값이 실제 사용 가능하게 되었을 때 contents로 접근해 화면에 보여주면 된다.

단, 여기서 state는 한 번 "loading" 에서 "hasValue" 상태가 되면 그 뒤로 setter 함수를 이용해 상태 값을 업데이트 한다고 해도 "loading" 상태로 돌아가지는 않는다. 이는 리코일에서 기본 동작인데, 만약 강제적으로 다시 로딩 상태로 업데이트 해야만 한다면 아래와 같이 Selector를 작성할 수도 있기는 하다.

export const asyncCounterLoadable = selector({
  key: "asyncCounterLoadable",
  get: ({ get }) => get(asyncCounter),
  set: ({ set }, newValue) =>
    set(asyncCounter, async () => {
      RecoilLoadable.loading(); // state가 lodaing으로 변경된다
      return await new Promise((resolve) => {
        // ...
      });
    }),
});

4. Error 처리

비동기 작업을 수행하다 에러가 발생하는 경우에 대비해 Error Handling 코드를 추가할 수 있다.
아래에서는 디폴트 값을 가져오는 도중 일부러 에러가 발생하는 상황을 재현해 보았다.

// src/states/index.js

export const asyncCounter = atom({
  key: "asyncCounter",
  default: selector({
    key: "asyncCounter/Default",
    get: async ({ get }) => {
      try {
        return await new Promise((resolve, reject) => {
          setTimeout(() => {
            // resolve(0);
            reject(new Error("Error occurred"));
          }, 2000);
        });
      } catch (error) {
        throw error;
      }
    },
  }),
});
// src/components/Counter.jsx

const Counter = () => {
  const [count, setCount] = useRecoilStateLoadable(asyncCounter);

  // ...

  return (
    <>
      <p>Current state: {count.state}</p>
      {count.state === "hasValue" && (
        <>
          <p>Current count: {count.contents}</p>
          <button onClick={() => handleAdd()}>+</button>
        </>
      )}
      {count.state === "hasError" && <p>{count.contents.toString()}</p>}
    </>
  );
};

export default Counter;

이와 같이 이제 비동기 작업을 수행하던 도중 오류가 발생하게 되더라도 개발자가 적절한 처리를 추가할 수 있다.

5. useRecoilCallback() 활용

이전에 작성했던 <Counter> 컴포넌트에서 다시 약간 수정해 주었다.
상태 읽기는 useRecoilValueLodable()에서 이루어지고, 업데이트는 useRecoilCallback() 안에서 수행한다.

// src/components/Counter.jsx

const Counter = () => {
  const count = useRecoilValueLoadable(asyncCounter);

  const handleAdd = useRecoilCallback(
    ({ snapshot, set }) =>
      async () => {
        const prev = await snapshot.getPromise(asyncCounter);
        const delayedValue = await new Promise((resolve) => {
          setTimeout(() => {
            return resolve(prev + 1);
          }, 2000);
        });
        set(asyncCounter, delayedValue);
      },
    []
  );

  return {...};
};

export default Counter;

콜백에서 2가지 매개 변수를 받고 있는데, 리코일에서 제공하는 Snapshot과 state setter가 있다.

먼저 snapshot 객체는 리코일 상태의 변경 불가능한(immutable) 스냅샷을 가지고 있다. 이는 전역 리코일 상태를 observe 하거나 검사/관리 하기 위한 목적으로 사용된다. 이 값에 접근은 getPromise() 혹은 getLodable()을 통해 가능한데 여기서는 전자를 사용하였다.

그런 다음 업데이트 될 값이 정해지면 set() 함수를 호출하면 된다.


마치며

리코일의 강점 중 하나인 훅 기반의 간단한 API를 활용하여 기존보다는 다소 복잡해진(?) 상태 관리 패턴을 구축할 수 있는 몇가지 특수한 훅들의 활용 방법에 대해서 알아 보았다. 이번 경험은 리코일을 활용한 새로운 상태 관리 패턴에 대해 익혀볼 볼 수 있는 기회였다.

참고한 예제 문서에서는 좀 더 많은 예제가 나와 있는데 일반적인 상태 관리보다는 좀 더 체계적이고 효율적인 접근을 가능케 하는 것으로 보이니 참고해볼 수 있겠다. 그렇지만 나의 경우에는 만약 더 복잡하고 빈도 높은 비동기 상태 관리가 필요하다면 서문에 언급했던 서버 상태 관리에 특화된 라이브러리들을 활용할 것 같다.

참고 링크

profile
배우고 익힌 것을 나만의 언어로 정리하는 공간 ..🛝

0개의 댓글