<Suspense>를 사용하면 자식 컴포넌트들이 로딩을 완료할 때까지 fallback을 표시할 수 있어요.
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
<Suspense> {/suspense/}children: 실제로 렌더링하려는 UI예요. children이 렌더링 중에 중단(suspend)되면, Suspense 경계는 fallback을 렌더링하도록 전환할 거예요.fallback: 실제 UI가 로딩을 완료하지 못했을 때 대신 렌더링할 대체 UI예요. 모든 유효한 React 노드가 허용되지만, 실제로는 fallback은 로딩 스피너나 스켈레톤과 같은 가벼운 플레이스홀더 뷰예요. Suspense는 children이 중단되면 자동으로 fallback으로 전환하고, 데이터가 준비되면 다시 children으로 돌아가요. fallback이 렌더링 중에 중단되면, 가장 가까운 부모 Suspense 경계가 활성화될 거예요.startTransition이나 useDeferredValue로 인한 업데이트가 아닌 한 fallback이 다시 표시될 거예요.애플리케이션의 어느 부분이든 Suspense 경계로 감쌀 수 있어요:
<Suspense fallback={<Loading />}>
<Albums />
</Suspense>
React는 자식 컴포넌트에 필요한 모든 코드와 데이터가 로드될 때까지 로딩 fallback을 표시할 거예요.
아래 예제에서 Albums 컴포넌트는 앨범 목록을 가져오는 동안 중단돼요. 렌더링할 준비가 될 때까지, React는 가장 가까운 위쪽 Suspense 경계를 전환해서 fallback--즉 Loading 컴포넌트를 표시해요. 그런 다음 데이터가 로드되면, React는 Loading fallback을 숨기고 데이터와 함께 Albums 컴포넌트를 렌더링해요.
import { useState } from 'react';
import ArtistPage from './ArtistPage.js';
export default function App() {
const [show, setShow] = useState(false);
if (show) {
return (
<ArtistPage
artist={{
id: 'the-beatles',
name: 'The Beatles',
}}
/>
);
} else {
return (
<button onClick={() => setShow(true)}>
Open The Beatles artist page
</button>
);
}
}
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>;
}
import {use} from 'react';
import { fetchData } from './data.js';
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>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url === '/the-beatles/albums') {
return await getAlbums();
} else {
throw Error('Not implemented');
}
}
async function getAlbums() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 3000);
});
return [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
}
Suspense 컴포넌트를 활성화하는 것은 Suspense가 지원되는 데이터 소스뿐이에요. 다음을 포함해요:
lazy를 사용한 지연 로딩 컴포넌트 코드use를 사용한 캐시된 Promise 값 읽기Suspense는 Effect나 이벤트 핸들러 내부에서 데이터를 가져오는 것을 감지하지 못해요.
위의 Albums 컴포넌트에서 데이터를 로드하는 정확한 방법은 프레임워크에 따라 달라요. Suspense 지원 프레임워크를 사용한다면 데이터 가져오기 문서에서 세부 사항을 찾을 수 있을 거예요.
특정 프레임워크를 사용하지 않는 Suspense 지원 데이터 가져오기는 아직 지원되지 않아요. Suspense 지원 데이터 소스를 구현하기 위한 요구 사항은 불안정하고 문서화되지 않았어요. Suspense와 데이터 소스를 통합하기 위한 공식 API는 React의 향후 버전에서 출시될 예정이에요.
기본적으로 Suspense 내부의 전체 트리는 단일 단위로 취급돼요. 예를 들어, 이 컴포넌트들 중 하나만 일부 데이터를 기다리며 중단되더라도, 모두 함께 로딩 표시기로 대체될 거예요:
<Suspense fallback={<Loading />}>
<Biography />
<Panel>
<Albums />
</Panel>
</Suspense>
그런 다음 모두 표시될 준비가 되면, 모두 함께 한 번에 나타날 거예요.
아래 예제에서 Biography와 Albums 모두 일부 데이터를 가져와요. 하지만 단일 Suspense 경계 아래에 그룹화되어 있기 때문에, 이 컴포넌트들은 항상 동시에 함께 "팝인"돼요.
import { useState } from 'react';
import ArtistPage from './ArtistPage.js';
export default function App() {
const [show, setShow] = useState(false);
if (show) {
return (
<ArtistPage
artist={{
id: 'the-beatles',
name: 'The Beatles',
}}
/>
);
} else {
return (
<button onClick={() => setShow(true)}>
Open The Beatles artist page
</button>
);
}
}
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={<Loading />}>
<Biography artistId={artist.id} />
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
</>
);
}
function Loading() {
return <h2>🌀 Loading...</h2>;
}
export default function Panel({ children }) {
return (
<section className="panel">
{children}
</section>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
export default function Biography({ artistId }) {
const bio = use(fetchData(`/${artistId}/bio`));
return (
<section>
<p className="bio">{bio}</p>
</section>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
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>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url === '/the-beatles/albums') {
return await getAlbums();
} else if (url === '/the-beatles/bio') {
return await getBio();
} else {
throw Error('Not implemented');
}
}
async function getBio() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 1500);
});
return `The Beatles were an English rock band,
formed in Liverpool in 1960, that comprised
John Lennon, Paul McCartney, George Harrison
and Ringo Starr.`;
}
async function getAlbums() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 3000);
});
return [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
}
.bio { font-style: italic; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
데이터를 로드하는 컴포넌트가 Suspense 경계의 직접적인 자식일 필요는 없어요. 예를 들어, Biography와 Albums를 새로운 Details 컴포넌트로 이동할 수 있어요. 이것은 동작을 변경하지 않아요. Biography와 Albums는 같은 가장 가까운 부모 Suspense 경계를 공유하므로, 함께 조율되어 표시돼요.
<Suspense fallback={<Loading />}>
<Details artistId={artist.id} />
</Suspense>
function Details({ artistId }) {
return (
<>
<Biography artistId={artistId} />
<Panel>
<Albums artistId={artistId} />
</Panel>
</>
);
}
컴포넌트가 중단되면, 가장 가까운 부모 Suspense 컴포넌트가 fallback을 표시해요. 이를 통해 여러 Suspense 컴포넌트를 중첩해서 로딩 시퀀스를 만들 수 있어요. 각 Suspense 경계의 fallback은 다음 레벨의 콘텐츠가 사용 가능해지면 채워질 거예요. 예를 들어, 앨범 목록에 자체 fallback을 줄 수 있어요:
<Suspense fallback={<BigSpinner />}>
<Biography />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums />
</Panel>
</Suspense>
</Suspense>
이 변경으로, Biography를 표시하는 데 Albums가 로드될 때까지 "기다릴" 필요가 없어요.
시퀀스는 다음과 같을 거예요:
Biography가 아직 로드되지 않았다면, 전체 콘텐츠 영역 대신 BigSpinner가 표시돼요.Biography 로딩이 완료되면, BigSpinner가 콘텐츠로 대체돼요.Albums가 아직 로드되지 않았다면, Albums와 부모 Panel 대신 AlbumsGlimmer가 표시돼요.Albums 로딩이 완료되면, AlbumsGlimmer를 대체해요.import { useState } from 'react';
import ArtistPage from './ArtistPage.js';
export default function App() {
const [show, setShow] = useState(false);
if (show) {
return (
<ArtistPage
artist={{
id: 'the-beatles',
name: 'The Beatles',
}}
/>
);
} else {
return (
<button onClick={() => setShow(true)}>
Open The Beatles artist page
</button>
);
}
}
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>
);
}
export default function Panel({ children }) {
return (
<section className="panel">
{children}
</section>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
export default function Biography({ artistId }) {
const bio = use(fetchData(`/${artistId}/bio`));
return (
<section>
<p className="bio">{bio}</p>
</section>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
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>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url === '/the-beatles/albums') {
return await getAlbums();
} else if (url === '/the-beatles/bio') {
return await getBio();
} else {
throw Error('Not implemented');
}
}
async function getBio() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 500);
});
return `The Beatles were an English rock band,
formed in Liverpool in 1960, that comprised
John Lennon, Paul McCartney, George Harrison
and Ringo Starr.`;
}
async function getAlbums() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 3000);
});
return [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
}
.bio { font-style: italic; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-panel {
border: 1px dashed #aaa;
background: linear-gradient(90deg, rgba(221,221,221,1) 0%, rgba(255,255,255,1) 100%);
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-line {
display: block;
width: 60%;
height: 20px;
margin: 10px;
border-radius: 4px;
background: #f0f0f0;
}
Suspense 경계를 사용하면 UI의 어느 부분이 항상 동시에 함께 "팝인"되어야 하는지, 그리고 어느 부분이 로딩 상태 시퀀스에서 점진적으로 더 많은 콘텐츠를 표시해야 하는지 조율할 수 있어요. 트리의 어디에든 Suspense 경계를 추가, 이동 또는 삭제할 수 있으며 앱의 나머지 동작에는 영향을 주지 않아요.
모든 컴포넌트 주위에 Suspense 경계를 두지 마세요. Suspense 경계는 사용자가 경험하기를 원하는 로딩 시퀀스보다 더 세분화되어서는 안 돼요. 디자이너와 함께 작업한다면, 로딩 상태가 어디에 배치되어야 하는지 물어보세요--디자인 와이어프레임에 이미 포함되어 있을 가능성이 높아요.
이 예제에서 SearchResults 컴포넌트는 검색 결과를 가져오는 동안 중단돼요. "a"를 입력하고 결과를 기다린 다음 "ab"로 수정해보세요. "a"의 결과가 로딩 fallback으로 대체될 거예요.
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>
</>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
export default function SearchResults({ query }) {
if (query === '') {
return null;
}
const albums = use(fetchData(`/search?q=${query}`));
if (albums.length === 0) {
return <p>No matches for <i>"{query}"</i></p>;
}
return (
<ul>
{albums.map(album => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url.startsWith('/search?q=')) {
return await getSearchResults(url.slice('/search?q='.length));
} else {
throw Error('Not implemented');
}
}
async function getSearchResults(query) {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 500);
});
const allAlbums = [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
const lowerQuery = query.trim().toLowerCase();
return allAlbums.filter(album => {
const lowerTitle = album.title.toLowerCase();
return (
lowerTitle.startsWith(lowerQuery) ||
lowerTitle.indexOf(' ' + lowerQuery) !== -1
)
});
}
input { margin: 10px; }
일반적인 대체 UI 패턴은 목록 업데이트를 지연하고 새 결과가 준비될 때까지 이전 결과를 계속 표시하는 거예요. useDeferredValue Hook을 사용하면 쿼리의 지연된 버전을 전달할 수 있어요:
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>
</>
);
}
query는 즉시 업데이트되므로 입력에 새 값이 표시될 거예요. 하지만 deferredQuery는 데이터가 로드될 때까지 이전 값을 유지하므로, SearchResults는 잠시 동안 오래된 결과를 표시할 거예요.
사용자에게 더 명확하게 하려면, 오래된 결과 목록이 표시될 때 시각적 표시를 추가할 수 있어요:
<div style={{
opacity: query !== deferredQuery ? 0.5 : 1
}}>
<SearchResults query={deferredQuery} />
</div>
아래 예제에서 "a"를 입력하고 결과가 로드될 때까지 기다린 다음 입력을 "ab"로 수정해보세요. Suspense fallback 대신 새 결과가 로드될 때까지 흐릿한 오래된 결과 목록이 표시되는 것을 확인할 수 있어요:
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>
</>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
export default function SearchResults({ query }) {
if (query === '') {
return null;
}
const albums = use(fetchData(`/search?q=${query}`));
if (albums.length === 0) {
return <p>No matches for <i>"{query}"</i></p>;
}
return (
<ul>
{albums.map(album => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url.startsWith('/search?q=')) {
return await getSearchResults(url.slice('/search?q='.length));
} else {
throw Error('Not implemented');
}
}
async function getSearchResults(query) {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 500);
});
const allAlbums = [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
const lowerQuery = query.trim().toLowerCase();
return allAlbums.filter(album => {
const lowerTitle = album.title.toLowerCase();
return (
lowerTitle.startsWith(lowerQuery) ||
lowerTitle.indexOf(' ' + lowerQuery) !== -1
)
});
}
input { margin: 10px; }
지연된 값과 Transition 모두 인라인 표시기를 선호하여 Suspense fallback 표시를 피할 수 있게 해줘요. Transition은 전체 업데이트를 긴급하지 않은 것으로 표시하므로 일반적으로 프레임워크와 라우터 라이브러리에서 네비게이션에 사용돼요. 반면에 지연된 값은 주로 UI의 일부를 긴급하지 않은 것으로 표시하고 나머지 UI보다 "뒤처지게" 하려는 애플리케이션 코드에서 유용해요.
컴포넌트가 중단되면, 가장 가까운 부모 Suspense 경계가 fallback을 표시하도록 전환돼요. 이미 일부 콘텐츠를 표시하고 있었다면 사용자 경험이 불편할 수 있어요. 이 버튼을 눌러보세요:
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>;
}
export default function Layout({ children }) {
return (
<div className="layout">
<section className="header">
Music Browser
</section>
<main>
{children}
</main>
</div>
);
}
export default function IndexPage({ navigate }) {
return (
<button onClick={() => navigate('/the-beatles')}>
Open The Beatles artist page
</button>
);
}
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>
<Biography artistId={artist.id} />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
</>
);
}
function AlbumsGlimmer() {
return (
<div className="glimmer-panel">
<div className="glimmer-line" />
<div className="glimmer-line" />
<div className="glimmer-line" />
</div>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
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>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
export default function Biography({ artistId }) {
const bio = use(fetchData(`/${artistId}/bio`));
return (
<section>
<p className="bio">{bio}</p>
</section>
);
}
export default function Panel({ children }) {
return (
<section className="panel">
{children}
</section>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url === '/the-beatles/albums') {
return await getAlbums();
} else if (url === '/the-beatles/bio') {
return await getBio();
} else {
throw Error('Not implemented');
}
}
async function getBio() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 500);
});
return `The Beatles were an English rock band,
formed in Liverpool in 1960, that comprised
John Lennon, Paul McCartney, George Harrison
and Ringo Starr.`;
}
async function getAlbums() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 3000);
});
return [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
}
main {
min-height: 200px;
padding: 10px;
}
.layout {
border: 1px solid black;
}
.header {
background: #222;
padding: 10px;
text-align: center;
color: white;
}
.bio { font-style: italic; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-panel {
border: 1px dashed #aaa;
background: linear-gradient(90deg, rgba(221,221,221,1) 0%, rgba(255,255,255,1) 100%);
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-line {
display: block;
width: 60%;
height: 20px;
margin: 10px;
border-radius: 4px;
background: #f0f0f0;
}
버튼을 눌렀을 때, Router 컴포넌트는 IndexPage 대신 ArtistPage를 렌더링했어요. ArtistPage 내부의 컴포넌트가 중단되어 가장 가까운 Suspense 경계가 fallback을 표시하기 시작했어요. 가장 가까운 Suspense 경계가 루트 근처에 있었기 때문에, 전체 사이트 레이아웃이 BigSpinner로 대체되었어요.
이를 방지하려면 네비게이션 상태 업데이트를 startTransition을 사용해서 Transition으로 표시할 수 있어요:
function Router() {
const [page, setPage] = useState('/');
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
// ...
이것은 React에게 상태 전환이 긴급하지 않으며, 이미 공개된 콘텐츠를 숨기는 것보다 이전 페이지를 계속 표시하는 것이 더 낫다고 알려줘요. 이제 버튼을 클릭하면 Biography가 로드될 때까지 "기다려요":
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>;
}
export default function Layout({ children }) {
return (
<div className="layout">
<section className="header">
Music Browser
</section>
<main>
{children}
</main>
</div>
);
}
export default function IndexPage({ navigate }) {
return (
<button onClick={() => navigate('/the-beatles')}>
Open The Beatles artist page
</button>
);
}
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>
<Biography artistId={artist.id} />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
</>
);
}
function AlbumsGlimmer() {
return (
<div className="glimmer-panel">
<div className="glimmer-line" />
<div className="glimmer-line" />
<div className="glimmer-line" />
</div>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
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>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
export default function Biography({ artistId }) {
const bio = use(fetchData(`/${artistId}/bio`));
return (
<section>
<p className="bio">{bio}</p>
</section>
);
}
export default function Panel({ children }) {
return (
<section className="panel">
{children}
</section>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url === '/the-beatles/albums') {
return await getAlbums();
} else if (url === '/the-beatles/bio') {
return await getBio();
} else {
throw Error('Not implemented');
}
}
async function getBio() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 500);
});
return `The Beatles were an English rock band,
formed in Liverpool in 1960, that comprised
John Lennon, Paul McCartney, George Harrison
and Ringo Starr.`;
}
async function getAlbums() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 3000);
});
return [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
}
main {
min-height: 200px;
padding: 10px;
}
.layout {
border: 1px solid black;
}
.header {
background: #222;
padding: 10px;
text-align: center;
color: white;
}
.bio { font-style: italic; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-panel {
border: 1px dashed #aaa;
background: linear-gradient(90deg, rgba(221,221,221,1) 0%, rgba(255,255,255,1) 100%);
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-line {
display: block;
width: 60%;
height: 20px;
margin: 10px;
border-radius: 4px;
background: #f0f0f0;
}
Transition은 모든 콘텐츠가 로드될 때까지 기다리지 않아요. 이미 공개된 콘텐츠를 숨기지 않을 만큼만 충분히 기다려요. 예를 들어, 웹사이트 Layout은 이미 공개되었으므로 로딩 스피너 뒤에 숨기는 것은 나쁠 거예요. 하지만 Albums 주위의 중첩된 Suspense 경계는 새로운 것이므로, Transition은 그것을 기다리지 않아요.
Suspense 지원 라우터는 기본적으로 네비게이션 업데이트를 Transition으로 감싸야 해요.
위의 예제에서는 버튼을 클릭하면 네비게이션이 진행 중이라는 시각적 표시가 없어요. 표시기를 추가하려면 startTransition을 불린 값 isPending을 제공하는 useTransition으로 대체할 수 있어요. 아래 예제에서는 Transition이 진행되는 동안 웹사이트 헤더 스타일을 변경하는 데 사용돼요:
import { Suspense, useState, useTransition } 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('/');
const [isPending, startTransition] = useTransition();
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 isPending={isPending}>
{content}
</Layout>
);
}
function BigSpinner() {
return <h2>🌀 Loading...</h2>;
}
export default function Layout({ children, isPending }) {
return (
<div className="layout">
<section className="header" style={{
opacity: isPending ? 0.7 : 1
}}>
Music Browser
</section>
<main>
{children}
</main>
</div>
);
}
export default function IndexPage({ navigate }) {
return (
<button onClick={() => navigate('/the-beatles')}>
Open The Beatles artist page
</button>
);
}
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>
<Biography artistId={artist.id} />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
</>
);
}
function AlbumsGlimmer() {
return (
<div className="glimmer-panel">
<div className="glimmer-line" />
<div className="glimmer-line" />
<div className="glimmer-line" />
</div>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
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>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
export default function Biography({ artistId }) {
const bio = use(fetchData(`/${artistId}/bio`));
return (
<section>
<p className="bio">{bio}</p>
</section>
);
}
export default function Panel({ children }) {
return (
<section className="panel">
{children}
</section>
);
}
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.
let cache = new Map();
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url));
}
return cache.get(url);
}
async function getData(url) {
if (url === '/the-beatles/albums') {
return await getAlbums();
} else if (url === '/the-beatles/bio') {
return await getBio();
} else {
throw Error('Not implemented');
}
}
async function getBio() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 500);
});
return `The Beatles were an English rock band,
formed in Liverpool in 1960, that comprised
John Lennon, Paul McCartney, George Harrison
and Ringo Starr.`;
}
async function getAlbums() {
// Add a fake delay to make waiting noticeable.
await new Promise(resolve => {
setTimeout(resolve, 3000);
});
return [{
id: 13,
title: 'Let It Be',
year: 1970
}, {
id: 12,
title: 'Abbey Road',
year: 1969
}, {
id: 11,
title: 'Yellow Submarine',
year: 1969
}, {
id: 10,
title: 'The Beatles',
year: 1968
}, {
id: 9,
title: 'Magical Mystery Tour',
year: 1967
}, {
id: 8,
title: 'Sgt. Pepper\'s Lonely Hearts Club Band',
year: 1967
}, {
id: 7,
title: 'Revolver',
year: 1966
}, {
id: 6,
title: 'Rubber Soul',
year: 1965
}, {
id: 5,
title: 'Help!',
year: 1965
}, {
id: 4,
title: 'Beatles For Sale',
year: 1964
}, {
id: 3,
title: 'A Hard Day\'s Night',
year: 1964
}, {
id: 2,
title: 'With The Beatles',
year: 1963
}, {
id: 1,
title: 'Please Please Me',
year: 1963
}];
}
main {
min-height: 200px;
padding: 10px;
}
.layout {
border: 1px solid black;
}
.header {
background: #222;
padding: 10px;
text-align: center;
color: white;
}
.bio { font-style: italic; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-panel {
border: 1px dashed #aaa;
background: linear-gradient(90deg, rgba(221,221,221,1) 0%, rgba(255,255,255,1) 100%);
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
.glimmer-line {
display: block;
width: 60%;
height: 20px;
margin: 10px;
border-radius: 4px;
background: #f0f0f0;
}
Transition 중에 React는 이미 공개된 콘텐츠를 숨기지 않을 거예요. 하지만 다른 매개변수가 있는 경로로 이동하는 경우, React에게 다른 콘텐츠임을 알려주고 싶을 수 있어요. key를 사용해서 이를 표현할 수 있어요:
<ProfilePage key={queryParams.id} />
사용자의 프로필 페이지 내에서 탐색 중이고 무언가 중단된다고 상상해보세요. 그 업데이트가 Transition으로 감싸져 있다면, 이미 보이는 콘텐츠에 대한 fallback을 트리거하지 않을 거예요. 이것이 예상되는 동작이에요.
그러나 이제 두 개의 다른 사용자 프로필 사이를 탐색한다고 상상해보세요. 이 경우 fallback을 표시하는 것이 합리적이에요. 예를 들어, 한 사용자의 타임라인은 다른 사용자의 타임라인과 다른 콘텐츠예요. key를 지정하면, React가 다른 사용자의 프로필을 다른 컴포넌트로 취급하고 네비게이션 중에 Suspense 경계를 리셋하도록 보장할 수 있어요. Suspense 통합 라우터는 이것을 자동으로 해야 해요.
스트리밍 서버 렌더링 API 중 하나를 사용하거나 이에 의존하는 프레임워크를 사용하는 경우, React는 서버에서 에러를 처리하기 위해 <Suspense> 경계도 사용할 거예요. 컴포넌트가 서버에서 에러를 던지면, React는 서버 렌더링을 중단하지 않아요. 대신 그 위에 있는 가장 가까운 <Suspense> 컴포넌트를 찾고 생성된 서버 HTML에 해당 fallback(예: 스피너)을 포함할 거예요. 사용자는 처음에 스피너를 볼 거예요.
클라이언트에서 React는 동일한 컴포넌트를 다시 렌더링하려고 시도할 거예요. 클라이언트에서도 에러가 발생하면, React는 에러를 던지고 가장 가까운 에러 바운더리를 표시할 거예요. 하지만 클라이언트에서 에러가 발생하지 않으면, 콘텐츠가 결국 성공적으로 표시되었으므로 React는 사용자에게 에러를 표시하지 않을 거예요.
이를 사용해서 일부 컴포넌트를 서버에서 렌더링하지 않도록 할 수 있어요. 이렇게 하려면 서버 환경에서 에러를 던지고 <Suspense> 경계로 감싸서 HTML을 fallback으로 대체하세요:
<Suspense fallback={<Loading />}>
<Chat />
</Suspense>
function Chat() {
if (typeof window === 'undefined') {
throw Error('Chat should only render on the client.');
}
// ...
}
서버 HTML에는 로딩 표시기가 포함될 거예요. 클라이언트에서 Chat 컴포넌트로 대체될 거예요.
보이는 UI를 fallback으로 대체하면 사용자 경험이 불편해져요. 이것은 업데이트로 인해 컴포넌트가 중단되고, 가장 가까운 Suspense 경계가 이미 사용자에게 콘텐츠를 표시하고 있을 때 발생할 수 있어요.
이것이 발생하지 않도록 하려면, startTransition을 사용해서 업데이트를 긴급하지 않은 것으로 표시하세요. Transition 중에 React는 원치 않는 fallback이 나타나지 않도록 충분한 데이터가 로드될 때까지 기다릴 거예요:
function handleNextPageClick() {
// 이 업데이트가 중단되면, 이미 표시된 콘텐츠를 숨기지 마세요
startTransition(() => {
setCurrentPage(currentPage + 1);
});
}
이렇게 하면 기존 콘텐츠를 숨기지 않을 거예요. 하지만 새로 렌더링된 Suspense 경계는 여전히 UI를 차단하는 것을 방지하고 사용자가 사용 가능해지는 대로 콘텐츠를 볼 수 있도록 즉시 fallback을 표시할 거예요.
React는 긴급하지 않은 업데이트 중에만 원치 않는 fallback을 방지해요. 긴급한 업데이트의 결과인 경우 렌더링을 지연하지 않을 거예요. startTransition이나 useDeferredValue와 같은 API로 명시적으로 선택해야 해요.
라우터가 Suspense와 통합되어 있다면, 업데이트를 자동으로 startTransition으로 감싸야 해요.