1. Suspense Component πŸ‘©β€πŸ’»

Suspense λŠ” React κ°€ μ œκ³΅ν•˜λŠ” Wrapper Component 둜 children의 λ‘œλ”©μ΄ 끝날 λ•ŒκΉŒμ§€ λ Œλ”λ§ ν•  λŒ€μ²΄ μ»΄ν¬λ„ŒνŠΈλ₯Ό fallback으둜 μ œκ³΅ν•˜λŠ” μ»΄ν¬λ„ŒνŠΈλ‘œ κΈ°λ³Έ Syntax λŠ” λ‹€μŒκ³Ό κ°™λ‹€.

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

React 19 μ—μ„œ 좔가될 μ˜ˆμ •μΈ κΈ°λŠ₯듀이 있기 λ•Œλ¬Έμ— 19 버전 정식 μΆœμ‹œ μ΄μ „μ—λŠ” canary λ²„μ „μœΌλ‘œ μ„€μΉ˜ν•΄μ•Ό λͺ¨λ“  κΈ°λŠ₯을 μ‚¬μš©ν•  수 μžˆλ‹€.

# React 19 버전 미만일 경우 canary λ₯Ό μ„€μΉ˜ν•œλ‹€.
npm i react@canary react-dom@canary --legacy-peer-deps

2. Usage πŸ‘©β€πŸ’»

1. Displaying a fallback while content is loading

Suspense κ°€ λ“±μž₯ν•˜κΈ° μ „ Albums μ»΄ν¬λ„ŒνŠΈμ˜ λ‘œλ”© fallback 을 μ œκ³΅ν•˜λŠ” 방법은 λ‹€μŒκ³Ό κ°™μ•˜λ‹€.

  • Albums.jsx
export default function Albums({ artistId }) {
  const [loading, setLoading] = useState(false);
  const [albums, setAlbums] = useState();

  const getAlbums = async () => {
    setLoading(true);
    const response = await fetchData(`/${artistId}/albums`);
    setAlbums(response);
    setLoading(false);
  };

  useEffect(() => {
    getAlbums();
  }, [getAlbums]);

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

  return (
      <ul>
        {albums?.map((album) => (
            <li key={album.id}>
              {album.title} ({album.year})
            </li>
        ))}
      </ul>
  );
}

Suspense λ₯Ό λ„μž…ν•˜λ©΄ μœ„ μ»΄ν¬λ„ŒνŠΈλŠ” λΉ„μ¦ˆλ‹ˆμŠ€ 둜직과 fallback 을 뢄리할 수 μžˆλ‹€.

  • Albums.jsx
export default function Albums({ artistId }) {
  const albums = use(fetchData(`/${artistId}/albums`));
  return (
      <ul>
        {albums.map((album) => (
            <li key={album.id}>
              {album.title} ({album.year})
            </li>
        ))}
      </ul>
  );
}
<Suspense fallback={<Loading />}>
  <Albums />
</Suspense>

Albumsκ°€ λ‘œλ”©μ€‘μΌ λ•ŒλŠ” μžλ™μœΌλ‘œ Loading이 λ Œλ”λ§ 되고, λ‘œλ”©μ΄ λλ‚˜λ©΄ Albumsλ₯Ό λ Œλ”λ§ ν•΄ κ΅μ²΄ν•œλ‹€. 이λ₯Ό μ’€ 더 μ •ν™•ν•˜κ²Œ μ„€λͺ…ν•˜λ©΄, Suspense 의 children 이 λͺ¨λ‘ λ‘œλ”©λœ ν›„ fallback 이 ν•΄μ œλœλ‹€.

μ΄λ•Œ Suspense Component κ°€ 데이터 λ‘œλ”©μ„ 감지할 수 μžˆλŠ” 쑰건은 λ‹€μŒκ³Ό κ°™λ‹€.

  • Relayλ‚˜ Next.js와 같은 Suspense-enabled frameworks κ°€ 데이터λ₯Ό fetching ν•  λ•Œ.
  • lazy ν‚€μ›Œλ“œλ₯Ό μ‚¬μš©ν•œ Lazy-loading Component λ₯Ό μ‚¬μš©ν•  λ•Œ.
  • use둜 wrapping ν•œ Promise λ₯Ό 읽을 λ•Œ.

μ—¬κΈ°μ„œ useλŠ” React 18 λ²„μ „μ—λŠ” μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ”λ‹€. canary λ²„μ „μ—μ„œ μ‚¬μš© κ°€λŠ₯ν•œ μ‹ κ·œ API 둜 React 19 버전에 좔가될 μ˜ˆμ •μ΄λ‹€.

λ”°λΌμ„œ Suspense with axios on React 18 μ—μ„œ use 역할을 ν•˜λŠ” Wrapping ν•¨μˆ˜λ₯Ό λ§Œλ“€μ–΄ μ‚¬μš©ν•˜λŠ” 방법을 κ΅¬ν˜„ν•  것이닀. 단, use와 λ‹€λ₯Έ 점은 use λŠ” Promise 뿐 μ•„λ‹ˆλΌ Context λ₯Ό Wrapping ν•˜λŠ” 데도 μ‚¬μš©ν•  수 μžˆλŠ” 반면 μ•„λž˜μ„œ κ΅¬ν˜„ν•  ν•¨μˆ˜λŠ” 였직 Suspense λ₯Ό axios 와 ν•¨κ»˜ μ‚¬μš©ν•˜κΈ° μœ„ν•œ Wrapping ν•¨μˆ˜ λ‹€.

λ‹€μŒ μ½”λ“œλ₯Ό 보자.

<Suspense fallback={<Loading />}>
  <Biography />
  <Panel>
    <Albums />
  </Panel>
</Suspense>

μ΄λ ‡κ²Œ children 이 μ—¬λŸ¬ 개 μžˆλŠ” κ²½μš°λŠ” μ–΄λ–»κ²Œ μž‘λ™ν• κΉŒ? Suspense 의 children 이 λͺ¨λ‘ λ‘œλ”©λœ ν›„ fallback 이 ν•΄μ œλœλ‹€κ³  ν–ˆλ‹€. 특히 μžμ‹ μ»΄ν¬λ„ŒνŠΈ 뿐 μ•„λ‹ˆλΌ μžμ† μ»΄ν¬λ„ŒνŠΈκΉŒμ§€ λͺ¨λ‘ λ‹€ λ‘œλ”©μ΄ μ™„λ£Œλ˜μ–΄μ•Ό fallback 을 ν•΄μ œν•  수 μžˆλ‹€.

λ”°λΌμ„œ 일뢀 μ»΄ν¬λ„ŒνŠΈμ˜ λ‘œλ”©μ΄ μ™„λ£Œ λ˜λ”λΌλ„ μžμ†μ„ ν¬ν•¨ν•œ λͺ¨λ“  ν•˜μœ„ μ»΄ν¬λ„ŒνŠΈκ°€ λ‘œλ”©λ  λ•ŒκΉŒμ§€ 계속 fallback μƒνƒœμ— 놓여 있게 되고, μ‚¬μš©μžλŠ” 앱이 느리게 λ°˜μ‘ν•΄ 쒋지 λͺ»ν•œ μ‚¬μš©μž κ²½ν—˜μ„ ν•˜κ²Œ λœλ‹€.

2. Revealing nested content as it loads

그런데 μ–΄λ–€ μ»΄ν¬λ„ŒνŠΈλŠ” 느리게 λ‘œλ”©μ΄ λ˜μ§€λ§Œ μ–΄λ–€ μ»΄ν¬λ„ŒνŠΈλŠ” λΉ λ₯΄κ²Œ λ‘œλ”©μ΄ 될 μˆ˜λ„ μžˆλ‹€. 특히 API 응닡이 느린 뢀뢄은 ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ μ–΄λ–»κ²Œ ν•  수 μžˆλŠ” 뢀뢄이 μ•„λ‹ˆκΈ° λ•Œλ¬Έμ— λΉ λ₯΄κ²Œ λ‘œλ”© 된 것듀 λ¨Όμ € 보여주며 λ‘œλ”©μ΄ μ™„λ£Œλ¨μ— 따라 μ μ§„μ μœΌλ‘œ 화면을 μ—…λ°μ΄νŠΈ ν•˜λ©΄ 더 쒋은 μ‚¬μš©μž κ²½ν—˜μ„ μ œκ³΅ν•΄ 쀄 수 μžˆλ‹€.

Error Boundary와 λ§ˆμ°¬κ°€μ§€λ‘œ Suspense μ—­μ‹œ 쀑첩이 κ°€λŠ₯ν•˜λ‹€. 그리고 μ€‘μ²©λœ 경우 κ°€μž₯ κ°€κΉŒμ΄ μžˆλŠ” Suspense 에 영ν–₯을 λ°›μœΌλ―€λ‘œ λͺ¨λ“  μžμ†μ„ κ°μ‹œν•˜μ§€ μ•ŠλŠ”λ‹€. 무슨 말인지 μ½”λ“œλ₯Ό 보자.

<Suspense fallback={<BigSpinner />}>
  <Biography />
  <Suspense fallback={<AlbumsGlimmer />}>
    <Panel>
      <Albums />
    </Panel>
  </Suspense>
</Suspense>
  1. Biographyκ°€ λ‘œλ”©λ˜μ§€ μ•Šμ•˜λ‹€λ©΄ BigSpinnerκ°€ fallback λ˜μ–΄ 전체 μ˜μ—­μ„ λŒ€μ‹ ν•œλ‹€.
  2. Biographyκ°€ λ‘œλ”©λ˜λ©΄ BigSpinner fallback 이 ν•΄μ œλ˜μ–΄ content κ°€ 보여진닀.
  3. λ§Œμ•½ Albumsκ°€ 아직 λ‘œλ”©λ˜μ§€ μ•Šμ•˜λ‹€λ©΄ AlbumGlimmerκ°€ fallback λ˜μ–΄ children 의 λ‘œλ”©μ„ κΈ°λ‹€λ¦°λ‹€.
  4. Albumsκ°€ λ‘œλ”©λ˜λ©΄ AlbumGlimmer fallback μ—­μ‹œ ν•΄μ œλ˜μ–΄ λͺ¨λ“  content κ°€ 보여진닀.

3. useDeferredValue

데이터λ₯Ό κ²€μƒ‰ν•˜λŠ” λ™μ•ˆ Loading fallback 이 λŒ€μ²΄ν•˜λŠ” 경우λ₯Ό μƒκ°ν•΄λ³΄μž.

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

이 μ»΄ν¬λ„ŒνŠΈλŠ” 이전과 달리 λ Œλ”λ§μ΄ μ™„λ£Œλœ 이후에도 κ³„μ†ν•΄μ„œ API μš”μ²­μ„ ν•  수 μžˆλ‹€. 이 말은 검색을 ν•  λ•Œλ§ˆλ‹€ Promise λ₯Ό μƒμ„±ν•˜κ³ , 맀번 fallback 이 λ Œλ”λ§ λœλ‹€λŠ” 것이닀.

이미 λ Œλ”λ§ 된 μ»΄ν¬λ„ŒνŠΈκ°€ 맀번 μ§€μ›Œμ§€κ³  μƒˆλ‘­κ²Œ κ·Έλ €μ§€λŠ” 것은 μ„±λŠ₯에도 영ν–₯을 λ―ΈμΉ˜μ§€λ§Œ 무엇보닀 UI 상 쒋지 λͺ»ν•˜λ‹€λŠ” λ¬Έμ œμ μ„ κ°–λŠ”λ‹€. λ”°λΌμ„œ fallback 으둜 λŒ€μ²΄λ˜μ§€ μ•Šκ³ , μƒˆ 데이터가 λ‘œλ”©λ˜μ–΄ update 될 λ•ŒκΉŒμ§€ UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚€λŠ” 것이 μ’‹λ‹€.

const deferredValue = useDeferredValue(value)

useDeferredValue λŠ” UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚€λŠ” Hooks 둜 debounce ν•¨μˆ˜μ™€ 달리 λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μ€ μ§€μ—°μ‹œν‚€μ§€ μ•Šκ³  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=>
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </>
  );
}

이제 더이상 fallback 으둜 λŒ€μ²΄λ˜μ§€ μ•Šκ³  λ‘œλ”©μ΄ μ™„λ£Œλ  λ•ŒκΉŒμ§€ UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚¨λ‹€. 단 이 경우 이미 λ‘œλ”©λœ μ»΄ν¬λ„ŒνŠΈμΌ λ•Œλ§Œ UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚€λŠ” 것이 μ•„λ‹ˆκ³  useDefereedValue에 μ˜ν•΄ μ—…λ°μ΄νŠΈ λ˜λŠ” λͺ¨λ“  UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚€κΈ° λ•Œλ¬Έμ— μœ„ μ½”λ“œμ˜ fallback 은 항상 λŒ€μ²΄κ°€ λ˜μ§€ μ•ŠλŠ”λ‹€. λ§Œμ•½ fallback 으둜 λŒ€μ²΄κ°€ 되렀면 useDefereedValueκ°€ μ•„λ‹Œ λ‹€λ₯Έ state 에 μ˜ν•œ μ—…λ°μ΄νŠΈκ°€ μ΄λ£¨μ–΄μ Έμ•Όν•œλ‹€.

4. startTransition

useState의 μ—…λ°μ΄νŠΈμ— μ˜ν•œ UI μ—…λ°μ΄νŠΈ 지연은 useDeferredValue 훅을 μ‚¬μš©ν•΄ ν•΄κ²°ν•  수 μžˆμ—ˆλ‹€. 그런데 μ»΄ν¬λ„ŒνŠΈ 트리 κ΅¬μ‘°μ—μ„œ νŠΉμ • μ»΄ν¬λ„ŒνŠΈλ₯Ό μ§€μ—°μ‹œν‚€κ³ μž ν•  λ•ŒλŠ” μ–΄λ–»κ²Œ ν•΄μ•Όν• κΉŒ? μ•„λž˜ μ½”λ“œλ₯Ό 보자.

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=
      />
    );
  }
  return (
    <Layout>
      {content}
    </Layout>
  );
}

function BigSpinner() {
  return <h2>πŸŒ€ Loading...</h2>;
}

μœ„ μ½”λ“œλ₯Ό 보면 λΌμš°ν„°λ₯Ό Suspense μ»΄ν¬λ„ŒνŠΈκ°€ 감싸고 있고 fallback 으둜 BigSpinnerλ₯Ό μ œκ³΅ν•˜κ³  μžˆλ‹€. 그리고 λΌμš°ν„°λŠ” μ–Έμ œλ‚˜ λ§€μΉ­λ˜λŠ” μ»΄ν¬λ„ŒνŠΈλ₯Ό Layout μ»΄ν¬λ„ŒνŠΈλ‘œ 감싸기 λ•Œλ¬Έμ— μ»΄ν¬λ„ŒνŠΈκ°€ λ‹€μ‹œ λ Œλ”λ§ λ˜λ”λΌλ„ Layuout은 변함 없이 κ³΅ν†΅μœΌλ‘œ λ Œλ”λ§ λ˜λŠ” 것을 μ•Œ 수 μžˆλ‹€.

ν•˜μ§€λ§Œ children 을 ν†΅μ§Έλ‘œ fallback 으둜 λŒ€μ²΄ν•˜κΈ° λ•Œλ¬Έμ— Router μ»΄ν¬λ„ŒνŠΈλŠ” ν†΅μ§Έλ‘œ BigSpinner둜 λŒ€μ²΄λœλ‹€. μ§€μ›Œμ§ˆ ν•„μš”κ°€ μ—†λŠ” λ ˆμ΄μ•„μ›ƒκΉŒμ§€ λͺ¨λ‘ fallback λ˜μ–΄λ²„λ¦¬λ‹ˆ 마치 SSR 을 μ‚¬μš©ν•˜λŠ” 것과 같은 쒋지 λͺ»ν•œ μ‚¬μš©μž κ²½ν—˜μ„ ν•˜κ²Œ λœλ‹€.

즉, ν˜„μž¬ μƒνƒœλŠ” Revealing nested content as it loads 와 κ°™λ‹€κ³  ν•  수 μžˆλ‹€.

Layout은 fallback 이 λ˜μ§€ μ•Šκ³  ν•˜μœ„ μ»΄ν¬λ„ŒνŠΈλ§Œ fallback 되게 ν•˜λ €λ©΄ μ–΄λ–»κ²Œ ν•΄μ•Ό ν• κΉŒ? κ°€μž₯ κ°„λ‹¨ν•œ 방법은 Routerλ₯Ό Suspense둜 감싸지 μ•ŠλŠ” 것이닀. 그러면 ν•˜μœ„ μ»΄ν¬λ„ŒνŠΈκ°€ κ°–κ³  μžˆλŠ” Suspense 의 fallback 만 μž‘λ™ν•  것이닀.

λŒ€μ‹  이 경우 Layout은 fallback 이 λ˜μ§€ μ•ŠκΈ° λ•Œλ¬Έμ— 첫 λ‘œλ”©μ‹œμ—λ„ fallback λŒ€μ²΄κ°€ λΆˆκ°€λŠ₯ν•˜λ‹€. λ§Œμ•½ 첫 λ‘œλ”©μ‹œμ—λŠ” fallback 을 μ‚¬μš©ν•˜λ˜, 이후 ν•˜μœ„ μ»΄ν¬λ„ŒνŠΈ μ—…λ°μ΄νŠΈ μ‹œ 이미 λ Œλ”λ§ 된 content λ₯Ό μœ μ§€ν•œ μ±„λ‘œ ν•˜μœ„ fallback 만 λŒ€μ²΄ν•˜λ„λ‘ ν•˜κ³  μ‹Άλ‹€λ©΄ μ–΄λ–»κ²Œ ν•΄μ•Όν• κΉŒ?

useDeferredValue와 Transitions 은 UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚¨λ‹€.


μ—¬κΈ°μ„œ μš°λ¦¬μ—κ²Œ ν•„μš”ν•œ 것은 Transition이닀.

startTransition(scope)

startTransition 은 UI λΈ”λ‘œν‚Ή 없이 state λ₯Ό μ—…λ°μ΄νŠΈ ν•˜λ„λ‘ λ§Œλ“œλŠ” React API λ‹€. Hooks κ°€ μ•„λ‹ˆλΌ API μž„μ— μœ μ˜ν•˜λ„λ‘ ν•˜μž.

useDefferedValueλŠ” stateλ₯Ό parameter 둜 λ°›μ•„ μƒˆ λ³€μˆ˜λ₯Ό λ°˜ν™˜ν–ˆλ‹€. startTransition은 setStateλ₯Ό closure ν˜•νƒœλ‘œ λ°›μœΌλ©° API 에 λ°”λ‘œ λ“±λ‘λ˜κΈ° λ•Œλ¬Έμ— λ³„λ„μ˜ λ°˜ν™˜κ°’μ€ μ—†λ‹€.

startTransition(() => {
  // βœ… Setting state *during* startTransition call
  setPage('/about');
});

이미 λ Œλ”λ§ 된 μ»΄ν¬λ„ŒνŠΈμ˜ λΆˆν•„μš”ν•œ fallback λŒ€μ²΄κ°€ λ°œμƒν•˜μ§€ μ•Šλ„λ‘ startTransition을 μ μš©ν•΄λ³΄μž.

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

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

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

이제 Transitions κ°€ μ‹œμž‘λ˜λ©° UI μ—…λ°μ΄νŠΈκ°€ μ§€μ—°λ˜μ–΄ BigSpinner fallback 은 λŒ€μ²΄λ˜μ§€ μ•Šκ³  ν•˜μœ„ μ»΄ν¬λ„ŒνŠΈ ArtistPage에 μžˆλŠ” Albumsλ₯Ό 감싸고 μžˆλŠ” Suspense 의 AlbumsGlimmer fallback 만 λŒ€μ²΄λœλ‹€. μ΄λ•Œ μ£Όμ˜ν•΄μ•Ό ν•  것이 Layout이 fallback 으둜 λŒ€μ²΄λ˜μ§€ μ•Šμ•˜λ‹€κ³  μ•„μ˜ˆ λ Œλ” ν•¨μˆ˜ 호좜 μžμ²΄κ°€ 이루어지지 μ•ŠλŠ” 것은 μ•„λ‹ˆλ‹€!

λ Œλ” ν•¨μˆ˜μ˜ ν˜ΈμΆœμ€ μ •μƒμ μœΌλ‘œ μ΄λ£¨μ–΄μ§€μ§€μ§€λ§Œ fallback 으둜 λŒ€μ²΄λ˜λŠ” λŒ€μ‹  UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚€λŠ” 것 뿐이닀. 그리고 이것은 이미 그렀진 UI 일 λ•Œλ§Œ μ§€μ—°λ˜κΈ° λ•Œλ¬Έμ— 아직 UI κ°€ 그렀지지 μ•Šμ•„ ν•΄λ‹Ή Suspense κ°€ 졜초둜 μž‘λ™ν•˜λŠ” μ‹œμ μ—λŠ” fallback 으둜 λŒ€μ²΄κ°€ λœλ‹€. 즉, ν•΄λ‹Ή νŽ˜μ΄μ§€ 첫 λ°©λ¬Έ μ‹œμ—λŠ” Layout 의 fallback κ³Ό μžμ‹ μ»΄ν¬λ„ŒνŠΈμ˜ fallback 이 λͺ¨λ‘ μž‘λ™ν•˜μ§€λ§Œ, μ΄ν›„μ—λŠ” μžμ‹μ˜ fallback 만 μž‘λ™ν•˜λŠ” 것이닀.

λ¦¬μ•‘νŠΈλ‘œ 앱을 λ§Œλ“€ λ•Œ νŽ˜μ΄μ§€ 이동 μ‹œ Transitions λ₯Ό μ‚¬μš©ν•˜λŠ” 것이 ꢌμž₯λ˜λŠ” 데 두 가지 μ΄μœ λŠ” λ‹€μŒκ³Ό κ°™λ‹€.

  • λ Œλ”λ§μ΄ μ™„λ£Œλ˜κΈ° μ „ μ‚¬μš©μžκ°€ λ‹€λ₯Έ 것을 ν΄λ¦­ν•˜λ©΄ Transition 을 쀑단할 수 μžˆλ‹€ (λ§Œμ•½ Transition 을 μ‚¬μš©ν•˜μ§€ μ•ŠλŠ”λ‹€λ©΄ μ‚¬μš©μžμ˜ 클릭 μž…λ ₯은 μ „λ‹¬λ˜μ§€λ§Œ 이전에 ν΄λ¦­ν•œ UI μ—…λ°μ΄νŠΈ 지연이 μ€‘λ‹¨λ˜μ§€ μ•Šμ•„ κΈ°λ‹€λ¦° ν›„ 순차적으둜 μ—…λ°μ΄νŠΈλœλ‹€).
  • Transition 은 μ›μΉ˜ μ•ŠλŠ” loading indicator κ°€ λ‚˜νƒ€λ‚˜μ§€ μ•Šκ²Œ ν•  수 μžˆλ‹€.

λΌμš°ν„°μ— Suspense 와 Transitions λ₯Ό μ μš©ν•˜λŠ” 것을 Building a Suspense-enabled router 라 ν•œλ‹€.


startTransition μ‚¬μš©μ‹œ 유의점 두 κ°€μ§€λŠ” λ‹€μŒκ³Ό κ°™λ‹€.

1 ) startTransition is synchronous

μ΄λ•Œ μ£Όμ˜ν•΄μ•Ό ν•  것이 startTransition은 λ°˜λ“œμ‹œ Synchronous μ—¬μ•Ό ν•œλ‹€. λ”°λΌμ„œ λ‹€μŒμ€ ν—ˆμš©λ˜μ§€ μ•ŠλŠ”λ‹€.

startTransition(() => {
  // ❌ Setting state *after* startTransition call
  setTimeout(() => {
    setPage('/about');
  }, 1000);
});

λ§Œμ•½, setTimeout 을 μ μš©ν•΄μ•Ό ν•œλ‹€λ©΄ μˆœμ„œλ₯Ό λ°”κΏ” startTransition은 λ°˜λ“œμ‹œ Synchronous ν•˜λ„λ‘ ν•΄μ•Ό ν•œλ‹€.

setTimeout(() => {
  startTransition(() => {
    // βœ… Setting state *during* startTransition call
    setPage('/about');
  });
}, 1000);

λ§ˆμ°¬κ°€μ§€λ‘œ async/await μ—­μ‹œ Asynchronous μ½”λ“œμ΄κΈ° λ•Œλ¬Έμ— ν—ˆμš©λ˜μ§€ μ•ŠλŠ”λ‹€.

startTransition(async () => {
  await someAsyncFunction();
  // ❌ Setting state *after* startTransition call
  setPage('/about');
});

μ΄λ ‡κ²Œ startTransition λ°–μœΌλ‘œ λΉΌλ‚΄ λ°˜λ“œμ‹œ Synchronous μ—¬μ•Ό ν•œλ‹€.

await someAsyncFunction();
startTransition(() => {
  // βœ… Setting state *during* startTransition call
  setPage('/about');
});


그리고 startTransition을 μ‚¬μš©ν•  λ•Œ μ£Όμ˜ν•΄μ•Ό ν•  것이 이것은 λΈŒλΌμš°μ € API 인 setTimeoutκ³Ό 달리 λ‚˜μ€‘μ— μ‹€ν–‰λ˜μ§€ μ•ŠλŠ”λ‹€λŠ” 것이닀. UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œν‚€κΈ° λ•Œλ¬Έμ— ν˜Όλ™ν•  수 μžˆμ§€λ§Œ startTransition μ—­μ‹œ λ™κΈ°λ‘œ μž‘λ™ν•œλ‹€. 마치 context 전체λ₯Ό async 둜 감싸고 await 을 μ‚¬μš©ν•œ 것과 μœ μ‚¬ν•˜λ‹€.

console.log(1);
startTransition(() => {
  console.log(2);
  setPage('/about');
});
console.log(3);
1
2
3

λ™κΈ°μ μœΌλ‘œ μž‘λ™ν•˜κΈ° λ•Œλ¬Έμ— 1, 3, 2 μˆœμ„œκ°€ μ•„λ‹Œ 1, 2, 3 μˆœμ„œλ‘œ μ½”λ“œκ°€ μ‹€ν–‰λœλ‹€. 단지 UI μ—…λ°μ΄νŠΈ 지연이 λλ‚œ 이후에 λ‹€μŒ μ½”λ“œκ°€ μ‹€ν–‰λ˜λ„λ‘ μž μ‹œ 지연될 뿐이닀.


2 ) Updating an input in a Transition doesn’t work

또 λ‹€λ₯Έ μ£Όμ˜μ μ€ input 은 λ™κΈ°μ μœΌλ‘œ μ¦‰μ‹œ μ—…λ°μ΄νŠΈ 되기 λ•Œλ¬Έμ— startTransitionκ³Ό ν•¨κ»˜ μ‚¬μš©ν•  수 μ—†λ‹€. Transitions κ°€ non-blocking 이기 λ•Œλ¬Έμ΄λ‹€.

const [text, setText] = useState('');
// ...
function handleChange(e) {
  // ❌ Can't use Transitions for controlled input state
  startTransition(() => {
    setText(e.target.value);
  });
}
// ...
return <input value={text} onChange={handleChange} />;

이런 μƒν™©μ—μ„œ ν•΄κ²° 방법은 두 가지가 μžˆλ‹€.

  • 이 경우 두 개의 stateλ₯Ό λ§Œλ“€μ–΄ ν•˜λ‚˜λŠ” 값을 μ¦‰μ‹œ μ—…λ°μ΄νŠΈ ν•˜κ³ , λ‹€λ₯Έ ν•˜λ‚˜λŠ” Transitions UI λ Œλ”λ§ λ‘œμ§μ— μ‚¬μš©ν•˜λ„λ‘ ν•œλ‹€.
  • ν•˜λ‚˜μ˜ stateλ₯Ό μ‚¬μš©ν•˜λŠ” λŒ€μ‹  useDeferredValue λ₯Ό μΆ”κ°€ν•΄ μ‹€μ œ 값을 κ°μΆ˜λ‹€.

5. useTransition

startTransition API λ₯Ό μ‚¬μš©ν•˜λ©΄ Suspense의 fallback 을 μ œμ–΄ν•  수 μžˆλ‹€. ν•˜μ§€λ§Œ μ—¬μ „νžˆ 아쉬움이 μžˆλŠ”λ° λ‘œλ”© μ€‘μ΄λΌλŠ” μ‹œκ°μ  indicator κ°€ μ—†λ‹€ λ³΄λ‹ˆ UI μ—…λ°μ΄νŠΈ μ§€μ—°μœΌλ‘œ 인해 μ•±μ˜ λ°˜μ‘μ΄ λŠλ¦¬κ±°λ‚˜ 클릭에 λ°˜μ‘μ΄ μ—†λŠ” κ²ƒμœΌλ‘œ μ˜€ν•΄ν•  수 μžˆλ‹€λŠ” 점이닀.

이에 λŒ€ν•œ λŒ€μ•ˆμœΌλ‘œ κ°€μž₯ 쒋은 방법은 useTransition Hooks λ₯Ό μ‚¬μš©ν•˜λŠ” 것이닀. startTransition은 React API μ˜€μœΌλ‚˜ useTransition 은 Hooks λΌλŠ” 것에 μœ μ˜ν•˜μž. useTransition은 startTransition을 λ°˜ν™˜ν•˜κΈ° λ•Œλ¬Έμ— 직접 import ν•  ν•„μš”κ°€ μ—†μœΌλ©° μ‚¬μš© 방법은 λ‹€μŒκ³Ό κ°™λ‹€.

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

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }
  // ...
  return (
    <Layout isPending={isPending}>
      {content}
    </Layout>
  );
}
export default function Layout({ children, isPending }) {
  return (
    <div className="layout">
      <section className="header" style=>
        Music Browser
      </section>
      <main>
        {children}
      </main>
    </div>
  );
}

이제 Layout 의 헀더λ₯Ό 톡해 Loading indicator λ₯Ό ν‘œν˜„ν•  수 있게 λ˜μ—ˆλ‹€. 참고둜 μ—¬κΈ°μ„œ isPending이 true인 기간은 Layout μ»΄ν¬λ„ŒνŠΈκ°€ μ†ν•œ Suspense 의 fallback 이 ν‘œμ‹œλ˜μ–΄μ•Ό ν•˜λŠ” κΈ°κ°„ λ™μ•ˆμ— ν•΄λ‹Ήν•œλ‹€. 즉, BigSpinner둜 λŒ€μ²΄λ˜λŠ” λŒ€μ‹  isPending indicator λ₯Ό μ‚¬μš©ν•΄ λ‘œλ”©μ€‘μ„ μ‹œκ°μ μœΌλ‘œ ν‘œν˜„ν•΄μ£ΌλŠ” 것이닀. isPending은 ν•˜μœ„ μ»΄ν¬λ„ŒνŠΈμ— μžˆλŠ” Suspense 의 AlbumsGlimmer fallback κ³ΌλŠ” λ¬΄κ΄€ν•˜λ‹€.


useTransition μ‚¬μš©μ‹œ 유의점 두 κ°€μ§€λŠ” λ‹€μŒκ³Ό κ°™λ‹€.

1 ) The useTransition should be called inside a component

useTransition은 Hooks 이기 λ•Œλ¬Έμ— λ°˜λ“œμ‹œ μ»΄ν¬λ„ŒνŠΈ μ•ˆμ—μ„œλ§Œ ν˜ΈμΆœλ˜μ–΄μ•Όν•œλ‹€. 예λ₯Ό λ“€μ–΄ data library 와 같이 μ»΄ν¬λ„ŒνŠΈ λ°–μ—μ„œ 호좜이 ν•„μš”ν•˜λ‹€λ©΄ Hooks λŒ€μ‹  API 인 startTransition을 μ‚¬μš©ν•΄μ•Όν•˜λ©°, 이 경우 isPending indicator λŠ” μ‚¬μš©ν•  수 μ—†λ‹€.


2 ) Suspense integrated router should wrap its updates into startTransition automatically

λΌμš°ν„°κ°€ Suspense 와 ν†΅ν•©λœ 경우 λΌμš°ν„° μ—…λ°μ΄νŠΈλ₯Ό startTransition으둜 wrapping ν•΄μ•Ό ν•œλ‹€.

6. key props

Suspense와 Transitionsλ₯Ό μ‚¬μš©ν•˜λ©΄ 이미 λ Œλ”λ§ 된 μ»΄ν¬λ„ŒνŠΈμ˜ UI μ—…λ°μ΄νŠΈλ₯Ό μ§€μ—°μ‹œμΌœ fallback λŒ€μ²΄κ°€ μž‘λ™ν•˜μ§€ μ•Šλ„λ‘ ν•  수 μžˆλ‹€. ν•˜μ§€λ§Œ λ•Œλ‘œλŠ” 같은 UI 라 ν•˜λ”λΌλ„ λ‹€μ‹œ λ Œλ”λ§ λ˜λŠ” 것이 더 쒋은 UI κ°€ 될 λ•Œκ°€ μžˆλ‹€. 예λ₯Ό λ“€μ–΄ μ–΄λ–€ μ‚¬μš©μžμ˜ 정보λ₯Ό λ³΄λŠ” ProfilePage μ»΄ν¬λ„ŒνŠΈκ°€ μžˆλ‹€κ³  ν•΄λ³΄μž. 동일 μ‚¬μš©μžμ˜ μ—¬λŸ¬ 정보λ₯Ό λ³΄λŠ” λ™μ•ˆ 이 μ‚¬μš©μžμ˜ 곡톡적인 정보λ₯Ό λ‚˜νƒ€λ‚΄λŠ” μƒμœ„ μ»΄ν¬λ„ŒνŠΈμ˜ content λŠ” fallback 이 μž‘λ™ν•˜μ§€ μ•ŠλŠ” 것이 μ’‹λ‹€. μ„ΈλΆ€ μ •λ³΄μ˜ μ—…λ°μ΄νŠΈλ§Œ fallback 이 λ°œμƒν•˜λŠ” 것이 쒋은 UI λ‹€.

ν•˜μ§€λ§Œ λ‹€λ₯Έ μ‚¬μš©μžλ‘œ 변경될 κ²½μš°λŠ” μ–΄λ–¨κΉŒ? λ™μΌν•œ UI 이기 λ•Œλ¬Έμ— 였히렀 fallback 이 없을 경우 λ³€ν™”κ°€ 크지 μ•Šλ‹€λ©΄ UI μ—…λ°μ΄νŠΈλ₯Ό μΈμ§€ν•˜μ§€ λͺ» ν•  κ°€λŠ₯성이 μžˆλ‹€. 이런 경우 μ˜λ„μ μœΌλ‘œ fallback λŒ€μ²΄λ₯Ό μž‘λ™μ‹œμΌœ UI μ—…λ°μ΄νŠΈκ°€ μ΄λ£¨μ–΄μ‘ŒμŒμ„ ν‘œν˜„ν•΄μ£ΌλŠ” 것이 ν™•μ‹€νžˆ UI μ—…λ°μ΄νŠΈλ₯Ό μΈμ§€μ‹œμΌœ 더 쒋은 μ‚¬μš©μž κ²½ν—˜μ„ κ°–κ²Œ ν•œλ‹€.

μ–΄λ–»κ²Œ ꡬ뢄할 수 μžˆμ„κΉŒ? μ»΄ν¬λ„ŒνŠΈμ˜ iterating μ‹œ key props λ₯Ό μ‚¬μš©ν•΄ μ»΄ν¬λ„ŒνŠΈλ₯Ό ꡬ뢄할 수 μžˆλŠ” κ³ μœ κ°’μ„ μ œκ³΅ν•œλ‹€. 이 κ²½μš°λ„ λ™μΌν•˜κ²Œ key props λ₯Ό μ‚¬μš©ν•˜λ©΄ Suspense λŠ” Transition 을 μ‚¬μš©ν•˜λ”λΌλ„ keyκ°€ λ³€κ²½λ˜λ©΄ λ‹€λ₯Έ μ»΄ν¬λ„ŒνŠΈλ‘œ 인식해 fallback λŒ€μ²΄λ₯Ό μˆ˜ν–‰ν•œλ‹€.

<ProfilePage key={queryParams.id} />

μ΄λ ‡κ²Œ key props 만 μ œκ³΅ν•˜λ©΄ λ³€κ²½ μ—¬λΆ€λ₯Ό λ¦¬μ•‘νŠΈκ°€ 확인 ν›„ fallback λŒ€μ²΄ μ—¬λΆ€λ₯Ό νŒλ‹¨ν•œλ‹€.

7. Error Boundary

useTransition을 μœ„ν•œ Error Boundary λŠ” λ¦¬μ•‘νŠΈ 19 에 좔가될 μ˜ˆμ •μœΌλ‘œ ν˜„μž¬λŠ” canary λ²„μ „μ—μ„œλ§Œ μ‚¬μš©μ΄ κ°€λŠ₯ν•˜λ‹€.

npm i react-error-boundary

곡식 λ¬Έμ €μ—μ„œλ„ 클래슀 μ»΄ν¬λ„ŒνŠΈλ‘œ 직접 κ΅¬ν˜„ν•˜κΈ°λ³΄λ‹€λŠ” bvaughn / react-error-boundary 라이브러리λ₯Ό μ‚¬μš©ν–ˆκΈ°μ— μ—¬κΈ°μ„œλ„ λ™μΌν•˜κ²Œ μ§„ν–‰ν•˜κ³ μž ν•œλ‹€. ν˜Ήμ‹œ ErrorBoundary cannot be used as a JSX component μ—λŸ¬κ°€ λ°œμƒν•˜λ©΄ 링크λ₯Ό μ°Έμ‘°ν•΄ ν•΄κ²°ν•˜λ„λ‘ ν•œλ‹€.

export function AddCommentContainer() {
  return (
    <ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>
      <AddCommentButton />
    </ErrorBoundary>
  );
}

function addComment(comment) {
  if (comment == null) {
    throw new Error("Example Error: An error thrown to trigger error boundary");
  }
}

function AddCommentButton() {
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() => {
        startTransition(() => {
          addComment();
        });
      }}
    >
      Add comment
    </button>
  );
}

3. Suspense with axios on React 18 πŸ‘©β€πŸ’»

useλŠ” ν˜„μž¬ canary μ—μ„œλ§Œ μ‚¬μš©μ΄ κ°€λŠ₯ν•˜λ‹€. ν•˜μ§€λ§Œ κΈ°λŠ₯을 μ•„μ˜ˆ λͺ» μ“°λŠ” 건 μ•„λ‹ˆκ³  React κ°€ κ³΅μ‹μ μœΌλ‘œ μ œκ³΅ν•˜λŠ” κΈ°λŠ₯으둜 Promise 뿐 μ•„λ‹ˆλΌ Context 에도 μ‚¬μš© κ°€λŠ₯ν•œ ν•¨μˆ˜μΌ 뿐 ν˜„μž¬λ„ Suspense 에 Promise λ₯Ό 컨트둀 ν•˜κΈ° μœ„ν•΄ Wrapping ν•¨μˆ˜ useλ₯Ό λ§Œλ“€μ–΄ μ‚¬μš©ν•  수 μžˆλ‹€. 곡식 λ¬Έμ„œμ—μ„œ μƒ˜ν”Œλ‘œ λ§Œλ“€μ–΄ μ‚¬μš©ν•˜λŠ” ν•¨μˆ˜λŠ” λ‹€μŒκ³Ό 같은 ν˜•νƒœλ₯Ό 띄고 μžˆλ‹€.

export function use(promise) {
  if (promise.status === "fulfilled") {
    return promise.value;
  } else if (promise.status === "rejected") {
    throw promise.reason;
  } else if (promise.status === "pending") {
    throw promise;
  } else {
    promise.status = "pending";
    promise.then(
      (result) => {
        promise.status = "fulfilled";
        promise.value = result;
      },
      (reason) => {
        promise.status = "rejected";
        promise.reason = reason;
      },
    );
    throw promise;
  }
}

TypeScript ν™˜κ²½μ΄λ©° Axios 라이브러리λ₯Ό μ‚¬μš©ν•œλ‹€λ©΄ λ‹€μŒκ³Ό 같이 Wrapping ν•¨μˆ˜ useλ₯Ό λ§Œλ“€μ–΄ μ‚¬μš©ν•  수 μžˆλ‹€.

import { AxiosPromise } from "axios";

export const use = <T>(promise: AxiosPromise<T>) => {
  let status = "pending";
  let result: T;

  const suspender = promise
    .then((response) => {
      status = "success";
      result = response.data;
    })
    .catch((response) => {
      status = "error";
      result = response.error;
    });

  return () => {
    switch (status) {
      case "pending":
        throw suspender;
      case "success":
        return result;
      case "error":
        return result as any;
      default:
        throw new Error("Unknown status");
    }
  };
};
<Suspense fallback={<Loading />}>
  <Purchased />
  <button onClick={() => navigate("/")}>첫 νŽ˜μ΄μ§€λ‘œ</button>
</Suspense>
const Purchased: React.FC = () => {
  const [orderHistory, setOrderHistory] = useState<OrderInfo[]>();
  const { totals } = useContext(OrderContext);

  const getData = async () => {
    const promise = axios.post("/order", { price: totals.total });
    setOrderHistory(use(promise));
  };

  useEffect(() => {
    getData();
  }, []);

  return (
    <PurchasedWrapper>
      <h2>주문이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€</h2>
      <h3>μ£Όλ¬Έ λ‚΄μ—­</h3>
      <div className="order-history">
        <h5>주문 번호</h5>
        <h5>μ£Όλ¬Έ κΈˆμ•‘</h5>
        {orderHistory?.map((orderInfo) => (
          <Fragment key={orderInfo.orderNumber}>
            <div>{orderInfo.orderNumber}</div>
            <div>{orderInfo.price}</div>
          </Fragment>
        ))}
      </div>
    </PurchasedWrapper>
  );
};




Reference

  1. β€œSuspense.” React.dev Docs. accessed Jun. 03, 2024, https://react.dev/reference/react/Suspense.
  2. β€œuseTransition.” React.dev Docs. accessed Jun. 03, 2024, https://react.dev/reference/react/useTransition.