React Suspense + ErrorBoundary 개념과 활용

juyeong-s·2023년 4월 25일
4

React

목록 보기
8/8
post-thumbnail

개인 학습용으로 정리한 글입니다.

Suspense

Suspense는 children의 로딩이 완료될 때까지 다른 컴포넌트를 대신 보여줄 수 있게 해준다. REST API와 같이 비동기로 데이터를 가져오는 작업에서 활용도가 높다.

어떤 컴포넌트가 필요한 데이터가 아직 준비가 되지 않은 상태라는 것을 리액트에게 알려주는 매커니즘이다.

Suspense의 기능 및 활용하기

콘텐츠를 한번에 공개한다.

Suspense의 children 중 하나만 데이터가 준비되지 않은 상태여도 fallback 컴포넌트를 대신 렌더링한다.

가장 가까운 상위 Suspense 폴백을 표시한다.

컴포넌트가 데이터를 읽는 중일 때 가장 가까운 상위 Suspense의 폴백이 보여진다. 여러 Suspense를 중첩해서 로딩 시퀀스를 구성할 수 있다.

useDeferredValue: 새로운 콘텐츠가 로드되는 동안 로딩 UI가 아니라 이전 콘텐츠를 보여주기

다음 예제는 query 상태가 업데이트되는 동안 Loading UI를 보여주고 있다.

import { Suspense, useState } from 'react';
import SearchResults from './SearchResults.js';

export default function App() {
  const [query, setQuery] = useState('');
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <SearchResults query={query} />
      </Suspense>
    </>
  );
}

사용자 경험 개선을 위해 useDeferredValue를 사용해서 로딩 UI가 아닌 이전 상태를 보여주도록 해보자.

export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <div style={{ opacity: isStale ? 0.5 : 1 }}>
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </>
  );
}

검색 자동완성 기능에 활용하면 좋을 것 같다.

startTransition: 이미 렌더링된 컴포넌트가 로드 중인 데이터로 인해 폴백 UI로 대체되어 다시 렌더링되는 것을 방지하기

말이 어려우니 다음 예제를 또 보자. 아래는 Router 컴포넌트의 Layout 컴포넌트가 이미 렌더링 된 상태고, 버튼을 누르면 Router가 page에 따라 content를 업데이트하게 된다. 그에 따라서 Layout 컴포넌트가 화면에서 없어지고 폴백 UI가 렌더링되었다가 데이터 로드가 완료되면 다시 Layout이 화면에 나타난다.

export default function App() {
  return (
    <Suspense fallback={<BigSpinner />}>
      <Router />
    </Suspense>
  );
}

function Router() {
  const [page, setPage] = useState('/');

  function navigate(url) {
    setPage(url);
  }

  let content;
  if (page === '/') {
    content = (
      <IndexPage navigate={navigate} />
    );
  } else if (page === '/the-beatles') {
    content = (
      <ArtistPage
        artist={{
          id: 'the-beatles',
          name: 'The Beatles',
        }}
      />
    );
  }
  return (
    <Layout>
      {content}
    </Layout>
  );
}

이때 startTransition을 활용하여 UI를 차단하지 않고 상태를 업데이트할 수 있다.

export default function App() {
  return (
    <Suspense fallback={<BigSpinner />}>
      <Router />
    </Suspense>
  );
}

function Router() {
  const [page, setPage] = useState('/');

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }

  let content;
  if (page === '/') {
    content = (
      <IndexPage navigate={navigate} />
    );
  } else if (page === '/the-beatles') {
    content = (
      <ArtistPage
        artist={{
          id: 'the-beatles',
          name: 'The Beatles',
        }}
      />
    );
  }
  return (
    <Layout>
      {content}
    </Layout>
  );
}

리액트에서 상태 업데이트는 두 가지로 나뉠 수 있다.

  • 긴급한 업데이트 (urgent updates) : 입력, 클릭, 누르기 같은 다이렉트 상호작용을 반영
  • 전환 업데이트 (transition updates) : UI의 전환

타이핑, 클릭, 누르기 같은 긴급 업데이트는 빠르게 업데이트 되지 않으면 버벅거리면서 앱이 이상하다는 느낌을 줄 수 있지만, 화면은 곧바로 결과값을 볼거라고 기대하지 않기 때문에 전환 업데이트는 느리게 업데이트가 되어도 괜찮다.

위와 같이 startTransition으로 래핑된 업데이트는 전환 업데이트로 처리되며, 긴급한 업데이트가 들어오면 중단된다. 위에서 setPage(url)을 중단하고 긴급한 업데이트를 처리한 후 마지막으로 전환 업데이트를 처리하면 그 동안 Router 컴포넌트가 데이터를 로드하는 중이라고 인식하지 않기 때문에 Suspense의 폴백을 표시하지 않고 content 상태가 바뀌게 되는 것이다.


예시를 구체적으로 들면 이해가 빠를 것 같아서 정리해보자면, 아래 코드에서는 tab을 post에서 comment로 바꾸면서 의도치않게 Loading 폴백을 렌더링할 수가 있다.

<Suspense fallback={<Loading />}>
  {tab === 'post' ? <Post /> : <Comment />}
</Suspense>

만약 로더 대신 Post를 계속 노출시키고 싶다면 tab 상태를 바꾸는 것을 지연하면 된다.

function handleClick() {
  startTransition(() => {
    setTab('comment');
  });
}

useTransition를 사용해서 보류중 여부를 나타내는 변수와 함께 사용할 수도 있다.

const [isPending, startTransition] = useTransition();

isPending를 사용해서 page가 업데이트가 완료되기 전에 다른 UI를 보여주도록 할 수 있다.


ErrorBoundary와 함께 사용하기

ErrorBoundary 개념과 사용법

ErrorBoundary는 하위 컴포넌트 트리의 렌더링 중 발생한 에러를 감지하여 컴포넌트 트리 대신 폴백 UI를 보여줄 수 있는 컴포넌트다. 아래 컴포넌트에서 에러 발생 시 throw 하여 에러에 관한 책임을 Error Boundary가 갖도록 한다.

function Fetcher({ children }) {
  const dispatch = useDispatch();
  const { isLoading, error, data } = useSelector(state => state.comment);

  useEffect(() => {
    dispatch(fetchComments);
  }, []);

  if (error) {
    throw error;
  }

  if (isLoading) {
    return <Loading />;
  }

  return children;
}

생명주기 메서드인 static getDerivedStateFromError()componentDidCatch()를 정의하여 사용할 수 있다.

static getDerivedStateFromError
이 정적 메소드는 하위의 자식 컴포넌트에서 에러를 뱉었을 때 호출된다. 여기서 다음 렌더링에서 폴백 UI가 보이도록 에러에 관한 state를 업데이트할 수 있다.

componentDidCatch
에러 로그 기록과 같은 추가적인 로직을 작성할 수 있다.

주의할 점은, 다음과 같은 에러는 포착할 수 없다.

  • 이벤트 핸들러
  • 비동기 코드 (setTimeout 또는 requestAnimationFrame 콜백)
  • SSR
  • 자식 컴포넌트에서가 아닌 Error Boundary 자체에서 발생하는 에러
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    logErrorToMyService(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}

이제 Suspense와 Error Boundary를 합쳐서 책임을 나눠보자.
Suspense, Error Boundary, 컴포넌트를 각각 로딩, 에러, UI로 역할을 부여하여 구현해보았다.

Suspense + ErrorBoundary

비동기 처리가 존재하는 컴포넌트 상위를 감싸는 용도로 사용하기 위해 두 개를 합쳐 AsyncWrapper라는 컴포넌트를 제작했다.

ErrorBoundary.ts

class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = initialState;
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  resetQuery = () => {
    const { onReset } = this.props;
    onReset?.();
    this.setState(initialState);
  };

  render() {
    const { hasError, error } = this.state;
    const { children, fallback } = this.props;
    if (hasError && error) {
      return fallback({ reset: this.resetQuery });
    }

    return children;
  }
}

export default ErrorBoundary;

프로젝트에서 리액트 쿼리를 같이 사용하고 있었기 때문에 resetQuery가 추가되었다. 나머지 로직은 위 예시과 거의 같다.

resetQuery: 에러 발생 시 사용자가 reset 버튼을 클릭하여 에러를 리셋하고 다시 요청을 받아올 수 있도록 UX를 구성하기 위해 구현했다.

AsyncWrapper.tsx

function AsyncWrapper(props: Props) {
  const { children, errorFallback, suspenseFallback } = props;
  const { reset } = useQueryErrorResetBoundary();

  return (
    <ErrorBoundary fallback={errorFallback} onReset={reset}>
      <Suspense fallback={suspenseFallback}>{children}</Suspense>
    </ErrorBoundary>
  );
}

export default AsyncWrapper;

props로 errorFallback, suspenseFallback 을 넘겨 원하는 폴백을 넘길 수 있도록 했다.

useQueryErrorResetBoundary는 쿼리 에러를 초기화 해주기 위해 사용했다.


마주친 이슈

  • ErrorBoundary를 JSX 요소로 사용할 수 없다는 에러
    • 버전 이슈였고 @types/react 버전을 업그레이드하여 해결

참고

Suspense
https://react.dev/reference/react/Suspense
https://yrnana.dev/post/2022-04-12-react-18/
https://www.daleseo.com/react-suspense/

ErrorBoundary
https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
https://fe-developers.kakaoent.com/2022/221110-error-boundary/
https://ko.legacy.reactjs.org/docs/error-boundaries.html
https://velog.io/@kingyong9169/react-declarative-error-loading-handling

profile
frontend developer

0개의 댓글