React Labs: View Transitions, Activity 그리고 그 외
2025년 4월 23일, Ricky Hanlon
React Labs 게시글에는 활발히 연구 개발 중인 프로젝트에 대한 내용을 작성합니다. 이번 게시글에서는 오늘 바로 사용해볼 수 있는 두 가지 새로운 실험적 기능과 현재 작업 중인 다른 영역의 업데이트를 공유합니다.
오늘 저희는 테스트할 준비가 완료된 두 가지 새로운 실험적 기능에 대한 문서를 공개하게 되어 기쁩니다.
또한 현재 개발 중인 새로운 기능들에 대한 업데이트도 공유합니다.
- React Performance Tracks
- Compiler IDE Extension
- Automatic Effect Dependencies
- Fragment Refs
- Concurrent Stores
새로운 실험적 기능
View Transitions와 Activity는 이제 react@experimental
에서 테스트할 준비가 되었습니다. 이러한 기능들은 프로덕션에서 테스트되었으며 안정적이지만, 피드백을 반영하는 과정에서 최종 API가 여전히 변경될 수 있습니다.
가장 최신 실험적 버전으로 React 패키지를 업그레이드하여 사용해볼 수 있습니다.
react@experimental
react-dom@experimental
앱에서 이러한 기능을 사용하는 방법을 알아보려면 계속 읽어보시거나, 새로 공개된 문서를 확인해보세요.
<ViewTransition>
: Transition에 애니메이션을 활성화할 수 있는 컴포넌트입니다.addTransitionType
: Transition의 원인을 지정할 수 있는 함수입니다.<Activity>
: UI의 일부를 숨기거나 보여줄 수 있는 컴포넌트입니다.
View Transitions
React View Transitions는 앱의 UI 전환에 애니메이션을 더 쉽게 추가할 수 있게 해주는 새로운 실험적 기능입니다. 내부적으로, 이러한 애니메이션은 대부분의 최신 브라우저에서 사용할 수 있는 새로운 startViewTransition
API를 사용합니다.
엘리먼트의 애니메이션을 활성화하려면, 새로운 <ViewTransition>
컴포넌트로 감싸주세요.
// 애니메이션할 "대상"
<ViewTransition>
<div>animate me</div>
</ViewTransition>
이 새로운 컴포넌트를 사용하면 애니메이션이 활성화될 때 무엇을 애니메이션할지 선언적으로 정의할 수 있습니다.
View Transition에 대한 다음 세 가지 트리거 중 하나를 사용해서 “언제” 애니메이션할지 정의할 수 있습니다.
// 애니메이션할 "시점"
// Transitions
startTransition(() => setState(...));
// Deferred Values
const deferred = useDeferredValue(value);
// Suspense
<Suspense fallback={<Fallback />}>
<div>Loading...</div>
</Suspense>
기본적으로, 이러한 애니메이션은 View Transitions의 기본 CSS 애니메이션이 적용됩니다 (일반적으로 부드러운 크로스 페이드). view transition 의사 선택자를 사용해서 애니메이션이 “어떻게” 실행될지 정의할 수 있습니다. 예를 들어, *
를 사용해서 모든 전환에 대한 기본 애니메이션을 변경할 수 있습니다.
// 애니메이션하는 "방법"
::view-transition-old(*) {
animation: 300ms ease-out fade-out;
}
::view-transition-new(*) {
animation: 300ms ease-in fade-in;
}
startTransition
, useDeferredValue
, 또는 Suspense
폴백이 콘텐츠로 전환되는 것과 같은 애니메이션 트리거로 인해 DOM이 업데이트되면, React는 선언적 휴리스틱을 사용해서 애니메이션을 위해 활성화할 <ViewTransition>
컴포넌트를 자동으로 결정합니다. 그러면 브라우저가 CSS에서 정의된 애니메이션을 실행합니다.
브라우저의 View Transition API에 익숙하고 React가 이를 어떻게 지원하는지 알고 싶다면, 문서의 How does <ViewTransition>
Work를 확인해보세요.
이번 게시글에서는 View Transitions를 사용하는 몇 가지 예시를 살펴보겠습니다.
다음과 같은 상호작용을 애니메이션하지 않는 앱부터 시작하겠습니다.
- 비디오를 클릭해서 세부 정보를 봅니다.
- ”back”을 클릭해서 피드로 돌아갑니다.
- 목록에서 타이핑해서 비디오를 필터링합니다.
import TalkDetails from './Details'; import Home from './Home'; import {useRouter} from './router'; export default function App() { const {url} = useRouter(); // 🚩This version doesn't include any animations yet return url === '/' ? <Home /> : <TalkDetails />; }
네비게이션 애니메이션
저희 앱에는 Suspense가 활성화된 라우터가 포함되어 있으며, 페이지 전환이 이미 Transitions로 표시되어 있습니다. 이는 네비게이션이 startTransition
으로 수행된다는 의미입니다.
function navigate(url) {
startTransition(() => {
go(url);
});
}
startTransition
은 View Transition 트리거이므로, 페이지 간 애니메이션을 위해 <ViewTransition>
을 추가할 수 있습니다:
// 애니메이션할 "대상"
<ViewTransition key={url}>
{url === '/' ? <Home /> : <TalkDetails />}
</ViewTransition>
url
이 변경되면, <ViewTransition>
과 새로운 라우트가 렌더링됩니다. <ViewTransition>
이 startTransition
내부에서 업데이트되었으므로, <ViewTransition>
이 애니메이션을 위해 활성화됩니다.
기본적으로, View Transitions는 브라우저 기본 크로스 페이드 애니메이션을 포함합니다. 이를 예시에 추가하면, 이제 페이지 간 네비게이션할 때마다 크로스 페이드가 적용됩니다.
import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; export default function App() { const {url} = useRouter(); // Use ViewTransition to animate between pages. // No additional CSS needed by default. return ( <ViewTransition> {url === '/' ? <Home /> : <Details />} </ViewTransition> ); }
라우터가 이미 startTransition
을 사용해서 라우트를 업데이트하고 있기 때문에, <ViewTransition>
을 한 줄 추가하는 것만으로 기본 크로스 페이드 애니메이션이 활성화됩니다.
어떻게 동작하는지 궁금하다면 How does <ViewTransition>
work? 문서를 참고하세요.
Customizing animations
기본적으로 <ViewTransition>
은 브라우저의 기본 크로스페이드를 포함합니다.
애니메이션을 커스터마이징하려면, 어떻게 <ViewTransition>
이 활성화되는지에 따라, 어떤 애니메이션을 사용할지 지정하는 Props를 <ViewTransition>
컴포넌트에 제공할 수 있습니다.
예를 들어, default
크로스페이드 애니메이션을 느리게 만들 수 있습니다.
<ViewTransition default="slow-fade">
<Home />
</ViewTransition>
그리고 View Transition 클래스를 사용하여 CSS에서 slow-fade
를 정의합니다.
::view-transition-old(.slow-fade) {
animation-duration: 500ms;
}
::view-transition-new(.slow-fade) {
animation-duration: 500ms;
}
이제 크로스페이드가 더 느려집니다.
import { unstable_ViewTransition as ViewTransition } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); // Define a default animation of .slow-fade. // See animations.css for the animation definiton. return ( <ViewTransition default="slow-fade"> {url === '/' ? <Home /> : <Details />} </ViewTransition> ); }
<ViewTransition>
스타일링에 대한 전체 가이드는 View Transition 스타일링을 참조하세요.
Shared Element Transitions
두 페이지에 같은 요소가 있을 때, 종종 한 페이지에서 다음 페이지로 이어지도록 애니메이션을 주고 싶을 때가 있습니다.
이를 위해 <ViewTransition>
에 고유한 name
속성을 추가할 수 있습니다.
<ViewTransition name={`video-${video.id}`}>
<Thumbnail video={video} />
</ViewTransition>
이제 비디오 썸네일이 두 페이지 사이에서 애니메이션으로 전환됩니다.
import { useState, unstable_ViewTransition as ViewTransition } from "react"; import LikeButton from "./LikeButton"; import { useRouter } from "./router"; import { PauseIcon, PlayIcon } from "./Icons"; import { startTransition } from "react"; export function Thumbnail({ video, children }) { // Add a name to animate with a shared element transition. // This uses the default animation, no additional css needed. return ( <ViewTransition name={`video-${video.id}`}> <div aria-hidden="true" tabIndex={-1} className={`thumbnail ${video.image}`} > {children} </div> </ViewTransition> ); } export function VideoControls() { const [isPlaying, setIsPlaying] = useState(false); return ( <span className="controls" onClick={() => startTransition(() => { setIsPlaying((p) => !p); }) } > {isPlaying ? <PauseIcon /> : <PlayIcon />} </span> ); } export function Video({ video }) { const { navigate } = useRouter(); return ( <div className="video"> <div className="link" onClick={(e) => { e.preventDefault(); navigate(`/video/${video.id}`); }} > <Thumbnail video={video}></Thumbnail> <div className="info"> <div className="video-title">{video.title}</div> <div className="video-description">{video.description}</div> </div> </div> <LikeButton video={video} /> </div> ); }
기본적으로 React는 Transition에 활성화된 각 요소에 대해 고유한 name
을 자동으로 생성합니다. (<ViewTransition>
이 어떻게 동작하는지 참고하세요.) React가 어떤 Transition에서 특정 name
을 가진 <ViewTransition>
이 제거되고, 동일한 name
을 가진 새로운 <ViewTransition>
이 추가된 것을 감지하면 공유 요소 전환Shared Element Transition을 활성화합니다.
자세한 내용은 Animating a Shared Element 문서를 참고하세요.
원인에 따라 애니메이션 적용하기
때로는 트리거된 방식에 따라 요소의 애니메이션을 다르게 적용하고 싶을 때가 있습니다. 이 사용 사례의 경우 전환의 원인을 지정하기 위해 addTransitionType
이라는 새로운 API를 추가했습니다.
function navigate(url) {
startTransition(() => {
// Transition type for the cause "nav forward"
addTransitionType('nav-forward');
go(url);
});
}
function navigateBack(url) {
startTransition(() => {
// Transition type for the cause "nav backward"
addTransitionType('nav-back');
go(url);
});
}
Transition Types을 사용하면 <ViewTransition>
에 Props를 통해 커스텀 애니메이션을 제공할 수 있습니다. “6 Videos” 와 “Back” 헤더에 공유 엘리먼트 Transition을 추가해 보겠습니다.
<ViewTransition
name="nav"
share={{
'nav-forward': 'slide-forward',
'nav-back': 'slide-back',
}}>
{heading}
</ViewTransition>
여기에서는 share
Prop을 전달하여 Transition Type에 따라 어떻게 애니메이션을 적용할지 정의합니다. nav-forward
로 인해 공통 Transition이 활성화되면, View Transition 클래스인 slide-forward
가 적용됩니다. nav-back
로 인해 활성화되면, slide-back
애니메이션이 활성화됩니다. CSS에서 이러한 애니메이션을 정의해 보겠습니다.
::view-transition-old(.slide-forward) {
/* when sliding forward, the "old" page should slide out to left. */
animation: ...
}
::view-transition-new(.slide-forward) {
/* when sliding forward, the "new" page should slide in from right. */
animation: ...
}
::view-transition-old(.slide-back) {
/* when sliding back, the "old" page should slide out to right. */
animation: ...
}
::view-transition-new(.slide-back) {
/* when sliding back, the "new" page should slide in from left. */
animation: ...
}
이제 Navigation Type에 따라 썸네일과 헤더에 애니메이션을 적용할 수 있습니다.
import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; export default function Page({ heading, children }) { const isPending = useIsNavPending(); return ( <div className="page"> <div className="top"> <div className="top-nav"> {/* Custom classes based on transition type. */} <ViewTransition name="nav" share={{ 'nav-forward': 'slide-forward', 'nav-back': 'slide-back', }}> {heading} </ViewTransition> {isPending && <span className="loader"></span>} </div> </div> {/* Opt-out of ViewTransition for the content. */} {/* Content can define it's own ViewTransition. */} <ViewTransition default="none"> <div className="bottom"> <div className="content">{children}</div> </div> </ViewTransition> </div> ); }
Suspense Boundaries 애니메이팅
Suspense 역시 View Transition을 활성화합니다.
콘텐츠에 대한 폴백 애니메이션을 적용하려면 Suspense
를 <ViewTranstion>
으로 래핑하면 됩니다.
<ViewTransition>
<Suspense fallback={<VideoInfoFallback />}>
<VideoInfo />
</Suspense>
</ViewTransition>
이를 추가하면 폴백이 콘텐츠에 크로스 페이드됩니다. 동영상을 클릭하면 동영상 정보에 애니메이션이 적용됩니다.
import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; function VideoDetails({ id }) { // Cross-fade the fallback to content. return ( <ViewTransition default="slow-fade"> <Suspense fallback={<VideoInfoFallback />}> <VideoInfo id={id} /> </Suspense> </ViewTransition> ); } function VideoInfoFallback() { return ( <div> <div className="fit fallback title"></div> <div className="fit fallback description"></div> </div> ); } export default function Details() { const { url, navigateBack } = useRouter(); const videoId = url.split("/").pop(); const video = use(fetchVideo(videoId)); return ( <Layout heading={ <div className="fit back" onClick={() => { navigateBack("/"); }} > <ChevronLeft /> Back </div> } > <div className="details"> <Thumbnail video={video} large> <VideoControls /> </Thumbnail> <VideoDetails id={video.id} /> </div> </Layout> ); } function VideoInfo({ id }) { const details = use(fetchVideoDetails(id)); return ( <div> <p className="fit info-title">{details.title}</p> <p className="fit info-description">{details.description}</p> </div> ); }
또한 폴백에 exit
, 내부 콘텐츠에 enter
를 사용하여 커스텀 애니메이션을 제공할 수도 있습니다.
<Suspense
fallback={
<ViewTransition exit="slide-down">
<VideoInfoFallback />
</ViewTransition>
}
>
<ViewTransition enter="slide-up">
<VideoInfo id={id} />
</ViewTransition>
</Suspense>
CSS로 slide-down
과 slide-up
을 정의하는 방법은 다음과 같습니다.
::view-transition-old(.slide-down) {
/* Slide the fallback down */
animation: ...;
}
::view-transition-new(.slide-up) {
/* Slide the content up */
animation: ...;
}
이제 Suspense 콘텐츠가 슬라이드 애니메이션으로 폴백을 대체합니다.
import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; function VideoDetails({ id }) { return ( <Suspense fallback={ // Animate the fallback down. <ViewTransition exit="slide-down"> <VideoInfoFallback /> </ViewTransition> } > {/* Animate the content up */} <ViewTransition enter="slide-up"> <VideoInfo id={id} /> </ViewTransition> </Suspense> ); } function VideoInfoFallback() { return ( <> <div className="fallback title"></div> <div className="fallback description"></div> </> ); } export default function Details() { const { url, navigateBack } = useRouter(); const videoId = url.split("/").pop(); const video = use(fetchVideo(videoId)); return ( <Layout heading={ <div className="fit back" onClick={() => { navigateBack("/"); }} > <ChevronLeft /> Back </div> } > <div className="details"> <Thumbnail video={video} large> <VideoControls /> </Thumbnail> <VideoDetails id={video.id} /> </div> </Layout> ); } function VideoInfo({ id }) { const details = use(fetchVideoDetails(id)); return ( <> <p className="info-title">{details.title}</p> <p className="info-description">{details.description}</p> </> ); }
목록 애니메이팅
검색 가능 항목 목록에서처럼 <ViewTransition>
을 사용하여 항목 목록이 재정렬될 때 애니메이션을 적용할 수도 있습니다.
<div className="videos">
{filteredVideos.map((video) => (
<ViewTransition key={video.id}>
<Video video={video} />
</ViewTransition>
))}
</div>
ViewTransition을 활성화하려면 useDeferredValue
를 사용할 수 있습니다.
const [searchText, setSearchText] = useState('');
const deferredSearchText = useDeferredValue(searchText);
const filteredVideos = filterVideos(videos, deferredSearchText);
이제 검색창에 입력할 때 항목에 애니메이션이 적용됩니다.
import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; function SearchList({searchText, videos}) { // Activate with useDeferredValue ("when") const deferredSearchText = useDeferredValue(searchText); const filteredVideos = filterVideos(videos, deferredSearchText); return ( <div className="video-list"> <div className="videos"> {filteredVideos.map((video) => ( // Animate each item in list ("what") <ViewTransition key={video.id}> <Video video={video} /> </ViewTransition> ))} </div> {filteredVideos.length === 0 && ( <div className="no-results">No results</div> )} </div> ); } export default function Home() { const videos = use(fetchVideos()); const count = videos.length; const [searchText, setSearchText] = useState(''); return ( <Layout heading={<div className="fit">{count} Videos</div>}> <SearchInput value={searchText} onChange={setSearchText} /> <SearchList videos={videos} searchText={searchText} /> </Layout> ); } function SearchInput({ value, onChange }) { const id = useId(); return ( <form className="search" onSubmit={(e) => e.preventDefault()}> <label htmlFor={id} className="sr-only"> Search </label> <div className="search-input"> <div className="search-icon"> <IconSearch /> </div> <input type="text" id={id} placeholder="Search" value={value} onChange={(e) => onChange(e.target.value)} /> </div> </form> ); } function filterVideos(videos, query) { const keywords = query .toLowerCase() .split(" ") .filter((s) => s !== ""); if (keywords.length === 0) { return videos; } return videos.filter((video) => { const words = (video.title + " " + video.description) .toLowerCase() .split(" "); return keywords.every((kw) => words.some((w) => w.includes(kw))); }); }
최종 결과물
몇 개의 <ViewTransition>
컴포넌트와 몇 줄의 CSS를 추가하여 위의 모든 애니메이션을 최종 결과물에 추가할 수 있었습니다.
저희는 View Transition이 여러분의 앱 제작 수준을 한 단계 높여줄 것으로 기대하고 있습니다. 오늘부터 React 릴리즈의 Experimental 채널에서 사용해 볼 수 있습니다.
이제 느린 페이드 효과를 제거하고, 최종 결과물을 살펴봅시다.
import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; export default function App() { const {url} = useRouter(); // Animate with a cross fade between pages. return ( <ViewTransition key={url}> {url === '/' ? <Home /> : <Details />} </ViewTransition> ); }
작동 방식에 대해 자세히 알고 싶다면 문서에서 <ViewTransition>
의 작동 방식을 확인하세요.
View Transition을 구축한 배경에 대한 자세한 내용은 다음을 참조하세요. #31975, #32105, #32041, #32734, #32797 #31999, #32031, #32050, #32820, #32029, #32028, and #32038 by @sebmarkbage (Seb에게 감사합니다!)
Activity
지난 업데이트에서, 컴포넌트를 시각적으로 숨기고 우선순위를 지정 해제할 수 있는 API를 연구 중이며, CSS로 마운트 해제하거나 숨기는 것에 비해 성능 비용을 줄이면서 UI 상태를 유지할 수 있다고 공유한 바 있습니다.
이제 API와 그 작동 방식을 공유할 준비가 되었고, 실험적인 React 버전에서 테스트를 시작할 수 있습니다.
<Activity>
는 UI의 일부를 숨기고 표시하는 새로운 컴포넌트입니다.
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Page />
</Activity>
Activity가 visible하면 정상적으로 렌더링됩니다. Activity가 hidden이면 마운트 해제되지만, 상태를 저장하고 화면에 표시되는 항목보다 낮은 우선 순위로 계속 렌더링됩니다.
Activity
를 사용하여 사용자가 사용하지 않는 UI 부분의 상태를 저장하거나 사용자가 다음에 사용할 가능성이 있는 부분을 미리 렌더링할 수 있습니다.
위의 View Transition 예시를 개선한 몇 가지 예시를 살펴보겠습니다.
Activity로 상태 복원하기
사용자가 페이지에서 다른 페이지로 이동하면 이전 페이지의 렌더링을 중단하는 것이 일반적입니다.
function App() {
const { url } = useRouter();
return (
<>
{url === '/' && <Home />}
{url !== '/' && <Details />}
</>
);
}
그러나 이는 사용자가 이전 페이지로 돌아갈 경우 이전 State는 모두 손실되는 것을 의미합니다. 예를 들어 <Home />
페이지에 <input>
필드가 있는 경우 사용자가 페이지를 나가면 <input>
이 마운트 해제되고 입력했던 모든 텍스트가 손실됩니다.
Activity를 사용하면 사용자가 페이지를 변경할 때 상태를 유지하여, 다시 돌아왔을 때 중단한 부분부터 다시 시작할 수 있습니다. 이 작업은 트리의 일부를 <Activity>
로 감싸고 mode
를 전환하면 됩니다.
function App() {
const { url } = useRouter();
return (
<>
<Activity mode={url === '/' ? 'visible' : 'hidden'}>
<Home />
</Activity>
{url !== '/' && <Details />}
</>
);
}
이 변경으로 위의 View Transition 예시를 개선할 수 있습니다. 이전에는 동영상을 검색하고 선택한 후 돌아오면 검색 필터가 사라졌습니다. Activity를 사용하면 검색 필터가 복원되어 중단한 부분부터 다시 시작할 수 있습니다.
동영상을 검색하고 선택한 후 “back”을 클릭해 보세요.
import { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); return ( // View Transitions know about Activity <ViewTransition> {/* Render Home in Activity so we don't lose state */} <Activity mode={url === '/' ? 'visible' : 'hidden'}> <Home /> </Activity> {url !== '/' && <Details />} </ViewTransition> ); }
Activity로 사전 렌더링하기
사용자가 사용할 가능성이 높은 UI의 다음 부분을 미리 준비하여 그들이 사용할 준비가 되었을 때 바로 사용할 수 있도록 하고 싶을 때가 있습니다. 사용자가 네비게이팅하기 전에 데이터를 이미 가져왔을 수 있으므로, 다음 라우트가 렌더링해야 하는 데이터를 일시 중단해야 하는 경우 특히 유용합니다.
예를 들어, 현재 우리의 앱은 사용자가 동영상을 선택할 때 각 동영상에 대한 데이터를 로드하기 위해 일시 중단해야 합니다. 사용자가 탐색할 때까지 숨겨진 <Activity>
에 있는 모든 페이지를 렌더링하여 이 문제를 개선할 수 있습니다.
<ViewTransition>
<Activity mode={url === '/' ? 'visible' : 'hidden'}>
<Home />
</Activity>
<Activity mode={url === '/details/1' ? 'visible' : 'hidden'}>
<Details id={id} />
</Activity>
<Activity mode={url === '/details/1' ? 'visible' : 'hidden'}>
<Details id={id} />
</Activity>
<ViewTransition>
이 업데이트를 통해 다음 페이지의 콘텐츠가 미리 렌더링할 시간이 있는 경우 Suspense 폴백 없이 애니메이션이 적용됩니다. 동영상을 클릭하면 세부 정보 페이지의 동영상 제목과 설명이 폴백 없이 즉시 렌더링되는 것을 확인할 수 있습니다.
import { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity, use } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; import {fetchVideos} from './data' export default function App() { const { url } = useRouter(); const videoId = url.split("/").pop(); const videos = use(fetchVideos()); return ( <ViewTransition> {/* Render videos in Activity to pre-render them */} {videos.map(({id}) => ( <Activity key={id} mode={videoId === id ? 'visible' : 'hidden'}> <Details id={id}/> </Activity> ))} <Activity mode={url === '/' ? 'visible' : 'hidden'}> <Home /> </Activity> </ViewTransition> ); }
Activity를 사용한 서버 사이드 렌더링
서버 사이드 렌더링(SSR)을 사용하는 페이지에서 Activity를 사용하는 경우 추가적인 최적화 과정이 있습니다.
페이지의 일부가 mode="hidden"
으로 렌더링되는 경우, 해당 부분은 SSR 응답에 포함되지 않습니다. 대신, React는 페이지의 나머지 부분이 하이드레이션되는 동안 Activity 내부의 콘텐츠에 대한 클라이언트 렌더링을 예약하여 화면에 표시되는 콘텐츠의 우선순위를 정합니다.
mode="visible"
으로 렌더링된 UI의 일부의 경우, React는 Suspense 콘텐츠가 낮은 우선순위로 하이드레이션되는 것과 유사하게 활동 내 콘텐츠의 하이드레이션 우선순위를 낮춥니다. 사용자가 페이지와 상호작용하는 경우, 필요한 경우 경계 내에서 하이드레이션의 우선순위를 지정합니다.
이는 고급 사용 사례이지만 Activity에서 고려되는 추가적인 이점을 보여줍니다.
향후 Activity의 모드
향후 Activity에 더 많은 모드를 추가할 수 있습니다.
예를 들어 일반적인 사용 사례는 “활성화된” 모달 뷰 뒤에 이전의 “비활성된” 페이지가 표시되는 모달을 렌더링하는 것입니다. 이 사용 사례에서는 “hidden” 모드가 표시되지 않고 SSR에 포함되지 않기 때문에 작동하지 않습니다.
대신 콘텐츠를 계속 표시하고 —SSR에 포함하되— 마운트되지 않은 상태로 유지하고 업데이트 우선순위를 해제하는 새로운 모드를 고려하고 있습니다. 이 모드는 모달이 열려 있는 동안 백그라운드 콘텐츠가 업데이트되는 것을 보는 것이 방해가 될 수 있으므로, DOM 업데이트를 “일시 중지”해야 할 수도 있습니다.
Activity에서 고려 중인 또 다른 모드는 메모리가 너무 많이 사용되는 경우 숨겨진 활동의 상태를 자동으로 삭제하는 기능입니다. 컴포넌트가 이미 마운트 해제된 상태이므로 너무 많은 리소스를 소모하기보다는 앱에서 가장 최근에 사용된 숨겨진 부분의 상태를 파기하는 것이 더 바람직할 수 있습니다.
이 부분은 아직 연구 중인 부분이며, 진전이 있으면 더 많은 내용을 공유해드리겠습니다. 오늘 포함된 Activity에 대한 자세한 내용은 문서를 참조하세요.
개발 중인 기능
저희는 아래의 일반적인 문제들을 해결하는 데 도움이 되는 기능들도 개발하고 있습니다.
가능한 솔루션을 반복 개발하면서, 저희가 진행하고 있는 PR을 기반으로 테스트 중인 잠재적 API들이 공유되는 것을 보실 수 있습니다. 다양한 아이디어를 시도하면서, 시도해본 후 다른 솔루션을 자주 변경하거나 제거한다는 점을 기억해 주세요.
저희가 작업하고 있는 솔루션을 너무 일찍 공유하면, 커뮤니티에 혼란과 혼동을 일으킬 수 있습니다. 투명성과 혼란 제한 사이의 균형을 맞추기 위해, 염두에 두고 있는 특정 솔루션을 공유하지 않고 현재 솔루션을 개발하고 있는 문제들을 공유합니다.
이러한 기능들이 진전을 보이면, 여러분이 시도해볼 수 있도록 문서와 함께 블로그에서 발표하겠습니다.
React Performance Tracks
React 앱의 성능에 대한 더 많은 정보를 제공하기 위해 커스텀 트랙 추가를 허용하는 브라우저 API를 사용하여 성능 프로파일러에 새로운 커스텀 트랙 세트를 작업하고 있습니다.
이 기능은 아직 진행 중이므로, 실험적 기능으로 완전히 출시하기 위한 문서를 발행할 준비가 되지 않았습니다. React의 실험적 버전을 사용하면 미리 보기를 할 수 있으며, 이는 자동으로 프로필에 성능 트랙을 추가합니다.


성능과 스케줄러 트랙이 일시 중단된 트리에서 작업을 항상 “연결”하지 않는 등 해결할 계획인 몇 가지 알려진 문제들이 있어서, 아직 시도할 준비가 완전히 되지 않았습니다. 또한 트랙의 디자인과 사용성을 개선하기 위해 얼리 어답터들로부터 피드백을 계속 수집하고 있습니다.
이러한 문제들을 해결하면, 실험적 문서를 발행하고 시도할 준비가 되었다고 공유하겠습니다.
Automatic Effect Dependencies
hooks를 출시했을 때, 저희는 세 가지 동기가 있었습니다:
- 컴포넌트 간 코드 공유: hooks는 렌더링 props와 고차 컴포넌트 같은 패턴을 대체하여 컴포넌트 계층을 변경하지 않고도 상태가 있는 로직을 재사용할 수 있게 해주었습니다.
- 생명주기가 아닌 함수의 관점에서 사고: hooks는 생명주기 메서드를 기반으로 한 분할을 강제하는 것이 아니라 관련된 부분(구독 설정이나 데이터 가져오기 등)을 기반으로 하나의 컴포넌트를 더 작은 함수로 분할할 수 있게 해주었습니다.
- 사전 컴파일 지원: hooks는 생명주기 메서드로 인한 의도하지 않은 최적화 해제 문제와 클래스의 제약사항을 줄이면서 사전 컴파일을 지원하도록 설계되었습니다.
출시 이후 hooks는 컴포넌트 간 코드 공유에서 성공적이었습니다. Hooks는 이제 컴포넌트 간 로직을 공유하는 선호되는 방법이 되었고, 렌더링 props와 고차 컴포넌트의 사용 사례는 줄어들었습니다. Hooks는 또한 클래스 컴포넌트로는 불가능했던 Fast Refresh와 같은 기능을 지원하는 데도 성공적이었습니다.
Effects는 어려울 수 있습니다
안타깝게도, 일부 hooks는 여전히 생명주기 대신 함수의 관점에서 사고하기 어렵습니다. 특히 Effects는 여전히 이해하기 어렵고 개발자들로부터 듣는 가장 일반적인 고민거리입니다. 작년에 저희는 Effects가 어떻게 사용되는지, 그리고 이러한 사용 사례가 어떻게 단순화되고 이해하기 쉬워질 수 있는지에 대해 상당한 시간을 연구했습니다.
종종 혼란은 필요하지 않을 때 Effect를 사용하는 데서 온다는 것을 발견했습니다. You Might Not Need an Effect 가이드는 Effects가 올바른 솔루션이 아닌 경우들을 많이 다루고 있습니다. 하지만 Effect가 문제에 적합한 해결책일 때조차도, Effects는 클래스 컴포넌트 생명주기보다 여전히 이해하기 어려울 수 있습니다.
혼란의 이유 중 하나는 개발자들이 Effects 관점(Effect가 무엇을 하는지)이 아니라 컴포넌트의 관점(생명주기 같은)에서 Effects를 생각하기 때문이라고 생각합니다.
문서의 예시를 살펴보겠습니다:
useEffect(() => {
// Your Effect connected to the room specified with roomId...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
// ...until it disconnected
connection.disconnect();
};
}, [roomId]);
많은 사용자들은 이 코드를 “마운트 시에 roomId에 연결하고, roomId
가 변경될 때마다 이전 방으로부터 연결을 해제하고 새로운 연결을 생성한다”고 읽을 것입니다. 하지만 이는 컴포넌트의 생명주기 관점에서 생각하는 것이며, 이는 Effect를 올바르게 작성하기 위해 모든 컴포넌트 생명주기 상태를 생각해야 한다는 의미입니다. 이는 어려울 수 있으므로, 컴포넌트 관점을 사용할 때 Effects가 클래스 생명주기보다 어려워 보이는 것이 이해할 만합니다.
의존성 없는 Effects
대신 Effect의 관점에서 생각하는 것이 좋습니다. Effect는 컴포넌트 생명주기에 대해 알지 못합니다. 단지 동기화를 시작하는 방법과 중지하는 방법만 설명합니다. 사용자가 이런 식으로 Effects를 생각할 때, 그들의 Effects는 작성하기 더 쉬워지고, 필요한 만큼 여러 번 시작되고 중지되는 것에 더 탄력적이 됩니다.
Effects가 컴포넌트 관점에서 생각되는 이유를 연구하는 데 시간을 보냈고, 그 이유 중 하나가 의존성 배열이라고 생각합니다. 작성해야 하므로, 바로 거기에 있고 여러분이 무엇에 “반응”하고 있는지를 상기시키며 ‘이 값들이 변경될 때 이것을 하라’는 멘탈 모델로 유도합니다.
hooks를 출시할 때, 사전 컴파일로 사용하기 더 쉽게 만들 수 있다는 것을 알고 있었습니다. React Compiler를 사용하면, 이제 대부분의 경우 useCallback
과 useMemo
를 직접 작성하는 것을 피할 수 있습니다. Effects의 경우, 컴파일러가 의존성을 자동으로 삽입할 수 있습니다:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}); // 컴파일러가 의존성을 삽입했습니다.
이 코드를 사용하면, React Compiler가 의존성을 추론하고 자동으로 삽입하므로 보거나 작성할 필요가 없습니다. IDE 확장 프로그램과 useEffectEvent
같은 기능을 통해, 디버깅이 필요한 시점이나 의존성을 제거하여 최적화할 때 Compiler가 삽입한 것을 보여주는 CodeLens를 제공할 수 있습니다. 이는 언제든지 실행되어 컴포넌트나 hook의 상태를 다른 것과 동기화할 수 있는 Effects를 작성하는 올바른 멘탈 모델을 강화하는 데 도움이 됩니다.
저희의 희망은 의존성을 자동으로 삽입하는 것이 작성하기 더 쉬울 뿐만 아니라, 컴포넌트 생명주기가 아닌 Effect가 하는 일의 관점에서 생각하도록 강제함으로써 이해하기도 더 쉽게 만든다는 것입니다.
Compiler IDE Extension
이번 주 초에 React Compiler 릴리스 후보를 공유했으며, 앞으로 몇 달 안에 컴파일러의 첫 번째 SemVer 안정 버전을 출시하기 위해 작업하고 있습니다.
또한 React Compiler를 사용해서 코드 이해와 디버깅을 향상시킬 수 있는 정보를 제공하는 방법을 탐구하기 시작했습니다. 저희가 탐구하기 시작한 아이디어 중 하나는 Lauren Tan의 React Conf 발표에서 사용된 확장 프로그램과 유사한, React Compiler를 기반으로 하는 새로운 실험적 LSP 기반 React IDE 확장 프로그램입니다.
저희의 아이디어는 컴파일러의 정적 분석을 사용해서 IDE에서 직접 더 많은 정보, 제안, 최적화 기회를 제공할 수 있다는 것입니다. 예를 들어, React의 규칙을 위반하는 코드에 대한 진단을 제공하거나, 컴포넌트와 hooks가 컴파일러에 의해 최적화되었는지 보여주는 호버, 또는 자동으로 삽입된 Effect 의존성을 볼 수 있는 CodeLens를 제공할 수 있습니다.
IDE 확장 프로그램은 아직 초기 탐구 단계이지만, 향후 업데이트에서 진행 상황을 공유하겠습니다.
Fragment Refs
이벤트 관리, 위치 지정, 포커스를 위한 DOM API들은 React로 작성할 때 구성하기 어렵습니다. 이는 종종 개발자들이 Effects에 의존하거나, 여러 Refs를 관리하거나, findDOMNode
(React 19에서 제거됨)와 같은 API를 사용하게 만듭니다.
저희는 단일 엘리먼트가 아닌 DOM 엘리먼트 그룹을 가리키는 Fragments에 refs를 추가하는 것을 탐구하고 있습니다. 저희의 희망은 이것이 여러 자식을 관리하는 것을 단순화하고 DOM API를 호출할 때 구성 가능한 React 코드를 작성하기 더 쉽게 만드는 것입니다.
Fragment refs는 아직 연구 중입니다. 최종 API가 완성에 가까워지면 더 많은 내용을 공유하겠습니다.
Gesture Animations
저희는 또한 메뉴를 열기 위한 스와이프나 사진 캐러셀을 스크롤하는 것과 같은 제스처 애니메이션을 지원하기 위해 View Transitions를 향상시키는 방법을 연구하고 있습니다.
제스처는 몇 가지 이유로 새로운 도전을 제시합니다:
- Gestures are continuous: as you swipe the animation is tied to your finger placement time, rather than triggering and running to completion.
- Gestures don’t complete: when you release your finger gesture animations can run to completion, or revert to their original state (like when you only partially open a menu) depending on how far you go.
- Gestures invert old and new: while you’re animating, you want the page you are animating from to stay “alive” and interactive. This inverts the browser View Transition model where the “old” state is a snapshot and the “new” state is the live DOM.
We believe we’ve found an approach that works well and may introduce a new API for triggering gesture transitions. For now, we’re focused on shipping <ViewTransition>
, and will revisit gestures afterward.
Concurrent Stores
When we released React 18 with concurrent rendering, we also released useSyncExternalStore
so external store libraries that did not use React state or context could support concurrent rendering by forcing a synchronous render when the store is updated.
Using useSyncExternalStore
comes at a cost though, since it forces a bail out from concurrent features like transitions, and forces existing content to show Suspense fallbacks.
Now that React 19 has shipped, we’re revisiting this problem space to create a primitive to fully support concurrent external stores with the use
API:
const value = use(store);
Our goal is to allow external state to be read during render without tearing, and to work seamlessly with all of the concurrent features React offers.
This research is still early. We’ll share more, and what the new APIs will look like, when we’re further along.
Thanks to Aurora Scharff, Dan Abramov, Eli White, Lauren Tan, Luna Wei, Matt Carroll, Jack Pope, Jason Bonta, Jordan Brown, Jordan Eldredge, Mofei Zhang, Sebastien Lorber, Sebastian Markbåge, and Tim Yung for reviewing this post.