<Activity>

김동현·2026년 3월 17일

소개 {/intro/}

<Activity>를 사용하면 자식 컴포넌트들의 UI와 내부 상태를 숨기고 복원할 수 있어요.

<Activity mode={visibility}>
  <Sidebar />
</Activity>

레퍼런스 {/reference/}

<Activity> {/activity/}

Activity를 사용해서 애플리케이션의 일부를 숨길 수 있어요:

<Activity mode={isShowingSidebar ? "visible" : "hidden"}>
  <Sidebar />
</Activity>

Activity 경계가 hidden 상태일 때, React는 display: "none" CSS 속성을 사용해서 자식 컴포넌트들을 시각적으로 숨겨요. 또한 자식들의 Effects도 정리(destroy)하고, 활성화된 구독들도 정리해요.

숨겨진 상태에서도 자식 컴포넌트들은 새로운 props에 반응해서 여전히 리렌더링되지만, 나머지 콘텐츠보다 낮은 우선순위로 처리돼요.

경계가 다시 visible 상태가 되면, React는 이전 상태가 복원된 자식 컴포넌트들을 보여주고, Effects를 다시 생성해요.

이런 방식으로 Activity는 "백그라운드 활동"을 렌더링하는 메커니즘이라고 생각할 수 있어요. 다시 보여질 가능성이 있는 콘텐츠를 완전히 버리는 대신, Activity를 사용해서 그 콘텐츠의 UI와 내부 상태를 유지하고 복원할 수 있으면서도, 숨겨진 콘텐츠가 원치 않는 부작용을 일으키지 않도록 보장할 수 있어요.

아래에서 더 많은 예제를 확인해보세요.

Props {/props/}

  • children: 보여주고 숨기려는 UI예요.
  • mode: 'visible' 또는 'hidden' 문자열 값이에요. 생략하면 기본값은 'visible'이에요.

주의사항 {/caveats/}

  • Activity가 ViewTransition 내부에서 렌더링되고, startTransition으로 인한 업데이트 결과로 visible 상태가 되면, ViewTransition의 enter 애니메이션이 활성화돼요. hidden 상태가 되면 exit 애니메이션이 활성화돼요.
  • 텍스트만 렌더링하는 hidden Activity는 숨겨진 텍스트를 렌더링하는 대신 아무것도 렌더링하지 않아요. 왜냐하면 가시성 변경을 적용할 해당 DOM 요소가 없기 때문이에요. 예를 들어, <Activity mode="hidden"><ComponentThatJustReturnsText /></Activity>const ComponentThatJustReturnsText = () => "Hello, World!"에 대해 DOM에 어떤 출력도 생성하지 않아요. <Activity mode="visible"><ComponentThatJustReturnsText /></Activity>는 보이는 텍스트를 렌더링할 거예요.

사용법 {/usage/}

숨겨진 컴포넌트의 상태 복원하기 {/restoring-the-state-of-hidden-components/}

React에서 조건부로 컴포넌트를 보여주거나 숨기고 싶을 때, 일반적으로 그 조건에 따라 마운트하거나 언마운트해요:

{isShowingSidebar && (
  <Sidebar />
)}

하지만 컴포넌트를 언마운트하면 내부 상태가 파괴되는데, 이것이 항상 원하는 동작은 아니에요.

Activity 경계를 사용해서 컴포넌트를 숨기면, React가 나중을 위해 상태를 "저장"해요:

<Activity mode={isShowingSidebar ? "visible" : "hidden"}>
  <Sidebar />
</Activity>

이렇게 하면 컴포넌트를 숨겼다가 나중에 이전 상태 그대로 복원할 수 있어요.

다음 예제에는 확장 가능한 섹션이 있는 사이드바가 있어요. "Overview"를 누르면 아래에 있는 세 개의 하위 항목이 나타나요. 메인 앱 영역에는 사이드바를 숨기고 보여주는 버튼도 있어요.

Overview 섹션을 확장한 다음, 사이드바를 닫았다가 열어보세요:

// App.js
import { useState } from 'react';
import Sidebar from './Sidebar.js';

export default function App() {
  const [isShowingSidebar, setIsShowingSidebar] = useState(true);

  return (
    <>
      {isShowingSidebar && (
        <Sidebar />
      )}

      <main>
        <button onClick={() => setIsShowingSidebar(!isShowingSidebar)}>
          Toggle sidebar
        </button>
        <h1>Main content</h1>
      </main>
    </>
  );
}
// Sidebar.js
import { useState } from 'react';

export default function Sidebar() {
  const [isExpanded, setIsExpanded] = useState(false)
  
  return (
    <nav>
      <button onClick={() => setIsExpanded(!isExpanded)}>
        Overview
        <span className={`indicator ${isExpanded ? 'down' : 'right'}`}>
          &#9650;
        </span>
      </button>

      {isExpanded && (
        <ul>
          <li>Section 1</li>
          <li>Section 2</li>
          <li>Section 3</li>
        </ul>
      )}
    </nav>
  );
}
body { height: 275px; margin: 0; }
#root {
  display: flex;
  gap: 10px;
  height: 100%;
}
nav {
  padding: 10px;
  background: #eee;
  font-size: 14px;
  height: 100%;
}
main {
  padding: 10px;
}
p {
  margin: 0;
}
h1 {
  margin-top: 10px;
}
.indicator {
  margin-left: 4px;
  display: inline-block;
  rotate: 90deg;
}
.indicator.down {
  rotate: 180deg;
}

Overview 섹션은 항상 접힌 상태로 시작해요. isShowingSidebarfalse로 바뀔 때 사이드바를 언마운트하기 때문에, 모든 내부 상태가 손실되어요.

이것은 Activity를 사용하기에 완벽한 케이스예요. 사이드바의 내부 상태를 시각적으로 숨기면서도 보존할 수 있어요.

사이드바의 조건부 렌더링을 Activity 경계로 대체해볼게요:

// 이전
{isShowingSidebar && (
  <Sidebar />
)}

// 이후
<Activity mode={isShowingSidebar ? 'visible' : 'hidden'}>
  <Sidebar />
</Activity>

새로운 동작을 확인해보세요:

// App.js
import { Activity, useState } from 'react';

import Sidebar from './Sidebar.js';

export default function App() {
  const [isShowingSidebar, setIsShowingSidebar] = useState(true);

  return (
    <>
      <Activity mode={isShowingSidebar ? 'visible' : 'hidden'}>
        <Sidebar />
      </Activity>

      <main>
        <button onClick={() => setIsShowingSidebar(!isShowingSidebar)}>
          Toggle sidebar
        </button>
        <h1>Main content</h1>
      </main>
    </>
  );
}
// Sidebar.js
import { useState } from 'react';

export default function Sidebar() {
  const [isExpanded, setIsExpanded] = useState(false)
  
  return (
    <nav>
      <button onClick={() => setIsExpanded(!isExpanded)}>
        Overview
        <span className={`indicator ${isExpanded ? 'down' : 'right'}`}>
          &#9650;
        </span>
      </button>

      {isExpanded && (
        <ul>
          <li>Section 1</li>
          <li>Section 2</li>
          <li>Section 3</li>
        </ul>
      )}
    </nav>
  );
}
body { height: 275px; margin: 0; }
#root {
  display: flex;
  gap: 10px;
  height: 100%;
}
nav {
  padding: 10px;
  background: #eee;
  font-size: 14px;
  height: 100%;
}
main {
  padding: 10px;
}
p {
  margin: 0;
}
h1 {
  margin-top: 10px;
}
.indicator {
  margin-left: 4px;
  display: inline-block;
  rotate: 90deg;
}
.indicator.down {
  rotate: 180deg;
}

이제 사이드바의 내부 상태가 구현을 변경하지 않고도 복원돼요.


숨겨진 컴포넌트의 DOM 복원하기 {/restoring-the-dom-of-hidden-components/}

Activity 경계는 display: none을 사용해서 자식들을 숨기기 때문에, 숨겨졌을 때 자식들의 DOM도 보존돼요. 이것은 사용자가 다시 상호작용할 가능성이 있는 UI 부분의 임시 상태를 유지하는 데 아주 좋아요.

이 예제에서 Contact 탭에는 사용자가 메시지를 입력할 수 있는 <textarea>가 있어요. 텍스트를 입력하고, Home 탭으로 이동한 다음, Contact 탭으로 돌아오면 작성 중인 메시지가 사라져요:

// App.js
import { useState } from 'react';
import TabButton from './TabButton.js';
import Home from './Home.js';
import Contact from './Contact.js';

export default function App() {
  const [activeTab, setActiveTab] = useState('contact');

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'contact'}
        onClick={() => setActiveTab('contact')}
      >
        Contact
      </TabButton>

      <hr />

      {activeTab === 'home' && <Home />}
      {activeTab === 'contact' && <Contact />}
    </>
  );
}
// TabButton.js
export default function TabButton({ onClick, children, isActive }) {
  if (isActive) {
    return <b>{children}</b>
  }

  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}
// Home.js
export default function Home() {
  return (
    <p>Welcome to my profile!</p>
  );
}
// Contact.js
export default function Contact() {
  return (
    <div>
      <p>Send me a message!</p>

      <textarea />

      <p>You can find me online here:</p>
      <ul>
        <li>admin@mysite.com</li>
        <li>+123456789</li>
      </ul>
    </div>
  );
}
body { height: 275px; }
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }

이는 App에서 Contact를 완전히 언마운트하기 때문이에요. Contact 탭이 언마운트되면, <textarea> 요소의 내부 DOM 상태가 손실돼요.

Activity 경계를 사용해서 활성 탭을 보여주고 숨기도록 전환하면, 각 탭의 DOM 상태를 보존할 수 있어요. 텍스트를 입력하고 탭을 다시 전환해보세요. 작성 중인 메시지가 더 이상 리셋되지 않는 걸 볼 수 있어요:

// App.js
import { Activity, useState } from 'react';
import TabButton from './TabButton.js';
import Home from './Home.js';
import Contact from './Contact.js';

export default function App() {
  const [activeTab, setActiveTab] = useState('contact');

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'contact'}
        onClick={() => setActiveTab('contact')}
      >
        Contact
      </TabButton>

      <hr />

      <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
        <Home />
      </Activity>
      <Activity mode={activeTab === 'contact' ? 'visible' : 'hidden'}>
        <Contact />
      </Activity>
    </>
  );
}
// TabButton.js
export default function TabButton({ onClick, children, isActive }) {
  if (isActive) {
    return <b>{children}</b>
  }

  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}
// Home.js
export default function Home() {
  return (
    <p>Welcome to my profile!</p>
  );
}
// Contact.js
export default function Contact() {
  return (
    <div>
      <p>Send me a message!</p>

      <textarea />

      <p>You can find me online here:</p>
      <ul>
        <li>admin@mysite.com</li>
        <li>+123456789</li>
      </ul>
    </div>
  );
}
body { height: 275px; }
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }

다시 한번, Activity 경계를 사용해서 Contact 탭의 내부 상태를 구현 변경 없이 보존할 수 있었어요.


보여질 가능성이 있는 콘텐츠 미리 렌더링하기 {/pre-rendering-content-thats-likely-to-become-visible/}

지금까지 Activity가 사용자가 상호작용한 콘텐츠를 임시 상태를 버리지 않고 숨길 수 있는 방법을 살펴봤어요.

하지만 Activity 경계는 사용자가 아직 처음 보지 않은 콘텐츠를 준비하는 데도 사용할 수 있어요:

<Activity mode="hidden">
  <SlowComponent />
</Activity>

Activity 경계가 초기 렌더링 중에 hidden 상태일 때, 자식들은 페이지에 보이지 않지만 — 여전히 렌더링되어요. 단, 보이는 콘텐츠보다 낮은 우선순위로 렌더링되고, Effects는 마운트되지 않아요.

미리 렌더링(pre-rendering)을 통해 자식들이 필요한 코드나 데이터를 미리 로드할 수 있어서, 나중에 Activity 경계가 visible이 되면 자식들이 더 빨리 나타나고 로딩 시간이 줄어들어요.

예제를 살펴볼게요.

이 데모에서 Posts 탭은 일부 데이터를 로드해요. 누르면 데이터를 가져오는 동안 Suspense fallback이 표시돼요:

// App.js
import { useState, Suspense } from 'react';
import TabButton from './TabButton.js';
import Home from './Home.js';
import Posts from './Posts.js';

export default function App() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'posts'}
        onClick={() => setActiveTab('posts')}
      >
        Posts
      </TabButton>

      <hr />

      <Suspense fallback={<h1>🌀 Loading...</h1>}>
        {activeTab === 'home' && <Home />}
        {activeTab === 'posts' && <Posts />}
      </Suspense>
    </>
  );
}
// TabButton.js
export default function TabButton({ onClick, children, isActive }) {
  if (isActive) {
    return <b>{children}</b>
  }

  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}
// Home.js
export default function Home() {
  return (
    <p>Welcome to my profile!</p>
  );
}
// Posts.js
import { use } from 'react';
import { fetchData } from './data.js';

export default function Posts() {
  const posts = use(fetchData('/posts'));

  return (
    <ul className="items">
      {posts.map(post =>
        <li className="item" key={post.id}>
          {post.title}
        </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('/posts')) {
    return await getPosts();
  } else {
    throw Error('Not implemented');
  }
}

async function getPosts() {
  // Add a fake delay to make waiting noticeable.
  await new Promise(resolve => {
    setTimeout(resolve, 1000);
  });
  let posts = [];
  for (let i = 0; i < 10; i++) {
    posts.push({
      id: i,
      title: 'Post #' + (i + 1)
    });
  }
  return posts;
}
body { height: 275px; }
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
video { width: 300px; margin-top: 10px; aspect-ratio: 16/9; }

이는 App이 탭이 활성화될 때까지 Posts를 마운트하지 않기 때문이에요.

App을 Activity 경계를 사용해서 활성 탭을 보여주고 숨기도록 업데이트하면, Posts는 앱이 처음 로드될 때 미리 렌더링되어서 visible이 되기 전에 데이터를 가져올 수 있어요.

이제 Posts 탭을 클릭해보세요:

// App.js
import { Activity, useState, Suspense } from 'react';
import TabButton from './TabButton.js';
import Home from './Home.js';
import Posts from './Posts.js';

export default function App() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'posts'}
        onClick={() => setActiveTab('posts')}
      >
        Posts
      </TabButton>

      <hr />

      <Suspense fallback={<h1>🌀 Loading...</h1>}>
        <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
          <Home />
        </Activity>
        <Activity mode={activeTab === 'posts' ? 'visible' : 'hidden'}>
          <Posts />
        </Activity>
      </Suspense>
    </>
  );
}
// TabButton.js
export default function TabButton({ onClick, children, isActive }) {
  if (isActive) {
    return <b>{children}</b>
  }

  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}
// Home.js
export default function Home() {
  return (
    <p>Welcome to my profile!</p>
  );
}
// Posts.js
import { use } from 'react';
import { fetchData } from './data.js';

export default function Posts() {
  const posts = use(fetchData('/posts'));

  return (
    <ul className="items">
      {posts.map(post =>
        <li className="item" key={post.id}>
          {post.title}
        </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('/posts')) {
    return await getPosts();
  } else {
    throw Error('Not implemented');
  }
}

async function getPosts() {
  // Add a fake delay to make waiting noticeable.
  await new Promise(resolve => {
    setTimeout(resolve, 1000);
  });
  let posts = [];
  for (let i = 0; i < 10; i++) {
    posts.push({
      id: i,
      title: 'Post #' + (i + 1)
    });
  }
  return posts;
}
body { height: 275px; }
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
video { width: 300px; margin-top: 10px; aspect-ratio: 16/9; }

Posts는 hidden Activity 경계 덕분에 더 빠른 렌더링을 위해 미리 준비할 수 있었어요.


hidden Activity 경계로 컴포넌트를 미리 렌더링하는 것은 사용자가 다음에 상호작용할 가능성이 있는 UI 부분의 로딩 시간을 줄이는 강력한 방법이에요.

미리 렌더링 중에는 Suspense가 지원되는 데이터 소스만 가져와요. 여기에는 다음이 포함돼요:

  • RelayNext.js와 같은 Suspense 지원 프레임워크를 사용한 데이터 가져오기
  • lazy를 사용한 지연 로딩 컴포넌트 코드
  • use를 사용한 캐시된 Promise 값 읽기

Activity는 Effect 내부에서 가져온 데이터를 감지하지 못해요.

위의 Posts 컴포넌트에서 데이터를 로드하는 정확한 방법은 프레임워크에 따라 달라요. Suspense 지원 프레임워크를 사용한다면 데이터 가져오기 문서에서 세부 사항을 찾을 수 있을 거예요.

특정 프레임워크를 사용하지 않는 Suspense 지원 데이터 가져오기는 아직 지원되지 않아요. Suspense 지원 데이터 소스를 구현하기 위한 요구 사항은 불안정하고 문서화되지 않았어요. Suspense와 데이터 소스를 통합하기 위한 공식 API는 React의 향후 버전에서 출시될 예정이에요.


페이지 로드 중 상호작용 속도 높이기 {/speeding-up-interactions-during-page-load/}

React에는 Selective Hydration이라는 내부 성능 최적화가 포함되어 있어요. 이것은 앱의 초기 HTML을 청크 단위로 하이드레이션해서, 페이지의 다른 컴포넌트들이 코드나 데이터를 아직 로드하지 않았더라도 일부 컴포넌트가 상호작용 가능해지도록 해요.

Suspense 경계는 컴포넌트 트리를 서로 독립적인 단위로 자연스럽게 나누기 때문에 Selective Hydration에 참여해요:

function Page() {
  return (
    <>
      <MessageComposer />

      <Suspense fallback="Loading chats...">
        <Chats />
      </Suspense>
    </>
  )
}

여기서 MessageComposerChats가 마운트되고 데이터를 가져오기 시작하기도 전에 페이지의 초기 렌더링 중에 완전히 하이드레이션될 수 있어요.

그래서 컴포넌트 트리를 개별 단위로 나눔으로써, Suspense는 React가 앱의 서버 렌더링된 HTML을 청크 단위로 하이드레이션하도록 해서, 앱의 일부가 가능한 한 빨리 상호작용 가능해지도록 해요.

하지만 Suspense를 사용하지 않는 페이지는 어떨까요?

이 탭 예제를 살펴볼게요:

function Page() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <>
      <TabButton onClick={() => setActiveTab('home')}>
        Home
      </TabButton>
      <TabButton onClick={() => setActiveTab('video')}>
        Video
      </TabButton>

      {activeTab === 'home' && (
        <Home />
      )}
      {activeTab === 'video' && (
        <Video />
      )}
    </>
  )
}

여기서 React는 전체 페이지를 한 번에 하이드레이션해야 해요. Home이나 Video의 렌더링이 느리면, 하이드레이션 중에 탭 버튼이 응답하지 않는 것처럼 느껴질 수 있어요.

활성 탭 주위에 Suspense를 추가하면 이 문제를 해결할 수 있어요:

function Page() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <>
      <TabButton onClick={() => setActiveTab('home')}>
        Home
      </TabButton>
      <TabButton onClick={() => setActiveTab('video')}>
        Video
      </TabButton>

      <Suspense fallback={<Placeholder />}>
        {activeTab === 'home' && (
          <Home />
        )}
        {activeTab === 'video' && (
          <Video />
        )}
      </Suspense>
    </>
  )
}

...하지만 초기 렌더링에서 Placeholder fallback이 표시되기 때문에 UI도 변경돼요.

대신 Activity를 사용할 수 있어요. Activity 경계는 자식들을 보여주고 숨기기 때문에, 이미 컴포넌트 트리를 독립적인 단위로 자연스럽게 나눠요. 그리고 Suspense처럼, 이 기능을 통해 Selective Hydration에 참여할 수 있어요.

활성 탭 주위에 Activity 경계를 사용하도록 예제를 업데이트해볼게요:

function Page() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <>
      <TabButton onClick={() => setActiveTab('home')}>
        Home
      </TabButton>
      <TabButton onClick={() => setActiveTab('video')}>
        Video
      </TabButton>

      <Activity mode={activeTab === "home" ? "visible" : "hidden"}>
        <Home />
      </Activity>
      <Activity mode={activeTab === "video" ? "visible" : "hidden"}>
        <Video />
      </Activity>
    </>
  )
}

이제 초기 서버 렌더링된 HTML이 원래 버전과 동일하게 보이지만, Activity 덕분에 React는 Home이나 Video를 마운트하기도 전에 탭 버튼을 먼저 하이드레이션할 수 있어요.


따라서 콘텐츠를 숨기고 보여주는 것 외에도, Activity 경계는 React가 페이지의 어떤 부분이 독립적으로 상호작용 가능해질 수 있는지 알 수 있게 해서 하이드레이션 중 앱의 성능을 향상시키는 데 도움이 돼요.

그리고 페이지가 콘텐츠의 일부를 절대 숨기지 않더라도, 하이드레이션 성능을 향상시키기 위해 항상 visible인 Activity 경계를 추가할 수 있어요:

function Page() {
  return (
    <>
      <Post />

      <Activity>
        <Comments />
      </Activity>
    </>
  );
} 

문제 해결 {/troubleshooting/}

숨겨진 컴포넌트에 원치 않는 부수 효과가 있어요 {/my-hidden-components-have-unwanted-side-effects/}

Activity 경계는 자식에 display: none을 설정하고 그들의 Effect를 정리해서 콘텐츠를 숨겨요. 그래서 부수 효과를 제대로 정리하는 잘 만들어진 React 컴포넌트들은 이미 Activity에 의해 숨겨지는 것에 대해 견고할 거예요.

하지만 숨겨진 컴포넌트가 언마운트된 것과 다르게 동작하는 상황이 있어요. 가장 주목할 점은, 숨겨진 컴포넌트의 DOM이 파괴되지 않기 때문에 해당 DOM의 모든 부수 효과가 컴포넌트가 숨겨진 후에도 지속된다는 거예요.

예를 들어, <video> 태그를 생각해보세요. 일반적으로 정리가 필요하지 않아요. 왜냐하면 비디오를 재생 중이더라도 태그를 언마운트하면 브라우저에서 비디오와 오디오 재생이 중지되거든요. 이 데모에서 비디오를 재생한 다음 Home을 눌러보세요:

// src/App.js
import { useState } from 'react';
import TabButton from './TabButton.js';
import Home from './Home.js';
import Video from './Video.js';

export default function App() {
  const [activeTab, setActiveTab] = useState('video');

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'video'}
        onClick={() => setActiveTab('video')}
      >
        Video
      </TabButton>

      <hr />

      {activeTab === 'home' && <Home />}
      {activeTab === 'video' && <Video />}
    </>
  );
}
// src/TabButton.js
export default function TabButton({ onClick, children, isActive }) {
  if (isActive) {
    return <b>{children}</b>
  }

  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}
// src/Home.js
export default function Home() {
  return (
    <p>Welcome to my profile!</p>
  );
}
// src/Video.js
export default function Video() {
  return (
    <video
      // 'Big Buck Bunny' licensed under CC 3.0 by the Blender foundation. Hosted by archive.org
      src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
      controls
      playsInline
    />

  );
}
body { height: 275px; }
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
video { width: 300px; margin-top: 10px; aspect-ratio: 16/9; }

비디오가 예상대로 재생을 멈춰요.

이제 사용자가 마지막으로 시청한 타임코드를 보존해서, 비디오 탭으로 돌아왔을 때 처음부터 다시 시작하지 않도록 하고 싶다고 해볼게요.

이건 Activity의 완벽한 사용 사례예요!

비활성 탭을 언마운트하는 대신 숨겨진 Activity 경계로 숨기도록 App을 업데이트하고, 이번에 데모가 어떻게 동작하는지 확인해볼게요:

// src/App.js
import { Activity, useState } from 'react';
import TabButton from './TabButton.js';
import Home from './Home.js';
import Video from './Video.js';

export default function App() {
  const [activeTab, setActiveTab] = useState('video');

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'video'}
        onClick={() => setActiveTab('video')}
      >
        Video
      </TabButton>

      <hr />

      <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
        <Home />
      </Activity>
      <Activity mode={activeTab === 'video' ? 'visible' : 'hidden'}>
        <Video />
      </Activity>
    </>
  );
}
// src/TabButton.js
export default function TabButton({ onClick, children, isActive }) {
  if (isActive) {
    return <b>{children}</b>
  }

  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}
// src/Home.js
export default function Home() {
  return (
    <p>Welcome to my profile!</p>
  );
}
// src/Video.js
export default function Video() {
  return (
    <video
      controls
      playsInline
      // 'Big Buck Bunny' licensed under CC 3.0 by the Blender foundation. Hosted by archive.org
      src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
    />

  );
}
body { height: 275px; }
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
video { width: 300px; margin-top: 10px; aspect-ratio: 16/9; }

이런! 탭의 <video> 요소가 여전히 DOM에 있기 때문에, 숨겨진 후에도 비디오와 오디오가 계속 재생돼요.

이걸 해결하려면 비디오를 일시정지하는 정리 함수가 있는 Effect를 추가할 수 있어요:

export default function VideoTab() {
  const ref = useRef();

  useLayoutEffect(() => {
    const videoRef = ref.current;

    return () => {
      videoRef.pause()
    }
  }, []);

  return (
    <video
      ref={ref}
      controls
      playsInline
      src="..."
    />

  );
}

여기서 useEffect 대신 useLayoutEffect를 호출해요. 왜냐하면 개념적으로 정리 코드가 컴포넌트의 UI가 시각적으로 숨겨지는 것과 연결되어 있거든요. 일반 effect를 사용하면, 코드가 (예를 들어) 다시 중단되는 Suspense 경계나 View Transition에 의해 지연될 수 있어요.

새로운 동작을 확인해볼게요. 비디오를 재생하고, Home 탭으로 전환한 다음, 다시 Video 탭으로 돌아가보세요:

// src/App.js
import { Activity, useState } from 'react';
import TabButton from './TabButton.js';
import Home from './Home.js';
import Video from './Video.js';

export default function App() {
  const [activeTab, setActiveTab] = useState('video');

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'video'}
        onClick={() => setActiveTab('video')}
      >
        Video
      </TabButton>

      <hr />

      <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
        <Home />
      </Activity>
      <Activity mode={activeTab === 'video' ? 'visible' : 'hidden'}>
        <Video />
      </Activity>
    </>
  );
}
// src/TabButton.js
export default function TabButton({ onClick, children, isActive }) {
  if (isActive) {
    return <b>{children}</b>
  }

  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}
// src/Home.js
export default function Home() {
  return (
    <p>Welcome to my profile!</p>
  );
}
// src/Video.js
import { useRef, useLayoutEffect } from 'react';

export default function Video() {
  const ref = useRef();

  useLayoutEffect(() => {
    const videoRef = ref.current

    return () => {
      videoRef.pause()
    };
  }, [])

  return (
    <video
      ref={ref}
      controls
      playsInline
      // 'Big Buck Bunny' licensed under CC 3.0 by the Blender foundation. Hosted by archive.org
      src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
    />

  );
}
body { height: 275px; }
button { margin-right: 10px }
b { display: inline-block; margin-right: 10px; }
.pending { color: #777; }
video { width: 300px; margin-top: 10px; aspect-ratio: 16/9; }

잘 동작해요! 정리 함수가 Activity 경계에 의해 숨겨질 때 비디오 재생이 중지되도록 보장해요. 더 좋은 점은, <video> 태그가 절대 파괴되지 않기 때문에 타임코드가 보존되고, 사용자가 다시 전환해서 계속 시청할 때 비디오 자체를 다시 초기화하거나 다운로드할 필요가 없다는 거예요.

이것은 숨겨지지만 사용자가 곧 다시 상호작용할 가능성이 있는 UI 부분의 일시적인 DOM 상태를 보존하기 위해 Activity를 사용하는 훌륭한 예예요.


우리의 예제는 <video>와 같은 특정 태그에서 언마운트와 숨기기가 다른 동작을 한다는 것을 보여줘요. 컴포넌트가 부수 효과가 있는 DOM을 렌더링하고, Activity 경계가 숨길 때 그 부수 효과를 방지하고 싶다면, 정리하는 return 함수가 있는 Effect를 추가하세요.

이런 경우가 가장 흔한 태그들은:

  • <video>
  • <audio>
  • <iframe>

하지만 일반적으로 대부분의 React 컴포넌트는 이미 Activity 경계에 의해 숨겨지는 것에 대해 견고해야 해요. 그리고 개념적으로, "숨겨진" Activity를 언마운트된 것으로 생각해야 해요.

제대로 정리되지 않는 다른 Effect를 적극적으로 발견하려면 <StrictMode>를 사용하는 것을 권장해요. 이것은 Activity 경계뿐만 아니라 React의 다른 많은 동작에도 중요해요.


숨겨진 컴포넌트에 실행되지 않는 Effect가 있어요 {/my-hidden-components-have-effects-that-arent-running/}

<Activity>가 "숨겨지면", 모든 자식의 Effect가 정리돼요. 개념적으로, 자식은 언마운트되지만 React가 나중을 위해 상태를 저장해요. 이것은 Activity의 기능이에요. 왜냐하면 숨겨진 UI 부분에 대한 구독이 활성화되지 않아서, 숨겨진 콘텐츠에 필요한 작업량이 줄어들거든요.

Effect 마운트에 의존해서 컴포넌트의 부수 효과를 정리하고 있다면, Effect를 리팩토링해서 반환된 정리 함수에서 작업을 수행하도록 하세요.

문제가 있는 Effect를 적극적으로 찾으려면 <StrictMode>를 추가하는 것을 권장해요. 이것은 예상치 못한 부수 효과를 잡기 위해 Activity 언마운트와 마운트를 적극적으로 수행할 거예요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글