본문 바로가기

[리액트] useAsync Hook with Suspense

요즘 게을러져서 글을 쓸 의욕이 사라진 거 같다...(반성ㅠㅠ)

요즘 코로나 때문에 계속 재택근무를 하고 있어서 집에 있지만 있는거 같지 않다ㅠ

퇴근하고 나서도 매번 계속 프로젝트 고민하고 있으니 더욱 여유가 없는 기분이다 (구차한 변명이다)

you don't know js 시리즈도 다시 읽으면서 정리하고 싶었는데 힘내야지


2주 전인가 팀 동료분께서 생각해내신 아이디어와 프로토타입을 들은 적이 있다.

 

현 프로젝트에서는 mobx local store에 비동기 함수가 함께 있는 부분으로 인해 코드가 엄청 길어졌다. 

 

물론 잘못된 것도 아니고 모든 로직이 State와 함께 있어서 가독성면에서 좋았던 점도 있다.

 

팀원분이 생각해내신 아이디어는 비동기함수가 쓰이는 컴포넌트에 마치 동기함수를 호출하듯이 비동기 함수를 호출하며 Suspense의 특성을 활용하여 비동기 함수가 완료되기 전까지 fallback props의 컴포넌트를 렌더링하는 것이다.

[참고] Suspense 동작 방식
children 컴포넌트 내부에 pending된 Promise가 있으면 Promise가 fulfill된 이후에 children 컴포넌트를 렌더링한다.

What is not immediately clear is that it also throws a promise if the data is not ready.
The React.Suspense boundary will catch this and will render the fallback until the component can be safely rendered.

그래서 생각해낸 아이디어는 useAsync hook을 구현하는 것인데 마치 React.useEffect()처럼 사용할 수 있도록 하는 것이다.

 

비동기 함수의 인자를 useAsync의 두번째 인자(배열)로 전달하여 해당 인자중 하나라도 값이 변경되면 다시 비동기 함수를 호출하고, 값이똑같으면 재호출 없이 어딘가 캐싱해둔 데이터를 리턴한다. 해당 기능을 위해 useMemo를 흉내낸 useMyMemo를 구현했다.

 

* asyncManager: 캐시를 관리하는 모듈, key: 컴포넌트 재사용을 위해 key를 받아야 함

const useMyMemo = (fn, deps, key) => {
  if (!deps || !Array.isArray(deps) || deps.length === 0) return fn();

  if (asyncManager.has(key)) {
    return asyncManager.update(fn, deps, key);
  }

  return asyncManager.mount(fn, deps, key);
};

그리고 비동기 함수 호출은 Promise를 리턴하게 하고, 해당 Promise또한 캐싱하여 완료되지 않으면 suspender를 throw하여 Suspense에게 Promise가 완료되지 않았음을 알린다.

const getPromiseData = (getPromise, key, force) => {
  if (promiseMem.has(key) && !force) {
    return promiseMem.get(key);
  }

  const promise = getPromise();

  const promiseData = {
    promise,
    status: "pending",
    result: null
  };

  promiseData.suspender = promise.then(
    (r) => {
      promiseData.status = "success";
      promiseData.result = r;
    },
    (e) => {
      promiseData.status = "error";
      promiseData.result = e;
    }
  );

  promiseMem.set(key, promiseData);
  return promiseData;
};

최종적으로 useAsync는 아래처럼 useMyMemo로 Promise를 받아 Promise를 캐싱할 getPromiseData에서 Promise가 완료될 때까지 suspender를 throw할 것이다.

const useAsync = (getPromiseCandidate, deps, key) => {
  useEffect(() => {
    return () => asyncManager.delete(key);
  }, []);

  const { nextValue, force } = useMyMemo(() => getPromiseCandidate, deps, key);

  const { status, suspender, result } = getPromiseData(nextValue, key, force);

  if (status === "pending") {
    throw suspender;
  }

  if (status === "error") {
    throw result;
  }

  if (status === "success") {
    return result;
  }
};

useAsync는 아래의 코드처럼 사용할 수 있다.

const ExampleComponent = ({id, _key: key}) => {
  const data = useAsync(() => getAsyncData(id), [id], key);
  
  return (
  	<div>{data}</div>
  )
}

코드를 보다보면 왜 key를 컴포넌트에서 넘겨주어야 하는지 의문이 들 수도 있다.

 

아이디어 내신 팀원분, 옆의 또다른 팀원분 그리고 나 이렇게 셋이서 가장 고민했던 부분인데 컴포넌트가 재사용되더라도 함수는 같지만 서로 다른 엘리먼트에서 호출된 비동기 함수의 결과값을 캐싱할 key를 어떻게 정하는지였다.

여기서 엘리먼트가 뭔지 모른다면 이전에 번역했던 Components, Element, Instance에 관한 글을 참고하면 좋다 😊
(조회수 올리려는 🐶수작이 맞다)

처음 팀원분이 공유해주신 프로토타입 버전은 데이터를 캐싱할 Map의 key를 두번째 파라미터 배열을 stringify해서 저장했었다.

그러나 컴포넌트 재사용이 불가능한 단점이 있어 더욱 고유한 key를 생성해줄 필요가 있었다.

 

어떻게든 useAsync 내부에서 token을 생성해주려고 해도 이 token을 state로 저장하려면 무조건 한 번은 렌더링되어야만 해서 (이러면 Suspense의 의미가 없어져서ㅠㅠ) 만들어 줄 수 없었다...

 

그래서 어쩔 수 없이 사용자가 넘기는 방식을 택하게 되었다.

 

그나마 리스트처럼 반복적인 엘리먼트 생성시에는 key값을 정하기 쉬울 수도 있겠다 생각했다.

<Suspense fallback="Now Loading...1">
  {[1, 2, 3].map((elem) => (
    <ExampleComponent
      id={elem}
      key={elem}
      _key={elem}
    />
  ))}
</Suspense>

렌더링 없이 처음부터 Promise를 throw할 방법을 알아봐야 겠다.

예시는 codesandbox에 있다. (이전에 실험한 코드도 있고 그래서 보기에 지저분할 수 있다.)

 


번외

react-use라는 다양한 커스텀 훅을 정의한 라이브러리에도 useAsync hook이 있다. 어떤 로직인지 살펴보았는데 이는 첫 마운트 후에 비동기 함수를 호출하므로 Suspense의 의미가 무색해진다.

번외 2

어떤 외국 개발자분(Andrei)도 Suspense의 동작 방식을 활용한 비동기 함수 호출을 생각하셨다. 더 많은 고민이 담겨 있는 글이라 정말 재밌게 읽고 소개한 로직대로 구현해보았다. Suspense 사용이 가능하며 지연 호출과 바로 호출이 가능하게 구현할 수 있었다.

 

여기서는 key값을 object-hash 라이브러리를 사용했었는데 결국 같은 데이터에 대해서는 같은 hash값을 산출하기 때문에 컴포넌트 재사용은 어려워보인다...

(출처) Practical data fetching with React Suspense that you can use today

번외 3

또다른 글에서는 컴포넌트 밖에서 먼저 실행하도록 하여 이를 promiseWrapper로 감싸서 promise를 throw하는 방법이 있다.

(출처) Experimental React: Using Suspense for data fetching