iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

React 19.2で追加された<Activity>コンポーネントについて

はじめに

こんにちは、保田です。本記事はiimonアドベントカレンダー25日目の記事となります。

試験的機能として開発されていた<Activity>コンポーネントが、2025年10月にリリースされたReact 19.2で正式に導入されました。 普段の業務で使えるものなのか気になったので、今回調べてみることにしました。

https://ja.react.dev/reference/react/Activity

タブ切り替えで状態が消える問題

Reactでタブを切り替えるとき、よく使われるのが条件付きレンダリングです。

import { useState } from 'react';

const App = () => {
  const [tab, setTab] = useState<'A' | 'B'>('A');

  return (
    <div>
      <button onClick={() => setTab('A')}>Tab A</button>
      <button onClick={() => setTab('B')}>Tab B</button>

      {/* タブを切り替えるとコンポーネントが消えてしまう */}
      {tab === 'A' && <ContentA />}
      {tab === 'B' && <ContentB />}
    </div>
  );
};

この処理では、タブを切り替えたときに状態が消えてしまうという問題があります。 その結果、以下のような不都合が起きる場合があります。

  • <ContentB />に入力した内容が、Tab Aに移動した瞬間に消える
  • <ContentA />でスクロールした位置が、タブを切り替えるとリセットされる
  • コンポーネントの内部状態がすべて初期化される

「この箇所の状態はそのまま保持しておきたい」というケースは、普段の開発でもよくあるのではないでしょうか。

これまでの解決策と、その問題点

この問題に対して考えられる回避策はいくつかありますが、デメリットを伴う場合もあります。

  • グローバルState
    • やり方:ReduxやContext等で状態を外部に保存
    • 問題点:単純なUI状態管理に対しては過剰
  • CSSで非表示
    • やり方:style={{ display: isActive ? 'block' : 'none' }}
    • 問題点:useEffectが動き続ける
  • 状態のリフトアップ
    • やり方:親コンポーネントで全状態を管理
    • 問題点:バケツリレーなど、コードが複雑化することがある

一番シンプルなやり方はCSSでの非表示ですが、見えなくなってもuseEffectが動き続けるという問題は解決できません。 また、グローバルStateで状態を外部に保存する方法は、コードが複雑化しがちで避けたいところです。

// CSSで隠す方法の問題点
const HiddenComponent = () => {
  useEffect(() => {
    const handleScroll = () => {
      console.log('スクロール処理が実行され続ける');
    };
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return <div>テスト</div>;
};

<Activity> の基本

基本的な使い方

<Activity>の使い方は簡単で、modeで表示と非表示を切り替えるだけです。

import { useState, Activity } from 'react';

const App = () => {
  const [tab, setTab] = useState<'A' | 'B'>('A');

  return (
    <div>
      <button onClick={() => setTab('A')}>Tab A</button>
      <button onClick={() => setTab('B')}>Tab B</button>

      {/* Activityで状態を保ったまま切り替え */}
      <Activity mode={tab === 'A' ? 'visible' : 'hidden'}>
        <ContentA />
      </Activity>
      <Activity mode={tab === 'B' ? 'visible' : 'hidden'}>
        <ContentB />
      </Activity>
    </div>
  );
};

modeは2つの値を取ります。

  • 'visible': 普通に表示。useEffectも動く
  • 'hidden': 非表示。useEffectは止まる(状態は残る)

modeのデフォルト値は'visible'です。

<Activity>では何が行われているか?

<Activity>の動きを理解すると使用イメージが掴みやすくなります。 内部でどのような処理が行われているかを解説します。

mode='hidden'になったとき

  1. CSSdisplay: noneで見えなくする
  2. useEffectのクリーンアップ関数を実行する(タイマー解除やイベントリスナーの削除など)
  3. ただし、状態は残しておく

mode='visible'に戻ったとき

  1. 保存しておいた状態を使って元通りに表示
  2. useEffectを再開する

<Activity>の特徴は、hiddenにしても状態が破棄されないことです。 各手法の違いを比較すると以下のようになります。

手法 DOM 状態 useEffect
条件付きレンダリング 削除される 破棄される 停止(アンマウント)
CSS (display: none) 残る 保持される 動き続ける
<Activity mode="hidden"> 残る 保持される 停止(クリーンアップ実行)

<Activity> の活用パターン

内部状態を保持する

サイドバー、アコーディオン、折りたたみメニューなどで、展開状態や選択状態を保持したい場合に便利です。 展開状態や選択状態が消えてしまう問題を解決できます。

// Before - 状態が消えてしまう
{isShowingSidebar && <Sidebar />}

// After - 状態が保持される
<Activity mode={isShowingSidebar ? 'visible' : 'hidden'}>
  <Sidebar />
</Activity>

サイドバー内のアコーディオンを開いた状態などが、非表示→再表示しても残ります。 これだけで状態を保持できるのは嬉しいです。

入力内容を保持する

ページ切り替えやステップ形式のフォームなどで、入力内容を保持したい場合に便利です。 テキストエリアの入力内容が戻ってしまう問題を解決することができます。

import { useState, Activity } from 'react';

const App = () => {
  const [page, setPage] = useState<'A' | 'B'>('B');

  return (
    <>
      <button onClick={() => setPage('A')}>Page A</button>
      <button onClick={() => setPage('B')}>Page B</button>

      <hr />

      <Activity mode={page === 'A' ? 'visible' : 'hidden'}>
        <PageA />
      </Activity>
      <Activity mode={page === 'B' ? 'visible' : 'hidden'}>
        <PageB /> {/* ここでの入力中の内容などが消えない */}
      </Activity>
    </>
  );
};

テキストエリアに書きかけの内容があっても、ページを切り替えてから戻った際も、内容が消えずにそのまま残ります。

ハマりポイント

<Activity>は便利ですが、いくつか注意すべき点もあります。

動画や音声が止まらない

問題: <video><audio>は、hiddenにしても再生が止まりません。 見えなくなるだけで、DOMは残っていることが原因です。 当然といえば当然ですが、うっかりハマりやすいポイントです。

// hidden状態でも動画が再生され続ける
<Activity mode={isActive ? 'visible' : 'hidden'}>
  <video src="/movie.mp4" autoPlay />
</Activity>

解決策: useLayoutEffectで自分で止める処理を書くことによって解決することができます。

import { useRef, useLayoutEffect } from 'react';

// 解決方法は、hidden時に再生を停止
const Video = () => {
  const ref = useRef<HTMLVideoElement>(null);

  useLayoutEffect(() => {
    const videoRef = ref.current;
    return () => {
      videoRef?.pause(); // hiddenになったら一時停止
    };
  }, []);

  return <video ref={ref} controls playsInline src="/movie.mp4" />;
};

useLayoutEffectのクリーンアップはUIの変更と同期的に実行されるため、 コンポーネントが非表示になるタイミングで確実に再生を停止することができます。

テキストだけのコンポーネントは何も出力されない

問題: テキストだけを返すコンポーネントは、hiddenモードではDOMに何も出力されません。

// hiddenモードでは何も出力されない
const ComponentThatJustReturnsText = () => 'テストテキスト';

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

<Activity>は子要素のホスト要素(divspanなどのDOM要素)にdisplay: noneを適用することで非表示を実現しています。 テキストノードには対応するDOM要素がないため、hiddenモードでは何も出力されません。

解決策: divやspanで囲むことで正しく非表示にできます。

// spanなどでラップすれば動く
<Activity mode="hidden">
  <span>
    <ComponentThatJustReturnsText />
  </span>
</Activity>

// または、コンポーネント側でDOM要素を返す
const TextWithWrapper = () => <div>テストテキスト</div>;

いつ使えばいいか?

<Activity>は例えば、以下のような場面で有効です。

  • ページ切り替え

  • サイドバーの開閉

  • フォームのステップ移動など

判断基準は「頻繁に表示/非表示が切り替わり、状態を残したいかどうか」で考えるのが良さそうです。

おわりに

<Activity>は、状態を残したままUIを切り替えたいという問題に対して、とても便利なコンポーネントです。 これまでライブラリを使ったり、いろいろ工夫したりしていた問題が、React本体の機能でシンプルに解決できるようになりました。 主なメリットは以下の通りです。

  • コードがシンプルになる - 状態管理の複雑さが減る
  • パフォーマンスが良くなる - useEffectの適切な停止と再開
  • ユーザー体験が良くなる - 入力内容や操作状態が消えない

普段の業務で遭遇する「フォームの入力内容が消える」「サイドバーの状態がリセットされる」といった問題に対して、シンプルに解決できる手段だと感じました。 次に該当するケースに遭遇したら、積極的に使っていこうと思います。 それでは、良いクリスマスを! 今年一年皆さん本当にお世話になりました。

ここまで読んでくださってありがとうございます!

弊社ではエンジニアを募集しています!少しでもご興味がありましたら、ぜひカジュアル面談でお話しましょう!

iimon採用サイト / Wantedly

参考リンク