React 19.2 `<Activity>`와 `<ViewTransition>`: 탭·모달 전환에서 사라지는 폼 상태 문제를 구조적으로 해결하는 방법
3단계짜리 위저드 모달에서 마지막 스텝까지 채웠다가 실수로 닫았을 때, 다시 열면 처음부터 시작해야 하는 그 상황 — 겪어보셨다면 공감할 겁니다. 저도 실무에서 display: none과 visibility: hidden을 번갈아 써봤는데, 상태는 유지되지만 숨겨진 컴포넌트의 useEffect가 살아서 불필요한 API를 계속 호출하는 부작용 때문에 결국 타협이 필요했습니다. && 조건부 렌더링으로 완전히 제거하면 깔끔하지만, 사용자 맥락이 통째로 날아가는 건 마찬가지였고요.
React 19.2에서 <Activity> 컴포넌트가 안정(stable) 버전으로 공개되면서, 이 고민에 꽤 명쾌한 해법이 생겼습니다. 컴포넌트의 수명(lifetime)과 가시성(visibility)을 분리한다는 개념인데, 여기에 실험적(experimental) API인 <ViewTransition>을 연동하면 서드파티 애니메이션 라이브러리 없이도 부드러운 enter/exit 애니메이션까지 얻을 수 있습니다. 이 글을 다 읽으면 탭·모달·사전 렌더링 3가지 패턴을 바로 코드에 적용할 수 있고, 기존 방식의 어떤 한계를 어떻게 해결하는지 동작 원리 수준에서 이해하게 됩니다.
이 글은 React의
useState,useEffect, Concurrent 기능(startTransition)에 어느 정도 익숙한 React 중급자 이상을 대상으로 씁니다. 각 개념에 처음 접하는 분을 위해 용어가 등장할 때마다 간단히 풀어두겠습니다.
핵심 개념
<Activity>: 컴포넌트를 "죽이지 않고 재우는" 방법
기존 세 가지 방식의 한계를 먼저 짚어볼게요.
| 방식 | 상태 보존 | Effects 정리 | 문제 |
|---|---|---|---|
&& 조건부 렌더링 |
❌ | ✅ | 전환 때마다 상태, DOM, 스크롤 위치 전부 초기화 |
display: none |
✅ | ❌ | 숨겨진 동안 useEffect가 계속 돌아 불필요한 API 호출 발생 |
<Activity mode="hidden"> |
✅ | ✅ | — |
<Activity>는 mode prop 하나로 두 조건을 동시에 충족합니다.
| mode | 화면 표시 | Effects | React 상태 / DOM |
|---|---|---|---|
"visible" |
정상 렌더링 | 마운트·업데이트 정상 실행 | 유지 |
"hidden" |
React가 내부적으로 인라인 display: none 처리 |
cleanup 실행 후 중단 | 유지 |
"hidden" 상태에서 Context 업데이트는 낮은 우선순위로 계속 수신됩니다. 완전히 끊어지는 게 아니라, 화면에 표시 중인 컴포넌트보다 스케줄 우선순위가 내려간다고 보면 됩니다.
// 기본 사용 형태
import { Activity } from 'react'; // React 19.2 stable에서 제공
<Activity mode={isActive ? 'visible' : 'hidden'}>
<SomeHeavyComponent />
</Activity>Offscreen 아키텍처:
<Activity>의 내부 코드명이 "Offscreen"이었습니다. React 팀이 오랫동안 준비해온 개념으로, React 19.2에서 공개 API로 전환되었습니다. 이는 React가 컴포넌트의 수명과 가시성을 독립적으로 제어하는 방향으로 나아가는 첫 공개 신호입니다.
<ViewTransition>: 상태 변경을 애니메이션으로 연결하는 선언적 방법
브라우저의 View Transition API는 document.startViewTransition()으로 DOM 변경 전후를 스크린샷 찍어 CSS 애니메이션으로 연결하는 Web API입니다. 그런데 이걸 React 렌더링 사이클과 직접 연동하려면 타이밍을 맞추는 게 생각보다 까다롭습니다.
<ViewTransition>은 이 연동을 React가 직접 처리해줍니다. 단, 중요한 조건이 하나 있는데, 반드시 startTransition 내부에서 상태 변경이 이루어져야 애니메이션이 활성화됩니다.
startTransition은 React의 Concurrent 기능 중 하나로, 상태 업데이트의 우선순위를 낮춰 렌더링 도중에도 UI 반응성을 유지하게 해줍니다.<ViewTransition>은 이 Concurrent 렌더링 사이클 안에서만 작동합니다.
import { startTransition } from 'react';
// ❌ ViewTransition 애니메이션 미작동
setActiveTab('settings');
// ✅ ViewTransition 애니메이션 작동
startTransition(() => {
setActiveTab('settings');
});<Activity>와의 연동 포인트는 명확합니다. mode가 hidden → visible로 바뀌면 enter 애니메이션, visible → hidden으로 바뀌면 exit 애니메이션이 자동으로 트리거됩니다.
현재 상태 (2025-10):
<Activity>는 React 19.2 stable에서'react'로 바로 임포트할 수 있습니다. 반면<ViewTransition>은 아직 실험적 API라 안정 채널에서는unstable_ViewTransition으로 노출되거나, experimental 채널 패키지(react@experimental)를 별도 설치해야 사용할 수 있습니다. 프로덕션 적용 전에 breaking change 가능성을 염두에 두는 것이 좋습니다.
브라우저 지원 현황: View Transition API는 Chrome 111+, Safari 18+에서 지원하며 Firefox는 현재 개발 중입니다. 미지원 브라우저에서는 애니메이션 없이 즉각적인 상태 전환이 일어나므로(progressive enhancement), 기능 자체는 정상 동작합니다.
실전 적용
예시 1: 탭 전환 — 폼 상태와 스크롤 위치 보존
실무에서 자주 맞닥뜨리는 상황입니다. 탭 간에 필터 선택값이나 작성 중인 내용이 있을 때, 탭 전환 후 돌아오면 초기화되어 있는 패턴이죠.
import { useState, startTransition } from 'react';
import { Activity } from 'react';
// ViewTransition은 experimental 채널 패키지 또는 unstable_ prefix 확인 필요
import { unstable_ViewTransition as ViewTransition } from 'react';
const tabs = [
{ id: 'home', label: '홈' },
{ id: 'search', label: '검색' },
{ id: 'settings', label: '설정' },
];
export function TabbedLayout() {
const [activeTab, setActiveTab] = useState('home');
function handleTabChange(tabId: string) {
// startTransition으로 감싸야 ViewTransition 애니메이션이 작동합니다
startTransition(() => {
setActiveTab(tabId);
});
}
return (
<div>
<nav>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
aria-selected={activeTab === tab.id}
>
{tab.label}
</button>
))}
</nav>
{tabs.map((tab) => (
<Activity
key={tab.id}
mode={activeTab === tab.id ? 'visible' : 'hidden'}
>
{/* mode 전환 시 enter/exit 애니메이션 자동 트리거 */}
<ViewTransition>
{/* view-transition-name으로 CSS 의사 선택자와 연결 */}
<TabContent
tabId={tab.id}
style={{ viewTransitionName: `tab-${tab.id}` }}
/>
</ViewTransition>
</Activity>
))}
</div>
);
}CSS에서 enter/exit 애니메이션을 커스터마이징하고 싶다면, View Transition 의사 선택자의 인자로 viewTransitionName에 지정한 이름 값을 사용합니다. CSS 클래스 선택자가 아니므로 점(.)을 붙이지 않습니다.
/* JSX에서: style={{ viewTransitionName: 'tab-home' }} */
/* CSS에서: 클래스가 아니라 이름 값 그대로 사용 */
::view-transition-new(tab-home) {
animation: slide-in 0.2s ease-out;
}
::view-transition-old(tab-home) {
animation: slide-out 0.2s ease-in;
}
@keyframes slide-in {
from { transform: translateX(16px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-16px); opacity: 0; }
}저도 처음에 ::view-transition-new(.tab-content) 식으로 클래스처럼 쓰다가 애니메이션이 아무것도 동작하지 않아서 한참 헤맸습니다. view-transition-name은 CSS 클래스가 아니라 고유한 이름 식별자이므로, CSS 의사 선택자에서도 이름 값 자체를 그대로 씁니다.
예시 2: 모달 숨김/표시 — 작성 중인 폼 내용 보존
모달을 닫았다가 다시 열었을 때 이전에 입력하던 내용이 남아있으면 사용자 경험이 훨씬 자연스럽습니다. 특히 긴 폼이나 멀티스텝 위저드에서 효과가 큰데, 이게 실제로 어떻게 동작하는지 ContactModal 내부까지 열어서 보겠습니다.
import { useState, startTransition } from 'react';
import { Activity } from 'react';
import { unstable_ViewTransition as ViewTransition } from 'react';
// 모달 내부: 폼 상태가 여기서 살아남습니다
function ContactModal({ onClose }: { onClose: () => void }) {
const [name, setName] = useState('');
const [message, setMessage] = useState('');
return (
<div role="dialog" aria-modal="true" aria-label="문의하기">
<h2>문의하기</h2>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="이름"
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="문의 내용을 입력해주세요"
rows={4}
/>
<div>
<button onClick={onClose}>닫기</button>
<button type="submit">전송</button>
</div>
</div>
);
}
export function ModalExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => startTransition(() => setIsOpen(true))}>
문의하기
</button>
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ViewTransition>
<ContactModal onClose={() => startTransition(() => setIsOpen(false))} />
</ViewTransition>
</Activity>
</>
);
}저도 처음엔 헷갈렸는데, mode="hidden"일 때 모달 내부의 useEffect는 cleanup이 실행되어 중단되지만, useState로 관리하는 name, message 같은 React 상태는 그대로 살아있습니다. 다시 mode="visible"로 전환하면 Effects가 재마운트되면서 이전 상태를 그대로 표시합니다. 사용자가 이름과 메시지를 절반쯤 입력한 상태에서 모달을 닫았다가 다시 열어도, 입력 내용이 그대로입니다.
Portal 주의:
createPortal과<ViewTransition>을 함께 사용하면 z-index 레이어 순서가 어긋나는 버그(GitHub Issue #34769)가 현재 알려져 있습니다. 모달을 portal로 렌더링하는 패턴에서는 수정이 완료될 때까지 별도로 확인해보시는 것을 권장합니다.
예시 3: 사전 렌더링으로 탐색 속도 개선
백그라운드에서 미리 렌더링해두는 패턴입니다. mode="hidden"으로 초기 렌더링하면 클라이언트에서 낮은 우선순위로 코드·데이터·이미지를 미리 로드해두고, 사용자가 실제로 전환할 때 거의 즉각적인 화면 전환을 구현할 수 있습니다.
import { useState, startTransition } from 'react';
import { Activity } from 'react';
import { unstable_ViewTransition as ViewTransition } from 'react';
export function ProductLayout({ productId }: { productId: string }) {
const [showDetail, setShowDetail] = useState(false);
return (
<div>
{/* 목록 뷰 — 항상 표시 */}
<ProductList
onSelect={() => startTransition(() => setShowDetail(true))}
/>
{/* 상세 뷰 — 처음부터 백그라운드에서 낮은 우선순위로 렌더링 */}
<Activity mode={showDetail ? 'visible' : 'hidden'}>
<ViewTransition>
<ExpensiveProductDetail productId={productId} />
</ViewTransition>
</Activity>
</div>
);
}한 가지 주의할 점이 있는데, useEffect 내부에서 데이터를 패칭하는 방식(예: TanStack Query의 useQuery 기본 동작)은 mode="hidden" 동안 Effects가 중단되므로 사전 로딩 이점을 직접 누리기 어렵습니다. Suspense 기반 패턴(TanStack Query의 useSuspenseQuery 등)으로 데이터를 패칭할 때 시너지가 훨씬 커집니다.
Suspense: React에서 데이터 로딩이나 코드 스플리팅을 비동기로 처리할 때 로딩 상태를 선언적으로 관리하는 기능입니다. Concurrent 렌더링과 연동되어
<Activity>사전 렌더링의 이점을 가장 잘 살릴 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 상태 보존 | 폼 입력값, 스크롤 위치, 내부 UI 토글 상태가 탭/모달 전환 후에도 유지됨 |
| Effects 정리 | 숨김 전환 시 useEffect cleanup이 실행되어 불필요한 API 호출·이벤트 리스너가 차단됨 |
| 사전 렌더링 | mode="hidden" 초기 렌더링으로 백그라운드 로딩 → 전환 시 빠른 화면 표시 |
| 애니메이션 연동 | <ViewTransition>과 결합 시 enter/exit 애니메이션 자동 트리거, 별도 라이브러리 불필요 |
| 백그라운드 업데이트 | 숨겨진 상태에서도 낮은 우선순위로 Context 업데이트를 수신해 데이터 최신성 유지 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 메모리 증가 | 숨겨진 컴포넌트도 메모리에 유지되어, 다수의 hidden Activity 동시 존재 시 메모리 사용량이 크게 늘어날 수 있음 | 자주 전환되는 2~3개의 탭에만 적용하고, 나머지는 조건부 렌더링을 유지하는 하이브리드 전략 검토 |
| ViewTransition 실험적 | <ViewTransition>은 아직 experimental API로, breaking change 가능성이 있음 |
unstable_ prefix 없이 import 가능한 시점(stable 전환)까지 프로덕션 적용 범위를 신중하게 결정 |
| startTransition 필수 | 일반 setState로는 ViewTransition 애니메이션이 작동하지 않음 |
탭/모달 상태 변경 함수는 항상 startTransition으로 감싸는 패턴을 팀 내 컨벤션으로 정착시키는 것이 좋음 |
| Effect 기반 Fetching 미지원 | useEffect 내부 데이터 패칭은 사전 렌더링 이점을 누리지 못함 |
Suspense 기반 패칭(useSuspenseQuery 등)으로 마이그레이션 검토 |
| Portal 충돌 | createPortal + <ViewTransition> 혼용 시 z-index 레이어 순서 버그 존재(Issue #34769) |
수정 전까지는 portal 모달에 ViewTransition 적용 시 별도 테스트 필수 |
적용 시 흔한 실수
-
startTransition누락: 탭 전환 핸들러에서startTransition없이setState만 호출하면,<ViewTransition>이 아무리 잘 배치되어 있어도 애니메이션이 전혀 동작하지 않습니다. 디버깅하다가 시간을 꽤 쓸 수 있는 부분입니다. 저도 처음 적용할 때 이것만으로 30분을 날렸습니다. -
CSS 의사 선택자에 점(
.) 붙이기:::view-transition-new(.tab-content)처럼 CSS 클래스 선택자 문법으로 쓰면 아무 애니메이션도 동작하지 않습니다. View Transition 의사 선택자의 인자는 CSS 클래스가 아니라viewTransitionName에 지정한 이름 값 자체(tab-content)입니다. 점은 붙이지 않아야 합니다. -
모든 탭을
<Activity>로 감싸기: 탭이 10개, 20개가 되면 모든 탭을<Activity>로 유지하는 건 메모리 관점에서 좋지 않습니다. 5개짜리 대시보드 탭에 전부 적용해봤을 때도 메모리 증가가 체감되는 수준이었습니다. 자주 전환되는 핵심 탭 2~3개에만 적용하고, 나머지는 기존 조건부 렌더링을 유지하는 하이브리드 전략이 현실적입니다. -
<ViewTransition>을<Activity>바깥에 배치:<ViewTransition>이<Activity>외부에 있으면 mode 전환 시점에 enter/exit 트리거가 제대로 연결되지 않습니다.<Activity>내부의 직접 자식으로<ViewTransition>을 배치하는 것을 권장합니다.
마치며
<Activity>는 단순한 숨김/표시 토글이 아니라, React가 컴포넌트의 수명과 가시성을 독립적으로 제어하기 시작했다는 구조적인 전환점입니다. 프리렌더링, 오프스크린 렌더링 같은 더 많은 UX 패턴이 이 기반 위에서 열릴 가능성이 큽니다. 여기에 <ViewTransition>을 더하면 서드파티 애니메이션 라이브러리 없이도 부드러운 UI 전환을 선언적으로 표현할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
React 19.2로 업그레이드:
pnpm add react@^19.2.0 react-dom@^19.2.0으로 설치한 뒤, 현재display: none이나&&조건부 렌더링으로 처리하고 있는 탭·모달을 찾아봅니다. 상태 보존이 필요한 컴포넌트가 1순위 후보입니다. -
<Activity>만 먼저 적용:<ViewTransition>없이<Activity mode={...}>로 먼저 교체해보면서 상태 보존이 잘 되는지, 메모리 사용량이 크게 늘지는 않는지 확인합니다. React DevTools의 Profiler로 렌더링 우선순위 변화를 눈으로 확인해볼 수 있습니다. -
<ViewTransition>연동 및 CSS 커스터마이징:<ViewTransition>experimental 빌드가 필요하다면pnpm add react@experimental react-dom@experimental로 별도 설치한 뒤,startTransition으로 상태 변경을 감싸고<ViewTransition>을 추가합니다.::view-transition-new(이름값)과::view-transition-old(이름값)의사 선택자로 enter/exit 애니메이션을 팀의 디자인 시스템에 맞게 조정해봅니다. 브라우저 DevTools의 Animations 패널에서 실시간으로 타이밍을 확인하면서 작업하면 편리합니다.
다음 글 예정: React Router v7의
viewTransitionprop과useViewTransitionState()훅으로 페이지 간 라우트 전환에 View Transition API를 연동하는 방법을 다룰 예정입니다.
참고 자료
- React Labs: View Transitions, Activity, and more | React 공식 블로그
- React 19.2 공식 릴리즈 노트 | React 공식 블로그
<Activity>| React 공식 문서<ViewTransition>| React 공식 문서- React View Transitions and Activity API tutorial | LogRocket Blog
- React 19.2 is here: Activity API, useEffectEvent, and more | LogRocket Blog
- The Complete Guide to React ViewTransition | DEV Community
- Stop Losing UI State: A Practical Guide to React's New
<Activity>Component | Medium - Bug: ViewTransition animations broken when using React Portal | GitHub Issues
- View Transitions | React Router 공식 문서
- ViewTransition | MDN Web Docs