Front-End/React

Suspense

Voyage_dev 2024. 8. 2. 21:59
✔️ React 18에서 추가된 Suspense에 대해 살펴보자

 

Suspense란?

  • fallback 컴포넌트를 로딩이 끝날 때 까지 보여주는 기능이다.
  • 사실 Suspense는 React 16.6 버전에서 실험적으로 도입된 기능이었다.

기존의 한계

  • Next.js에서 사용할 수 없었다. → (React 18) Next.js에서도 사용할 수 있게 되었다.
  • 컴포넌트가 마운트가 되기 전에 effect가 실행되는 문제가 발생했다. → (React 18) 컴포넌트가 화면에 실제로 노출될 때 effect가 실행된다.
  • 이전에는 컴포넌트가 스스로 Suspense에 의해 보여지고 있는지 없는지 알 방법이 없었다. → (React 18) Suspense로 인해 컴포넌트가 보이거나 사라질 때도 effect가 정상적으로 실행이 되는 방식으로 바뀌었다.
    • Suspense에 의해 노출된다면 useLayoutEffectdml effect(componentDidMount)가
    • 가려진다면 useLayOutEffect의 cleanUp(componentWillMount)이 정상적으로 실행
  • Suspense 내의 쓰로틀링이 추가
    • 중첩된 Suspense의 fallback이 있다면, 자동으로 쓰로틀링 되어 최대한 자연스럽게 보여준다

예제

https://react.dev/reference/react/Suspense

 

Suspense는 기본적으로 다음과 같이 사용된다

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>
  • 어떤 컴포넌트가 있고 그 컴포넌트를 바깥에서 Suspense로 감싸준 후에 그 Suspense의 fallback props를 통해서 어떤 컴포넌트를 이 children이 자식 컴포넌트가 Loading이 끝났을 때까지 보여줄 것인지를 넣어주면 된다.

주의할 점

  • React는 최초 마운트가 되기 전에 중단된 렌더링 상태에 대해서는 어떤한 상태도 보존이 되지 않는다. 만약에 컴포넌트가 로드가 되면 React는 중단된 트리를 처음부터 다시 리렌더링 시도를 하게 된다
  • Suspense가 트리에서 컨텐츠를 표시하다가 중단된 경우 그 업데이트가 startTransition 또는 useDeferredValue에 의해서 발생된게 아니라면 fallback 컴포넌트가 다시 보여지게 된다.
  • React가 이미 보이는 컨텐츠를 다시 중단해서 숨겨야 할 때 React는 컨텐츠 트리에서 레이아웃 이펙트를 정리를 하게 된다. 만약에 다시 컨텐츠를 표시할 준비가 되었을 때 React는 이 레이아웃 이펙트를 다시 발생을 시키는데 이러한 작업은 컨텐츠가 숨겨진 동안 DOM 레이아웃을 측정하는 이펙트가 이를 시도하지 않도록 보장하는 것을 의미한다.

기본예제

import { Suspense } from 'react';
import Albums from './Albums.js';

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<Loading />}>
        <Albums artistId={artist.id} />
      </Suspense>
    </>
  );
}

function Loading() {
  return <h2>🌀 Loading...</h2>;
}

  • 이러한 어떤 Albums라는 자식 컴포넌트가 데이터를 불러올 때까지 Loading을 보여주고 싶다고 했을 때 Suspense를 감싸주고 Loading 컴포넌트로 fallbac을 넣어주면 된다.

Suspense안에 하나의 컨텐츠만 들어갈 수도 있지만 여러 개의 컨텐츠가 들어갈 수도 있다

<Suspense fallback={<Loading />}>
  <Biography />
  <Panel>
    <Albums />
  </Panel>
</Suspense>
  • 이렇게 여러 개의 컨텐츠가 들어간 경우에는 동일하게 Loading이 보여지고 여러 개의 컨텐츠가 모두 다 Loading이 끝난 이후에 한꺼번에 다 보여주게 된다.

하지만 어떤 컴포넌트는 금방 Loading이 되는데 어떤 컴포넌트는 굉장히 오래 걸리는 경우는 어떻게 해야 될까? 그러면 마지막 컴포넌트가 끝날 때 까지 기다려야 하나?

 

Suspense에서는 중첩된 방식으로 구현할 수도 있다!

<Suspense fallback={<BigSpinner />}>
  <Biography />
  <Suspense fallback={<AlbumsGlimmer />}>
    <Panel>
      <Albums />
    </Panel>
  </Suspense>
</Suspense>

  • 이렇게 여러 개의 컨텐츠가 있을 때 컨텐츠별로 중첩해서 Suspense를 만들어 줄 수가 있다.

실행 순서는 Biography가 로드가 되기 전에는 BigSpinner가 돌아가게 된다 → Biography로드가 다 되었으면 BigSpinner가 사라지고 Biography가 보여지게 된다 → 밑에 애들은 Suspense 보여지지 않는 상태가 있다 → Panel과 Albums 이 부분이 로드가 안 되었기 때문에 Biography가 보여지고 나서도 AlbumsGlimmer는 계속 보여지게 된다 → Panel과 Albums이 다 로드가 되었을 때 AlbumsGlimmer가 사라지고 Panel과 Albums이 보여진다.

import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<BigSpinner />}>
        <Biography artistId={artist.id} />
        <Suspense fallback={<AlbumsGlimmer />}>
          <Panel>
            <Albums artistId={artist.id} />
          </Panel>
        </Suspense>
      </Suspense>
    </>
  );
}

function BigSpinner() {
  return <h2>🌀 Loading...</h2>;
}

function AlbumsGlimmer() {
  return (
    <div className="glimmer-panel">
      <div className="glimmer-line" />
      <div className="glimmer-line" />
      <div className="glimmer-line" />
    </div>
  );
}

 

만약에 컨텐츠가 조금 지연될 경우 어떻게 될까? (useDeferredValue)

  • 먼저 우리가 어떤 쿼리를 한다고 했을 때 쿼리를 하면 쿼리 데이터를 불러오는데 시간 때문에 Suspense가 보여지게 된다
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>
    </>
  );
}

  • Loading 보여지고 데이터가 보여지고를 반복하는 코드다

그런데 만약에 우리가 deferredQuery를 사용한다면 state가 바뀔 때마다 매번 바로 Loading이 이루어지는게 아니라 UI 렌더링을 방해하지 않는 선에서 지연된 쿼리를 받아볼 수 있다는 특징을 가지고 있다.

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

  • 쿼리를 이렇게 deferredQuery로 한 번 감싸주고
<div style={{
  opacity: query !== deferredQuery ? 0.5 : 1 
}}>
  <SearchResults query={deferredQuery} />
</div>
  • deferredQuery가 실제 쿼리랑 변화를 눈으로 볼 수 있게 한 번 처리를 해준 다음에 deferredQuery를 넣어보자
import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults.js';

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>
    </>
  );
}

  • Loading이라는 fallback을 넣어도 보지 못 한다. Loading을 Suspense에서 인지하고 있는게 아니라 deferred 처리로 인해서 state가 지연된 처리가 이루어졌기 때문이다.

컨텐츠가 부분적으로 리렌더링 될 때 기존 방식은 이미 보여지고 있었던 컨텐츠가 사라지게 되고 숨겨지게 되는 요소이기 때문에 그렇게 좋은 권장하는 패턴은 아니다. 기존에 만들어 놨던 것들은 계속해서 유지를 한 상태로 이제 새로운 컨텐츠들을 Suspense로 보여주는 것이 굉장히 중요하다 → 여기서 사용하는 개념이 startTransition이다.

 

startTransition을 사용을 하게되면 Route처리 navigate를 하는 처리에서 URL 상태 페이지의 상태를 바꿔줄 때 그냥 바꿔주는게 아니라 기존에는 그냥 바로 바꿔줬다면

import { Suspense, useState } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

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>
  );
}

function BigSpinner() {
  return <h2>🌀 Loading...</h2>;
}

→ startTransition을 감싸줘서 바꿔주면

import { Suspense, startTransition, useState } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

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>
  );
}

function BigSpinner() {
  return <h2>🌀 Loading...</h2>;
}

  • 브라우저는 그대로 있고 이 안에 컨텐츠만 새롭게 fallback도 보여지고 그리고 fallback 안에 중첩이 된 컨텐츠들이 보여지게 된다.
  • 만약에 state를 그냥 바꾸면 가장 가까운 부모의 Suspense의 fallback을 나타내 주게 된다.
  • startTransition 쓰게 되면 React는 이 상태 변화가 급한게 아니고 천천히 해도 된다고 인식을 하고 이미 보여줬던 것들을 그대로 놔두고 하위 컴포넌트가 보여질 때까지 기존 것을 유지하는 경향을 가지고 있다.