커스텀 훅으로 React Suspense 활용하기

웅로그·2024년 11월 1일
0

1. React Suspense란?

React Suspense는 컴포넌트의 렌더링을 중단하고 데이터 비동기 처리를 기다릴 수 있도록 하는 기능.
Suspense를 사용하면 데이터 로딩 상태를 선언적으로 처리할 수 있다.

2. 기존 데이터 로딩 패턴의 문제점

2-1. 기존 데이터 로딩 패턴

// 기존의 방식
function Profile() {
  // data와 로딩 상태 관리를 위한 변수 선언
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  // 데이터 로딩 후 재렌더링을 위한 useEffect 사용
  useEffect(() => {
    fetchUserData()
      .then(userData => setData(userData))
      .finally(() => setLoading(false));
  }, []);

  // 데이터 로딩 중 로딩 화면을 보여주기 위한 조건부 렌더링 사용
  if (loading) return <div>로딩중...</div>;
  
  return <div>안녕하세요, {data.name}!</div>;
}

기존 데이터 로딩 패턴은 데이터 로딩 후 로딩된 데이터를 반영할 때 아래와 같은 문제가 있다.
1. useState로 data와 loading 상태를 직접 관리해야 함.
2. 로딩 중 다른 화면을 보여주기 위해 조건부 렌더링을 사용해야 함.
3. useEffect를 활용하여 재렌더링도 해주어야 함.

2-2. Suspense를 사용한 코드

하지만 Suspense를 사용하면 위의 문제를 해결하고 아래와 같이 선언적으로 코드를 작성할 수 있다.

// 데이터 fetching을 위한 resource 생성
const resource = {
  read() {
	// 1. Promise를 throw하여 Suspense에게 "아직 로딩 중"이라고 알림
    throw fetchUserData().then(userData => {
      // 2. Promise가 resolve되면 데이터를 저장
      this.userData = userData;
    });

    return this.userData;
  }
};

// 사용하는 곳
function App() {
  const data = resource.read();
  
  return (
    <Suspense fallback={<div>로딩중...</div>}>
      <div>안녕하세요, {data.name}!</div>
    </Suspense>
  );
}

Suspense가 작동하기 위해서는 위와 같이 promise를 throw 해주어야 한다.
이를 통해 promise가 처리될 때까지 렌더링을 멈출 수 있다.
Suspense는 fallback이라는 UI 설정 prop을 통해 로딩하는 동안 보여줄 화면을 prop으로 전달해 보여줄 수 있다.
Suspense를 사용한 코드에서는 loading 상태 관리가 Suspense로 위임되고, 조건부 렌더링도 해줄 필요가 없어져 선언적으로 데이터를 로딩해서 사용할 수 있게 된다.

3. React Suspense로 렌더링 중단하기 활용

React Suspense를 쓸 때 Promise 객체를 throw하여 Promise가 완료될 때까지 컴포넌트 렌더링을 중단할 수 있다.
이는 React가 javascript의 예외 처리 메커니즘을 이용하여 코드 실행을 중단시키는 것이다.
아래는 Promise를 반환하는 비동기 함수를 받아서, React Suspense와 함께 동작할 수 있도록 Promise의 상태에 따라 데이터를 반환하거나 Promise를 throw하는 함수를 생성하는 래퍼(wrapper) 함수이다.

const wrappedPromise = <DataType>(
  promiseFn: PromiseFn<DataType>,
  ...args: any[]
): DataReadFn<DataType> => {
  // 데이터 상태를 관리하는 변수들
  let status = Status.pending;
  let result: DataType;
  let error: Error;
  
  // Promise 실행 및 상태 업데이트
  const suspender = promiseFn(...args)
    .then(response => {
      result = response;
      status = Status.success;
    })
    .catch((e: Error) => {
      error = e;
      status = Status.error;
    });
  
  // 상태에 따라 다른 동작을 하는 함수 반환
  function dataReaderFn(): DataType {
    if (status === Status.pending) {
      throw suspender; // 데이터 로딩 중: Promise를 throw하여 렌더링 중단
    } else if (status === Status.error) {
      throw error;     // 에러 발생: 에러를 throw
    }
    return result;     // 로딩 완료: 데이터 반환
  }
  return dataReaderFn;
};

wrappedPromise는 다음과 같이 동작한다.
1. Promise 함수를 실행하고 그 결과를 추적하는 클로저를 생성.
2. Promise의 상태에 따라 다르게 동작하는 함수(dataReaderFn)를 반환.
3. 데이터가 준비되지 않았을 때는 Promise를 throw하여 Suspense가 렌더링을 중단하도록 함.

wrappedPromise의 반환값을 Suspense로 감싸져 있는 하위 컴포넌트의 prop으로 전달하고 하위 컴포넌트에서 실행해주어야 한다.
아래는 그 예시이다.

// 상위 컴포넌트
const App = () => {
  const dataReader = useMemo(
    () => wrappedPromise(fetchData, args),
    [fetchData, args]
  );
  
  return (
  	<Suspense fallback={<Loading />}>
    <ExampleComponent fetchData={dataReader} />
  	</Suspense>
  )
}

);

// 하위 컴포넌트
const ExampleComponent = ({ fetchData }) => {
  // 컴포넌트 내부에서 한 번만 실행
  const data = fetchData();
  return <div>{data}</div>;
  
  return null;
};

Suspense가 동작하면 App 컴포넌트는 fetchSomeData Promise가 완료될 때까지 계속해서 재렌더링되고 이로 인해 fetchSomeData도 다시 호출되고 또 재렌더링이 일어나 fetchSomeData가 무한호출되어 하위 컴포넌트가 렌더링이 계속 막히기 때문이다.
wrappedPromise에서 Promise를 throw 하면 Suspense에 의해 App 컴포넌트가 재렌더링 되고 dataReader가 다시 실행되며 클로저를 통해 wrappedPromise의 status를 추적해 Promise가 완료될 때까지 Promise를 throw하여 하위 컴포넌트의 렌더링을 멈출 수 있다.
이를 통해 비동기 처리가 완료될 때까지 하위 컴포넌트의 렌더링을 막고 로딩 화면을 보여줄 수 있는 것이다.

profile
프론트엔드 개발자입니다.

0개의 댓글