ReactのActivityで、隠した画面の状態を残す方法

​​React​Activity,TypeScript,useEffect,useLayoutEffect

<Activity> で画面の一部を隠すと、表示に戻したときに、隠す前の state と入力欄の中身がそのまま戻ります。{show && <Sidebar />} のような条件付きレンダリングでは、隠すたびにコンポーネントが外され、state は初めの値に戻ってしまいます。

筆者は検証環境で、同じコンポーネントを2通りの書き方で隠し、表示に戻しました。state、入力欄、エフェクトがどうなるかを調べた結果に、公式ドキュメントにある注意点を合わせてまとめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 6.0.3、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で描画しました。<video> の確認と GIF の撮影には、Playwright 1.56.1 の Chromium を使いました。

条件で出し分けると、隠したときに state が消える

<Activity> は React 19.2 で入ったコンポーネントです(React 19.2 の発表記事)。発表記事では、アプリの一部を条件付きで描画する代わりに使えるものとして紹介されています。

公式リファレンスにある、隠したコンポーネントの state を戻す例によると、条件付きレンダリングでは、隠すときにコンポーネントがアンマウントされ、内部の state が失われます。

検証では、次のコンポーネントを用意しました。+1 ボタンで増える count の state と、state を持たない入力欄があります。エフェクトの開始と片付けの回数も数えます。

tsx
import { useEffect, useState } from 'react';
import { log } from './log';

export function Counter({ note }: { note: string }) {
  const [count, setCount] = useState(0);
  log.renders++;

  useEffect(() => {
    log.setups++; // 購読を始める、などの処理にあたる
    return () => {
      log.cleanups++; // 購読をやめる、などの処理にあたる
    };
  }, []);

  return (
    <div>
      <button id="inc" onClick={() => setCount((c) => c + 1)}>+1</button>
      <span id="count">{count}</span>
      <input id="draft" />
      <span id="note">{note}</span>
    </div>
  );
}

1つ目の書き方は、show が true のときだけ描画する、これまでどおりの書き方です。

tsx
import { Counter } from './Counter';

export function App({ show, note }: { show: boolean; note: string }) {
  return <>{show && <Counter note={note} />}</>;
}

2つ目の書き方では、<Activity> で包み、mode に 'visible' か 'hidden' を渡します。

tsx
import { Activity } from 'react';
import { Counter } from './Counter';

export function App({ show, note }: { show: boolean; note: string }) {
  return (
    <Activity mode={show ? 'visible' : 'hidden'}>
      <Counter note={note} />
    </Activity>
  );
}

同じ手順で隠して戻すと、Activity 版だけ元に戻る

動かす手順は、2つとも同じです。まず表示した状態で +1 を3回押し、入力欄に「下書き」と入れました。次に隠し、隠したまま props の note を A から B に変えました。最後に表示に戻しました。

renders は Counter が描画された回数、setups と cleanups はエフェクトの開始と片付けの回数です。style は、Counter のいちばん外側の div の style 属性です。

text
$ node run.mjs ./out/AppConditional.mjs
./out/AppConditional.mjs
  1. 表示して +1 を3回、入力欄に「下書き」: count=3 draft="下書き" note=A style="" | renders=4 setups=1 cleanups=0
  2. 隠す: DOM なし | renders=4 setups=1 cleanups=1
  3. 隠したまま note を B に: DOM なし | renders=4 setups=1 cleanups=1
  4. 表示に戻す: count=0 draft="" note=B style="" | renders=5 setups=2 cleanups=1

$ node run.mjs ./out/AppActivity.mjs
./out/AppActivity.mjs
  1. 表示して +1 を3回、入力欄に「下書き」: count=3 draft="下書き" note=A style="" | renders=4 setups=1 cleanups=0
  2. 隠す: count=3 draft="下書き" note=A style="display: none !important;" | renders=5 setups=1 cleanups=1
  3. 隠したまま note を B に: count=3 draft="下書き" note=B style="display: none !important;" | renders=6 setups=1 cleanups=1
  4. 表示に戻す: count=3 draft="下書き" note=B style="" | renders=7 setups=2 cleanups=1

条件付きレンダリングの版は、隠すと DOM ごとなくなりました。表示に戻すと count は 0、入力欄は空になりました。

<Activity> の版は、表示に戻したとき、count が 3、入力欄が「下書き」のままでした。隠している間も DOM は残っていて、display: none !important; で見えなくなっているだけです。入力欄の中身は React の state ではなく DOM が持っている値なので、DOM が残ることで一緒に残ります。

次の GIF は、2つの書き方を Chromium の画面に縦に並べて動かした様子です。上の「+1」ボタンは、2つのパネルの中にある Counter の +1 ボタンを順に押します。「隠す/表示する」ボタンは、2つのパネルの show を切り替えます。入力欄には、撮り始める前に、両方とも「下書き」と入れておきました。

押した直後は、値が変わった欄がピンクの枠と文字になります。5回目のクリックで表示に戻すと、「① 条件で出し分ける」は count が 0 になり、入力欄が空になります。「② <Activity> で隠す」は、count 3 と「下書き」が残ったままです。

2つの書き方を縦に並べ、+1を3回押してから、隠して表示に戻したときのcountと入力欄、エフェクトの回数の変化
+1を3回押したあと、隠して表示に戻す動き。①はcountが0に戻って入力欄も空になり、②はcount 3と「下書き」が残る。エフェクトはどちらも隠すと片付けられ、戻すと開始し直す
ポイント

条件付きレンダリングを <Activity mode={show ? 'visible' : 'hidden'}> に置き換えるだけで、隠したコンポーネントの state と DOM が残ります。Counter の中身は変えていません。

隠している間、エフェクトは片付けられ、描画は続く

出力の setups と cleanups を見ると、<Activity> の版でも、隠したときにエフェクトが片付けられ(cleanups=1)、表示に戻したときに開始し直されています(setups=2)。ここは、条件付きレンダリングの版と同じ動きです。

公式リファレンスの <Activity> の説明にも、隠すと子のエフェクトを破棄して購読を片付け、表示に戻すとエフェクトを作り直す、と書かれています。隠している画面で購読が動き続けることはありません。

違うのは、描画です。隠したまま note を B に変えると、renders が増え、DOM の note も B になりました。リファレンスによると、隠している間も、子は新しい props に応じて描画し直されます。ただし、ほかの表示中の部分よりも低い優先度で処理されます。この優先度の違いは、検証では確かめていません。

隠しても止まらないもの:再生中の video

DOM が残るということは、DOM そのものが起こす動きも残るということです。リファレンスの、隠したコンポーネントの副作用についての説明では、<video> を例にしています。条件付きレンダリングでアンマウントすれば動画は止まりますが、<Activity> で隠すと、要素が DOM に残るため再生が続く、というものです。

Chromium で確かめました。動画は ffmpeg で作った20秒のテスト映像(音声なし)で、自動再生できるよう muted にしています。plain は片付けなしの版、pause は片付けで pause() を呼ぶ版です。どちらも <Activity> の中に置き、再生してから隠しました。

text
$ node video/run-video.mjs
plain: 再生して2秒  paused=false currentTime=2.0 display=inline
plain: 隠した直後    paused=false currentTime=2.0 display=none
plain: 隠して2秒後   paused=false currentTime=4.0 display=none
plain: 表示に戻した  paused=false currentTime=4.0 display=inline
pause: 再生して2秒  paused=false currentTime=2.0 display=inline
pause: 隠した直後    paused=true currentTime=2.0 display=none
pause: 隠して2秒後   paused=true currentTime=2.0 display=none
pause: 表示に戻した  paused=true currentTime=2.0 display=inline

片付けのない版は、隠したあとも paused=false のままで、再生位置が2秒から4秒に進みました。見えていないだけで、再生は続いています。

注意

<Activity> で隠した <video> は、片付けを書かないと再生を続けます。音声つきの動画なら、画面にないのに音だけが流れることになります(検証の動画は音声なしです)。

片付けで pause() を呼ぶ版は、隠した直後に paused=true になり、再生位置は2秒のまま止まりました。表示に戻しても、再生位置は2秒のままです。ただし、再生は自動では再開せず、paused=true のままでした。片付けの書き方は次のとおりです(検証に使った Video.tsx からの抜粋で、import は省いています)。

tsx
function PausingVideo() {
  const ref = useRef<HTMLVideoElement>(null);
  useLayoutEffect(() => {
    const video = ref.current!;
    return () => {
      video.pause(); // 隠されたときに止める
    };
  }, []);
  return <video id="v" ref={ref} src="test.webm" muted playsInline />;
}

リファレンスでは、useEffect ではなく useLayoutEffect を使う理由を、片付けが「画面から見えなくなること」に結びついているためだと説明しています。通常のエフェクトだと、Suspense の境界がもう一度サスペンドしたときや、View Transition の影響で、片付けが遅れることがあるとしています。リファレンスは、同じ対処が必要になりやすい要素として、<video> のほかに <audio> と <iframe> を挙げています。<audio> と <iframe> は、検証環境では試していません。

最初から hidden にすると、見せる前に描画だけしておける

<Activity> は、まだ見せていない画面を前もって描画しておくのにも使えます。リファレンスの、前もって描画しておく使い方の説明によると、最初から hidden で描画した子は、画面には出ないものの描画はされ、エフェクトはマウントされません。

検証でも、そのとおりになりました。最初から hidden で描画すると、Counter は1回描画され、DOM も display: none !important; の状態で作られました。エフェクトは、表示にしたときに初めて開始しました。

text
$ node prerender.mjs
最初から hidden: renders=1 setups=0 cleanups=0 DOM=<div style="display: none !important;"><button id="inc">+1</button><span id="count">0</span><input id="draft"><span id="note">A</span></div>
visible にした: renders=2 setups=1 cleanups=0 style=""

リファレンスによると、前もって描画している間にデータを読み込めるのは、use で読む Promise のように、Suspense が働くデータの読み方をした場合だけです。エフェクトの中で取得するデータは、表示するまで読み込まれません。データの読み込みについては、検証では確かめていません。

テキストだけを返すコンポーネントは、隠すと何も出ない

リファレンスの注意事項には、テキストだけを返すコンポーネントを隠すと、何も描画されないと書かれています。display: none を付けられる DOM 要素がないためです。

text
$ node text.mjs
mode=hidden: innerHTML=""
mode=visible: innerHTML="Hello, World!"
mode=hidden: innerHTML=""

検証でも、hidden の間は何も出力されず、visible にするとテキストが出ました。

隠した状態は「アンマウントされたもの」と考える

リファレンスの、隠したコンポーネントのエフェクトについての説明によると、hidden の <Activity> の子は、概念としてはアンマウントされたものです。React が state を取っておいてくれるだけで、エフェクトは動いていない、という考え方です。

そのため、エフェクトの片付けをきちんと書いているコンポーネントなら、ほとんどはそのまま <Activity> で隠せます。気をつけるのは、先ほどの <video> のように、DOM 自体が動き続ける要素です。リファレンスは、片付けの漏れを早めに見つけるために、<StrictMode> を使うことを勧めています。

なお、リファレンスには、<Activity> がサーバーで描画した HTML のハイドレーションを細かく分けるのにも役立つという説明もあります(ページ読み込み中の操作を速くする使い方)。この点は、検証環境では試していません。

​Posted by 86