useTransition은 UI의 일부를 백그라운드에서 렌더링할 수 있게 해주는 React Hook이에요.
const [isPending, startTransition] = useTransition()
useTransition() {/usetransition/}컴포넌트의 최상위 레벨에서 useTransition을 호출해서 일부 상태 업데이트를 Transition으로 표시할 수 있어요.
// App.js
import { useTransition } from 'react';
function TabContainer() {
const [isPending, startTransition] = useTransition();
// ...
}
useTransition은 매개변수를 받지 않아요.
useTransition은 정확히 두 개의 항목을 가진 배열을 반환해요:
isPending 플래그startTransition 함수startTransition(action) {/starttransition/}useTransition이 반환하는 startTransition 함수를 사용하면 업데이트를 Transition으로 표시할 수 있어요.
// TabContainer.js
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
// ...
}
#### `startTransition`에서 호출되는 함수는 "Actions"라고 불러요 {/*functions-called-in-starttransition-are-called-actions*/}
startTransition에 전달되는 함수를 "Action"이라고 불러요. 관례상, startTransition 내부에서 호출되는 모든 콜백(예: 콜백 prop)은 action이라는 이름을 사용하거나 "Action" 접미사를 포함해야 해요:
// SubmitButton.js
function SubmitButton({ submitAction }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await submitAction();
});
}}
>
Submit
</button>
);
}
action: 하나 이상의 set 함수를 호출하여 상태를 업데이트하는 함수예요. React는 매개변수 없이 즉시 action을 호출하고, action 함수 호출 중에 동기적으로 예약된 모든 상태 업데이트를 Transition으로 표시해요. action 내에서 await되는 모든 비동기 호출은 Transition에 포함되지만, 현재는 await 이후의 모든 set 함수를 추가 startTransition으로 감싸야 해요 (문제 해결 참조). Transition으로 표시된 상태 업데이트는 논블로킹이며 원치 않는 로딩 표시기를 표시하지 않아요.startTransition은 아무것도 반환하지 않아요.
useTransition은 Hook이므로 컴포넌트나 커스텀 Hook 내부에서만 호출할 수 있어요. 다른 곳(예: 데이터 라이브러리)에서 Transition을 시작해야 한다면, 독립형 startTransition을 대신 호출하세요.
해당 상태의 set 함수에 접근할 수 있는 경우에만 업데이트를 Transition으로 감쌀 수 있어요. prop이나 커스텀 Hook 값에 대한 응답으로 Transition을 시작하려면 useDeferredValue를 사용해보세요.
startTransition에 전달하는 함수는 즉시 호출되며, 실행되는 동안 발생하는 모든 상태 업데이트를 Transition으로 표시해요. 예를 들어 setTimeout 내에서 상태 업데이트를 수행하려고 하면 Transition으로 표시되지 않아요.
비동기 요청 후의 모든 상태 업데이트를 Transition으로 표시하려면 다른 startTransition으로 감싸야 해요. 이것은 향후 수정될 알려진 제한사항이에요 (문제 해결 참조).
startTransition 함수는 안정적인 identity를 가지므로 Effect 의존성에서 생략되는 것을 자주 볼 수 있지만, 포함해도 Effect가 실행되지 않아요. 린터가 에러 없이 의존성을 생략하도록 허용하면 안전하게 생략할 수 있어요. Effect 의존성 제거에 대해 더 알아보세요.
Transition으로 표시된 상태 업데이트는 다른 상태 업데이트에 의해 중단될 거예요. 예를 들어, Transition 내에서 차트 컴포넌트를 업데이트하지만 차트가 리렌더링 중일 때 입력을 시작하면, React는 입력 업데이트를 처리한 후 차트 컴포넌트의 렌더링 작업을 다시 시작해요.
Transition 업데이트는 텍스트 입력을 제어하는 데 사용할 수 없어요.
여러 개의 진행 중인 Transition이 있는 경우, React는 현재 이들을 일괄 처리해요. 이것은 향후 릴리스에서 제거될 수 있는 제한사항이에요.
컴포넌트 최상단에서 useTransition을 호출해서 Actions를 생성하고 대기 중인 상태에 접근하세요:
import {useState, useTransition} from 'react';
function CheckoutForm() {
const [isPending, startTransition] = useTransition();
// ...
}
useTransition은 정확히 두 개의 항목을 가진 배열을 반환해요:
isPending 플래그startTransition 함수Transition을 시작하려면 다음과 같이 startTransition에 함수를 전달하세요:
import {useState, useTransition} from 'react';
import {updateQuantity} from './api';
function CheckoutForm() {
const [isPending, startTransition] = useTransition();
const [quantity, setQuantity] = useState(1);
function onSubmit(newQuantity) {
startTransition(async function () {
const savedQuantity = await updateQuantity(newQuantity);
startTransition(() => {
setQuantity(savedQuantity);
});
});
}
// ...
}
startTransition에 전달된 함수를 "Action"이라고 불러요. Action 내에서 상태를 업데이트하고 (선택적으로) 부수 효과를 수행할 수 있으며, 이 작업은 페이지의 사용자 상호작용을 차단하지 않고 백그라운드에서 수행돼요. Transition은 여러 Actions를 포함할 수 있으며, Transition이 진행되는 동안 UI는 응답성을 유지해요. 예를 들어, 사용자가 탭을 클릭했다가 마음을 바꿔 다른 탭을 클릭하면, 첫 번째 업데이트가 완료되기를 기다리지 않고 두 번째 클릭이 즉시 처리돼요.
진행 중인 Transition에 대한 사용자 피드백을 제공하기 위해, isPending 상태는 startTransition의 첫 번째 호출에서 true로 전환되고, 모든 Actions가 완료되고 최종 상태가 사용자에게 표시될 때까지 true로 유지돼요. Transition은 원치 않는 로딩 표시기를 방지하기 위해 Actions의 부수 효과가 순서대로 완료되도록 보장하며, useOptimistic으로 Transition이 진행 중일 때 즉각적인 피드백을 제공할 수 있어요.
이 예제에서 updateQuantity 함수는 장바구니의 항목 수량을 업데이트하는 서버 요청을 시뮬레이션해요. 이 함수는 요청을 완료하는 데 최소 1초가 걸리도록 인위적으로 느려졌어요.
수량을 여러 번 빠르게 업데이트해보세요. 요청이 진행 중일 때 대기 중인 "Total" 상태가 표시되고, 마지막 요청이 완료된 후에만 "Total"이 업데이트되는 것을 확인할 수 있어요. 업데이트가 Action 내에 있기 때문에 요청이 진행 중일 때도 "quantity"를 계속 업데이트할 수 있어요.
{
"dependencies": {
"react": "beta",
"react-dom": "beta"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useTransition } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";
export default function App({}) {
const [quantity, setQuantity] = useState(1);
const [isPending, startTransition] = useTransition();
const updateQuantityAction = async newQuantity => {
// Transition의 대기 중인 상태에 접근하려면,
// startTransition을 다시 호출하세요.
startTransition(async () => {
const savedQuantity = await updateQuantity(newQuantity);
startTransition(() => {
setQuantity(savedQuantity);
});
});
};
return (
<div>
<h1>Checkout</h1>
<Item action={updateQuantityAction}/>
<hr />
<Total quantity={quantity} isPending={isPending} />
</div>
);
}
import { startTransition } from "react";
export default function Item({action}) {
function handleChange(event) {
// action prop을 노출하려면, startTransition에서 콜백을 await하세요.
startTransition(async () => {
await action(event.target.value);
})
}
return (
<div className="item">
<span>Eras Tour Tickets</span>
<label htmlFor="name">Quantity: </label>
<input
type="number"
onChange={handleChange}
defaultValue={1}
min={1}
/>
</div>
)
}
const intl = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
export default function Total({quantity, isPending}) {
return (
<div className="total">
<span>Total:</span>
<span>
{isPending ? "🌀 Updating..." : `${intl.format(quantity * 9999)}`}
</span>
</div>
)
}
export async function updateQuantity(newQuantity) {
return new Promise((resolve, reject) => {
// 느린 네트워크 요청을 시뮬레이션합니다.
setTimeout(() => {
resolve(newQuantity);
}, 2000);
});
}
.item {
display: flex;
align-items: center;
justify-content: start;
}
.item label {
flex: 1;
text-align: right;
}
.item input {
margin-left: 4px;
width: 60px;
padding: 4px;
}
.total {
height: 50px;
line-height: 25px;
display: flex;
align-content: center;
justify-content: space-between;
}
이것은 Actions가 어떻게 작동하는지 보여주는 기본 예제지만, 이 예제는 순서대로 완료되지 않는 요청을 처리하지 않아요. 수량을 여러 번 업데이트할 때, 이전 요청이 나중 요청 이후에 완료되어 수량이 순서대로 업데이트되지 않을 수 있어요. 이것은 향후 수정될 알려진 제한사항이에요 (아래 문제 해결 참조).
일반적인 사용 사례의 경우, React는 다음과 같은 내장 추상화를 제공해요:
이러한 솔루션은 요청 순서를 자동으로 처리해줘요. Transition을 사용해서 비동기 상태 전환을 관리하는 자체 커스텀 훅이나 라이브러리를 구축할 때는 요청 순서를 더 세밀하게 제어할 수 있지만, 직접 처리해야 해요.
이 예제에서도 updateQuantity 함수는 장바구니의 항목 수량을 업데이트하는 서버 요청을 시뮬레이션해요. 이 함수는 요청을 완료하는 데 최소 1초가 걸리도록 인위적으로 느려졌어요.
수량을 여러 번 빠르게 업데이트해보세요. 요청이 진행 중일 때 대기 중인 "Total" 상태가 표시되지만, "quantity"를 클릭한 횟수만큼 "Total"이 여러 번 업데이트되는 것을 확인할 수 있어요:
{
"dependencies": {
"react": "beta",
"react-dom": "beta"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";
export default function App({}) {
const [quantity, setQuantity] = useState(1);
const [isPending, setIsPending] = useState(false);
const onUpdateQuantity = async newQuantity => {
// isPending 상태를 수동으로 설정합니다.
setIsPending(true);
const savedQuantity = await updateQuantity(newQuantity);
setIsPending(false);
setQuantity(savedQuantity);
};
return (
<div>
<h1>Checkout</h1>
<Item onUpdateQuantity={onUpdateQuantity}/>
<hr />
<Total quantity={quantity} isPending={isPending} />
</div>
);
}
export default function Item({onUpdateQuantity}) {
function handleChange(event) {
onUpdateQuantity(event.target.value);
}
return (
<div className="item">
<span>Eras Tour Tickets</span>
<label htmlFor="name">Quantity: </label>
<input
type="number"
onChange={handleChange}
defaultValue={1}
min={1}
/>
</div>
)
}
const intl = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
export default function Total({quantity, isPending}) {
return (
<div className="total">
<span>Total:</span>
<span>
{isPending ? "🌀 Updating..." : `${intl.format(quantity * 9999)}`}
</span>
</div>
)
}
export async function updateQuantity(newQuantity) {
return new Promise((resolve, reject) => {
// 느린 네트워크 요청을 시뮬레이션합니다.
setTimeout(() => {
resolve(newQuantity);
}, 2000);
});
}
.item {
display: flex;
align-items: center;
justify-content: start;
}
.item label {
flex: 1;
text-align: right;
}
.item input {
margin-left: 4px;
width: 60px;
padding: 4px;
}
.total {
height: 50px;
line-height: 25px;
display: flex;
align-content: center;
justify-content: space-between;
}
이 문제에 대한 일반적인 해결책은 수량이 업데이트되는 동안 사용자가 변경하지 못하도록 막는 거예요:
{
"dependencies": {
"react": "beta",
"react-dom": "beta"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useTransition } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";
export default function App({}) {
const [quantity, setQuantity] = useState(1);
const [isPending, setIsPending] = useState(false);
const onUpdateQuantity = async event => {
const newQuantity = event.target.value;
// isPending 상태를 수동으로 설정합니다.
setIsPending(true);
const savedQuantity = await updateQuantity(newQuantity);
setIsPending(false);
setQuantity(savedQuantity);
};
return (
<div>
<h1>Checkout</h1>
<Item isPending={isPending} onUpdateQuantity={onUpdateQuantity}/>
<hr />
<Total quantity={quantity} isPending={isPending} />
</div>
);
}
export default function Item({isPending, onUpdateQuantity}) {
return (
<div className="item">
<span>Eras Tour Tickets</span>
<label htmlFor="name">Quantity: </label>
<input
type="number"
disabled={isPending}
onChange={onUpdateQuantity}
defaultValue={1}
min={1}
/>
</div>
)
}
const intl = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
export default function Total({quantity, isPending}) {
return (
<div className="total">
<span>Total:</span>
<span>
{isPending ? "🌀 Updating..." : `${intl.format(quantity * 9999)}`}
</span>
</div>
)
}
export async function updateQuantity(newQuantity) {
return new Promise((resolve, reject) => {
// 느린 네트워크 요청을 시뮬레이션합니다.
setTimeout(() => {
resolve(newQuantity);
}, 2000);
});
}
.item {
display: flex;
align-items: center;
justify-content: start;
}
.item label {
flex: 1;
text-align: right;
}
.item input {
margin-left: 4px;
width: 60px;
padding: 4px;
}
.total {
height: 50px;
line-height: 25px;
display: flex;
align-content: center;
justify-content: space-between;
}
이 솔루션은 사용자가 수량을 업데이트할 때마다 기다려야 하기 때문에 앱이 느리게 느껴져요. 수량이 업데이트되는 동안 사용자가 UI와 상호작용할 수 있도록 수동으로 더 복잡한 처리를 추가할 수도 있지만, Actions는 간단한 내장 API로 이 경우를 처리해요.
action prop 노출하기 {/exposing-action-props-from-components/}컴포넌트에서 action prop을 노출해서 부모가 Action을 호출할 수 있도록 할 수 있어요.
예를 들어, 이 TabButton 컴포넌트는 onClick 로직을 action prop으로 감싸요:
export default function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
return (
<button onClick={() => {
startTransition(async () => {
// 전달된 action을 await합니다.
// 이렇게 하면 동기 또는 비동기 모두 가능합니다.
await action();
});
}}>
{children}
</button>
);
}
부모 컴포넌트가 action 내에서 상태를 업데이트하기 때문에, 그 상태 업데이트는 Transition으로 표시돼요. 이는 "Posts"를 클릭한 후 즉시 "Contact"를 클릭해도 사용자 상호작용이 차단되지 않는다는 의미예요:
import { useState } from 'react';
import TabButton from './TabButton.js';
import AboutTab from './AboutTab.js';
import PostsTab from './PostsTab.js';
import ContactTab from './ContactTab.js';
export default function TabContainer() {
const [tab, setTab] = useState('about');
return (
<>
<TabButton
isActive={tab === 'about'}
action={() => setTab('about')}
>
About
</TabButton>
<TabButton
isActive={tab === 'posts'}
action={() => setTab('posts')}
>
Posts (slow)
</TabButton>
<TabButton
isActive={tab === 'contact'}
action={() => setTab('contact')}
>
Contact
</TabButton>
<hr />
{tab === 'about' && <AboutTab />}
{tab === 'posts' && <PostsTab />}
{tab === 'contact' && <ContactTab />}
</>
);
}
import { useTransition } from 'react';
export default function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
if (isPending) {
return <b className="pending">{children}</b>;
}
return (
<button onClick={async () => {
startTransition(async () => {
// 전달된 action을 await합니다.
// 이렇게 하면 동기 또는 비동기 모두 가능합니다.
await action();
});
}}>
{children}
</button>
);
}
export default function AboutTab() {
return (
<p>Welcome to my profile!</p>
);
}
import { memo } from 'react';
const PostsTab = memo(function PostsTab() {
// 한 번만 로그합니다. 실제 느림은 SlowPost 내부에 있습니다.
console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');
let items = [];
for (let i = 0; i < 500; i++) {
items.push(<SlowPost key={i} index={i} />);
}
return (
<ul className="items">
{items}
</ul>
);
});
function SlowPost({ index }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) {
// 항목당 1ms 동안 아무것도 하지 않아 극도로 느린 코드를 시뮬레이션합니다
}
return (
<li className="item">
Post #{index + 1}
</li>
);
}
export default PostsTab;
export default function ContactTab() {
return (
<>
<p>
You can find me online here:
</p>
<ul>
<li>admin@mysite.com</li>
<li>+123456789</li>
</ul>
</>
);
}
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
컴포넌트에서 action prop을 노출할 때는 transition 내부에서 await해야 해요.
이렇게 하면 action 콜백이 동기 또는 비동기 둘 다 가능하며, action 내에서 await를 감싸는 추가 startTransition이 필요하지 않아요.
useTransition이 반환하는 isPending 불린 값을 사용해서 사용자에게 Transition이 진행 중임을 알릴 수 있어요. 예를 들어, 탭 버튼에 특별한 "pending" 시각적 상태를 줄 수 있어요:
function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
// ...
if (isPending) {
return <b className="pending">{children}</b>;
}
// ...
이제 "Posts"를 클릭하면 탭 버튼 자체가 바로 업데이트되기 때문에 더 반응성이 좋게 느껴져요:
import { useState } from 'react';
import TabButton from './TabButton.js';
import AboutTab from './AboutTab.js';
import PostsTab from './PostsTab.js';
import ContactTab from './ContactTab.js';
export default function TabContainer() {
const [tab, setTab] = useState('about');
return (
<>
<TabButton
isActive={tab === 'about'}
action={() => setTab('about')}
>
About
</TabButton>
<TabButton
isActive={tab === 'posts'}
action={() => setTab('posts')}
>
Posts (slow)
</TabButton>
<TabButton
isActive={tab === 'contact'}
action={() => setTab('contact')}
>
Contact
</TabButton>
<hr />
{tab === 'about' && <AboutTab />}
{tab === 'posts' && <PostsTab />}
{tab === 'contact' && <ContactTab />}
</>
);
}
import { useTransition } from 'react';
export default function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
if (isPending) {
return <b className="pending">{children}</b>;
}
return (
<button onClick={() => {
startTransition(async () => {
await action();
});
}}>
{children}
</button>
);
}
export default function AboutTab() {
return (
<p>Welcome to my profile!</p>
);
}
import { memo } from 'react';
const PostsTab = memo(function PostsTab() {
// 한 번만 로그합니다. 실제 느림은 SlowPost 내부에 있습니다.
console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');
let items = [];
for (let i = 0; i < 500; i++) {
items.push(<SlowPost key={i} index={i} />);
}
return (
<ul className="items">
{items}
</ul>
);
});
function SlowPost({ index }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) {
// 항목당 1ms 동안 아무것도 하지 않아 극도로 느린 코드를 시뮬레이션합니다
}
return (
<li className="item">
Post #{index + 1}
</li>
);
}
export default PostsTab;
export default function ContactTab() {
return (
<>
<p>
You can find me online here:
</p>
<ul>
<li>admin@mysite.com</li>
<li>+123456789</li>
</ul>
</>
);
}
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
이 예제에서 PostsTab 컴포넌트는 use를 사용해서 일부 데이터를 가져와요. "Posts" 탭을 클릭하면 PostsTab 컴포넌트가 중단(suspends)되어 가장 가까운 로딩 fallback이 나타나요:
import { Suspense, useState } from 'react';
import TabButton from './TabButton.js';
import AboutTab from './AboutTab.js';
import PostsTab from './PostsTab.js';
import ContactTab from './ContactTab.js';
export default function TabContainer() {
const [tab, setTab] = useState('about');
return (
<Suspense fallback={<h1>🌀 Loading...</h1>}>
<TabButton
isActive={tab === 'about'}
action={() => setTab('about')}
>
About
</TabButton>
<TabButton
isActive={tab === 'posts'}
action={() => setTab('posts')}
>
Posts
</TabButton>
<TabButton
isActive={tab === 'contact'}
action={() => setTab('contact')}
>
Contact
</TabButton>
<hr />
{tab === 'about' && <AboutTab />}
{tab === 'posts' && <PostsTab />}
{tab === 'contact' && <ContactTab />}
</Suspense>
);
}
export default function TabButton({ action, children, isActive }) {
if (isActive) {
return <b>{children}</b>
}
return (
<button onClick={() => {
action();
}}>
{children}
</button>
);
}
export default function AboutTab() {
return (
<p>Welcome to my profile!</p>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
function PostsTab() {
const posts = use(fetchData('/posts'));
return (
<ul className="items">
{posts.map(post =>
<Post key={post.id} title={post.title} />
)}
</ul>
);
}
function Post({ title }) {
return (
<li className="item">
{title}
</li>
);
}
export default PostsTab;
export default function ContactTab() {
return (
<>
<p>
You can find me online here:
</p>
<ul>
<li>admin@mysite.com</li>
<li>+123456789</li>
</ul>
</>
);
}
// 참고: 데이터 가져오기를 수행하는 방식은
// Suspense와 함께 사용하는 프레임워크에 따라 다릅니다.
// 일반적으로 캐싱 로직은 프레임워크 내부에 있을 거예요.
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('/posts')) {
return await getPosts();
} else {
throw Error('Not implemented');
}
}
async function getPosts() {
// 대기를 눈에 띄게 하기 위해 가짜 지연을 추가합니다.
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
let posts = [];
for (let i = 0; i < 500; i++) {
posts.push({
id: i,
title: 'Post #' + (i + 1)
});
}
return posts;
}
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
전체 탭 컨테이너를 숨기고 로딩 표시기를 표시하면 사용자 경험이 불편해져요. TabButton에 useTransition을 추가하면 탭 버튼에서 대기 중인 상태를 표시할 수 있어요.
"Posts"를 클릭해도 이제 전체 탭 컨테이너가 스피너로 바뀌지 않는 것을 확인할 수 있어요:
import { Suspense, useState } from 'react';
import TabButton from './TabButton.js';
import AboutTab from './AboutTab.js';
import PostsTab from './PostsTab.js';
import ContactTab from './ContactTab.js';
export default function TabContainer() {
const [tab, setTab] = useState('about');
return (
<Suspense fallback={<h1>🌀 Loading...</h1>}>
<TabButton
isActive={tab === 'about'}
action={() => setTab('about')}
>
About
</TabButton>
<TabButton
isActive={tab === 'posts'}
action={() => setTab('posts')}
>
Posts
</TabButton>
<TabButton
isActive={tab === 'contact'}
action={() => setTab('contact')}
>
Contact
</TabButton>
<hr />
{tab === 'about' && <AboutTab />}
{tab === 'posts' && <PostsTab />}
{tab === 'contact' && <ContactTab />}
</Suspense>
);
}
import { useTransition } from 'react';
export default function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
if (isPending) {
return <b className="pending">{children}</b>;
}
return (
<button onClick={() => {
startTransition(async () => {
await action();
});
}}>
{children}
</button>
);
}
export default function AboutTab() {
return (
<p>Welcome to my profile!</p>
);
}
import {use} from 'react';
import { fetchData } from './data.js';
function PostsTab() {
const posts = use(fetchData('/posts'));
return (
<ul className="items">
{posts.map(post =>
<Post key={post.id} title={post.title} />
)}
</ul>
);
}
function Post({ title }) {
return (
<li className="item">
{title}
</li>
);
}
export default PostsTab;
export default function ContactTab() {
return (
<>
<p>
You can find me online here:
</p>
<ul>
<li>admin@mysite.com</li>
<li>+123456789</li>
</ul>
</>
);
}
// 참고: 데이터 가져오기를 수행하는 방식은
// Suspense와 함께 사용하는 프레임워크에 따라 다릅니다.
// 일반적으로 캐싱 로직은 프레임워크 내부에 있을 거예요.
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('/posts')) {
return await getPosts();
} else {
throw Error('Not implemented');
}
}
async function getPosts() {
// 대기를 눈에 띄게 하기 위해 가짜 지연을 추가합니다.
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
let posts = [];
for (let i = 0; i < 500; i++) {
posts.push({
id: i,
title: 'Post #' + (i + 1)
});
}
return posts;
}
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
Suspense와 함께 Transition 사용하기에 대해 더 읽어보세요.
Transition은 이미 공개된 콘텐츠(예: 탭 컨테이너)를 숨기지 않도록 충분히 오래 "기다려요". Posts 탭에 중첩된 <Suspense> boundary가 있는 경우, Transition은 그것을 "기다리지" 않아요.
React 프레임워크나 라우터를 만들고 있다면, 페이지 네비게이션을 Transition으로 표시하는 것을 권장해요.
function Router() {
const [page, setPage] = useState('/');
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
// ...
이것이 권장되는 세 가지 이유가 있어요:
다음은 네비게이션에 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>
);
}
// 참고: 데이터 가져오기를 수행하는 방식은
// Suspense와 함께 사용하는 프레임워크에 따라 다릅니다.
// 일반적으로 캐싱 로직은 프레임워크 내부에 있을 거예요.
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() {
// 대기를 눈에 띄게 하기 위해 가짜 지연을 추가합니다.
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() {
// 대기를 눈에 띄게 하기 위해 가짜 지연을 추가합니다.
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;
}
Suspense 지원 라우터는 기본적으로 네비게이션 업데이트를 Transition으로 감싸야 해요.
startTransition에 전달된 함수가 에러를 던지면, 에러 바운더리를 사용해서 사용자에게 에러를 표시할 수 있어요. 에러 바운더리를 사용하려면 useTransition을 호출하는 컴포넌트를 에러 바운더리로 감싸세요. startTransition에 전달된 함수가 에러를 던지면 에러 바운더리의 fallback이 표시돼요.
import { useTransition } from "react";
import { ErrorBoundary } from "react-error-boundary";
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(() => {
// 의도적으로 comment를 전달하지 않아서
// 에러가 발생하도록 함
addComment();
});
}}
>
Add comment
</button>
);
}
import { AddCommentContainer } from "./AddCommentContainer.js";
export default function App() {
return <AddCommentContainer />;
}
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
{
"dependencies": {
"react": "19.0.0-rc-3edc000d-20240926",
"react-dom": "19.0.0-rc-3edc000d-20240926",
"react-scripts": "^5.0.0",
"react-error-boundary": "4.0.3"
},
"main": "/index.js"
}
입력을 제어하는 상태 변수에는 Transition을 사용할 수 없어요:
const [text, setText] = useState('');
// ...
function handleChange(e) {
// ❌ 제어된 입력 상태에는 Transition을 사용할 수 없어요
startTransition(() => {
setText(e.target.value);
});
}
// ...
return <input value={text} onChange={handleChange} />;
이는 Transition이 논블로킹이지만, change 이벤트에 대한 응답으로 입력을 업데이트하는 것은 동기적으로 발생해야 하기 때문이에요. 타이핑에 대한 응답으로 Transition을 실행하려면 두 가지 옵션이 있어요:
useDeferredValue를 추가할 수 있어요. 새 값을 자동으로 "따라잡기" 위해 논블로킹 리렌더링을 트리거해요.상태 업데이트를 Transition으로 감쌀 때는 startTransition 호출 중에 발생하는지 확인하세요:
startTransition(() => {
// ✅ startTransition 호출 중에 상태 설정
setPage('/about');
});
startTransition에 전달하는 함수는 동기여야 해요. 다음과 같이 업데이트를 Transition으로 표시할 수 없어요:
startTransition(() => {
// ❌ startTransition 호출 후에 상태 설정
setTimeout(() => {
setPage('/about');
}, 1000);
});
대신 이렇게 할 수 있어요:
setTimeout(() => {
startTransition(() => {
// ✅ startTransition 호출 중에 상태 설정
setPage('/about');
});
}, 1000);
await 이후의 상태 업데이트를 Transition으로 취급하지 않아요 {/react-doesnt-treat-my-state-update-after-await-as-a-transition/}startTransition 함수 내에서 await를 사용하면, await 이후에 발생하는 상태 업데이트는 Transition으로 표시되지 않아요. 각 await 이후의 상태 업데이트를 startTransition 호출로 감싸야 해요:
startTransition(async () => {
await someAsyncFunction();
// ❌ await 이후에 startTransition을 사용하지 않음
setPage('/about');
});
하지만 이렇게 하면 작동해요:
startTransition(async () => {
await someAsyncFunction();
// ✅ await 이후에 startTransition 사용
startTransition(() => {
setPage('/about');
});
});
이것은 React가 비동기 컨텍스트의 범위를 잃기 때문에 발생하는 JavaScript의 제한사항이에요. 미래에 AsyncContext를 사용할 수 있게 되면 이 제한사항은 제거될 거예요.
useTransition을 호출하고 싶어요 {/i-want-to-call-usetransition-from-outside-a-component/}useTransition은 Hook이므로 컴포넌트 외부에서 호출할 수 없어요. 이 경우 독립형 startTransition 메서드를 대신 사용하세요. 같은 방식으로 작동하지만 isPending 표시기를 제공하지 않아요.
startTransition에 전달한 함수가 즉시 실행돼요 {/the-function-i-pass-to-starttransition-executes-immediately/}이 코드를 실행하면 1, 2, 3이 출력돼요:
console.log(1);
startTransition(() => {
console.log(2);
setPage('/about');
});
console.log(3);
1, 2, 3이 출력되는 것이 예상된 동작이에요. startTransition에 전달한 함수는 지연되지 않아요. 브라우저의 setTimeout과 달리 나중에 콜백을 실행하지 않아요. React는 함수를 즉시 실행하지만, 실행되는 동안 예약된 모든 상태 업데이트는 Transition으로 표시돼요. 다음과 같이 작동한다고 상상할 수 있어요:
// React가 작동하는 방식의 단순화된 버전
let isInsideTransition = false;
function startTransition(scope) {
isInsideTransition = true;
scope();
isInsideTransition = false;
}
function setState() {
if (isInsideTransition) {
// ... Transition 상태 업데이트 예약 ...
} else {
// ... 긴급 상태 업데이트 예약 ...
}
}
startTransition 내에서 await를 사용하면 업데이트가 순서대로 발생하지 않을 수 있어요.
이 예제에서 updateQuantity 함수는 장바구니의 항목 수량을 업데이트하는 서버 요청을 시뮬레이션해요. 이 함수는 경쟁 조건을 시뮬레이션하기 위해 인위적으로 이전 요청 이후에 다른 모든 요청을 반환해요.
수량을 한 번 업데이트한 다음 빠르게 여러 번 업데이트해보세요. 잘못된 합계가 표시될 수 있어요:
{
"dependencies": {
"react": "beta",
"react-dom": "beta"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useTransition } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";
export default function App({}) {
const [quantity, setQuantity] = useState(1);
const [isPending, startTransition] = useTransition();
// 불일치를 표시하기 위해 실제 수량을 별도의 상태에 저장합니다.
const [clientQuantity, setClientQuantity] = useState(1);
const updateQuantityAction = newQuantity => {
setClientQuantity(newQuantity);
// Transition의 대기 중인 상태에 접근하려면
// startTransition을 다시 호출하세요.
startTransition(async () => {
const savedQuantity = await updateQuantity(newQuantity);
startTransition(() => {
setQuantity(savedQuantity);
});
});
};
return (
<div>
<h1>Checkout</h1>
<Item action={updateQuantityAction}/>
<hr />
<Total clientQuantity={clientQuantity} savedQuantity={quantity} isPending={isPending} />
</div>
);
}
import {startTransition} from 'react';
export default function Item({action}) {
function handleChange(e) {
// Action에서 수량을 업데이트합니다.
startTransition(async () => {
await action(e.target.value);
});
}
return (
<div className="item">
<span>Eras Tour Tickets</span>
<label htmlFor="name">Quantity: </label>
<input
type="number"
onChange={handleChange}
defaultValue={1}
min={1}
/>
</div>
)
}
const intl = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
export default function Total({ clientQuantity, savedQuantity, isPending }) {
return (
<div className="total">
<span>Total:</span>
<div>
<div>
{isPending
? "🌀 Updating..."
: `${intl.format(savedQuantity * 9999)}`}
</div>
<div className="error">
{!isPending &&
clientQuantity !== savedQuantity &&
`Wrong total, expected: ${intl.format(clientQuantity * 9999)}`}
</div>
</div>
</div>
);
}
let firstRequest = true;
export async function updateQuantity(newName) {
return new Promise((resolve, reject) => {
if (firstRequest === true) {
firstRequest = false;
setTimeout(() => {
firstRequest = true;
resolve(newName);
// 다른 모든 요청이 더 느리도록 시뮬레이션
}, 1000);
} else {
setTimeout(() => {
resolve(newName);
}, 50);
}
});
}
.item {
display: flex;
align-items: center;
justify-content: start;
}
.item label {
flex: 1;
text-align: right;
}
.item input {
margin-left: 4px;
width: 60px;
padding: 4px;
}
.total {
height: 50px;
line-height: 25px;
display: flex;
align-content: center;
justify-content: space-between;
}
.total div {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.error {
color: red;
}
여러 번 클릭할 때 이전 요청이 나중 요청 이후에 완료될 수 있어요. 이런 일이 발생하면 React는 현재 의도된 순서를 알 수 있는 방법이 없어요. 이는 업데이트가 비동기적으로 예약되고 React가 비동기 경계를 넘어 순서의 컨텍스트를 잃기 때문이에요.
이것은 예상된 동작이에요. Transition 내의 Actions는 실행 순서를 보장하지 않아요. 일반적인 사용 사례의 경우, React는 순서를 자동으로 처리해주는 useActionState와 <form> actions 같은 상위 수준의 추상화를 제공해요. 고급 사용 사례의 경우 이를 처리하기 위해 자체 큐잉 및 중단 로직을 구현해야 해요.
실행 순서를 처리하는 useActionState 예제:
{
"dependencies": {
"react": "beta",
"react-dom": "beta"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useActionState } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";
export default function App({}) {
// 불일치를 표시하기 위해 실제 수량을 별도의 상태에 저장합니다.
const [clientQuantity, setClientQuantity] = useState(1);
const [quantity, updateQuantityAction, isPending] = useActionState(
async (prevState, payload) => {
setClientQuantity(payload);
const savedQuantity = await updateQuantity(payload);
return savedQuantity; // 새 수량을 반환하여 상태를 업데이트합니다
},
1 // 초기 수량
);
return (
<div>
<h1>Checkout</h1>
<Item action={updateQuantityAction}/>
<hr />
<Total clientQuantity={clientQuantity} savedQuantity={quantity} isPending={isPending} />
</div>
);
}
import {startTransition} from 'react';
export default function Item({action}) {
function handleChange(e) {
// Action에서 수량을 업데이트합니다.
startTransition(() => {
action(e.target.value);
});
}
return (
<div className="item">
<span>Eras Tour Tickets</span>
<label htmlFor="name">Quantity: </label>
<input
type="number"
onChange={handleChange}
defaultValue={1}
min={1}
/>
</div>
)
}
const intl = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
export default function Total({ clientQuantity, savedQuantity, isPending }) {
return (
<div className="total">
<span>Total:</span>
<div>
<div>
{isPending
? "🌀 Updating..."
: `${intl.format(savedQuantity * 9999)}`}
</div>
<div className="error">
{!isPending &&
clientQuantity !== savedQuantity &&
`Wrong total, expected: ${intl.format(clientQuantity * 9999)}`}
</div>
</div>
</div>
);
}
let firstRequest = true;
export async function updateQuantity(newName) {
return new Promise((resolve, reject) => {
if (firstRequest === true) {
firstRequest = false;
setTimeout(() => {
firstRequest = true;
resolve(newName);
// 다른 모든 요청이 더 느리도록 시뮬레이션
}, 1000);
} else {
setTimeout(() => {
resolve(newName);
}, 50);
}
});
}
.item {
display: flex;
align-items: center;
justify-content: start;
}
.item label {
flex: 1;
text-align: right;
}
.item input {
margin-left: 4px;
width: 60px;
padding: 4px;
}
.total {
height: 50px;
line-height: 25px;
display: flex;
align-content: center;
justify-content: space-between;
}
.total div {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.error {
color: red;
}