React Labs: ビュー遷移、Activity、その他もろもろ

April 23, 2025 by Ricky Hanlon


React Labs 記事では、現在活発に研究・開発が行われているプロジェクトについて述べていきます。この投稿では、今すぐ試すことができる 2 つの新しい実験的機能と、現在取り組んでいる他の分野の更新情報を共有します。

補足

React Conf 2025 が 10 月 7-8 日にネバダ州ヘンダーソンで開催されます!

この投稿で取り上げた機能について講演を行うためのスピーカーを募集しています。React Conf での講演に興味がある方は、こちらからご応募ください(講演概要は不要です)。

チケット、無料ストリーミング、スポンサーシップなどの詳細については、React Conf のウェブサイトをご覧ください。

本日、試用の準備が整った 2 つの新しい実験的な機能のドキュメントをリリースします!

また、現在開発中の新機能の更新情報も共有します。


新しい実験的な機能

ビュー遷移 (View Transition) と Activity について、react@experimental でテストする準備が整いました。これらの機能は本番環境でテストされており安定していますが、フィードバックを取り入れる過程で最終的な API が変更される可能性が残っています。

React パッケージを最新の実験的なバージョンにアップグレードすることで試すことが可能です。

  • react@experimental
  • react-dom@experimental

これらの機能をアプリで使用する方法を学ぶか、新しく公開されたドキュメントをチェックしてください:

  • <ViewTransition>:ビュー遷移アニメーションを有効にするコンポーネント。
  • addTransitionType:ビュー遷移の起因 (cause) を指定するための関数。
  • <Activity>:UI の一部を非表示・表示するために使用するコンポーネント。

ビュー遷移

React View Transition は、アプリの UI 遷移にアニメーションを追加しやすくするための新しい実験的機能です。内部的にこれらのアニメーションは、ほとんどのモダンブラウザで利用可能な新しい API である startViewTransition を使用します。

要素に対してアニメーションを有効化するには、新登場の <ViewTransition> コンポーネントで要素をラップします。

// "what" to animate.
<ViewTransition>
<div>animate me</div>
</ViewTransition>

この新しいコンポーネントを使用することで、アニメーションが起動した際に「何を」アニメーションするかを宣言的に定義できます。

「いつ」アニメーションするかの方は、以下の 3 つのビュー遷移トリガのいずれかを使用して定義します。

// "when" to animate.

// Transitions
startTransition(() => setState(...));

// Deferred Values
const deferred = useDeferredValue(value);

// Suspense
<Suspense fallback={<Fallback />}>
<div>Loading...</div>
</Suspense>

デフォルトでは、これらのアニメーションはビュー遷移のためのデフォルト CSS アニメーション を使用します(通常はスムースなクロスフェード)。アニメーションの実行方法を定義するためにビュー遷移関連の擬似セレクタを使用できます。たとえば、すべてのビュー遷移についてデフォルトのアニメーションを変更するには * を使用できます。

// "how" to animate.
::view-transition-old(*) {
animation: 300ms ease-out fade-out;
}
::view-transition-new(*) {
animation: 300ms ease-in fade-in;
}

アニメーションのトリガ(startTransitionuseDeferredValue、または Suspense フォールバックからコンテンツへの切り替え)によって DOM が更新される場合、React は宣言的なヒューリスティックスを使用して、どの <ViewTransition> コンポーネントでアニメーションを起動するかを自動的に決定します。その後、CSS で定義されたアニメーションをブラウザが実行します。

ブラウザのビュー遷移 API をすでにご存じで、React がそれをどのようにサポートしているかを知りたい場合は、ドキュメントの <ViewTransition> の動作の仕組みをチェックしてください。

この投稿では、ビュー遷移を使用するいくつかの例を見てみましょう。

以下のアプリから始めましょう。次のような操作ができますが、何のアニメーションも含まれてません。

  • ビデオをクリックして詳細を表示
  • ”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 />;
}

補足

ビュー遷移は CSS・JS 駆動のアニメーションを置き換えるものではない

ビュー遷移は、ナビゲーション、内容展開、オープン、並べ替えといった UI 遷移に対して使用されることを意図しています。アプリ内のすべてのアニメーションを置き換えることが意図されているわけではありません。

上記のサンプルアプリでは、“Like” ボタンをクリックしたときやサスペンスフォールバック内のローディング表示には、すでにアニメーションがあることに注意してください。これらは特定の要素だけをアニメーションしているため、CSS アニメーションの良い使用例です。

ナビゲーションのアニメーション

このアプリにはサスペンス対応のルータが含まれており、ページの遷移はすでにトランジションとしてマークされています。つまり、ナビゲーションは startTransition 内で実行されます。

function navigate(url) {
startTransition(() => {
go(url);
});
}

startTransition はビュー遷移のトリガの一種なので、ページ間でアニメーションするために <ViewTransition> を追加できます。

// "what" to animate
<ViewTransition key={url}>
{url === '/' ? <Home /> : <TalkDetails />}
</ViewTransition>

url が変化すると、<ViewTransition> と新しいページがレンダーされます。<ViewTransition> の更新が startTransition 内で起きたため、この <ViewTransition> のアニメーションが起動されます。

デフォルトでは、ビュー遷移としてブラウザのデフォルトのクロスフェードアニメーションが含まれています。これを追加することで、ページ間を移動するたびにクロスフェードが発生するようになります。

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> を追加するというこの 1 行の変更だけで、デフォルトのクロスフェードアニメーションが有効になるのです。

これがどのように機能するかに興味がある場合は、ドキュメントの <ViewTransition> の動作の仕組みを参照してください。

補足

<ViewTransition> アニメーションのオプトアウト

今回の例では単純化のため、アプリのルート (root) を <ViewTransition> でラップしていますが、これによりアプリ内のすべての UI 遷移がアニメーション化され、予期しないアニメーションが発生する可能性があります。

修正するには、ルートの子を "none" でラップして、各ページが独自のアニメーションを制御できるようにします。

// Layout.js
<ViewTransition default="none">
{children}
</ViewTransition>

実際には、ナビゲーションは props の “enter” と “exit” を介して、または遷移タイプを使用して行うべきです。

アニメーションのカスタマイズ

デフォルトでは、<ViewTransition> にはブラウザのデフォルトのクロスフェードが含まれています。

アニメーションをカスタマイズするには、<ViewTransition> コンポーネントに props を渡して、<ViewTransition> の起動方法に記載されているようにして使用するアニメーションを指定できます。

たとえば、default クロスフェードアニメーションを遅くするには以下のようにします。

<ViewTransition default="slow-fade">
<Home />
</ViewTransition>

その後にビュー遷移クラスを用いて 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> のスタイリングに関する完全なガイドがあります。

共通要素の遷移

2 つのページに同一の要素が含まれている場合、その要素をページをまたいでアニメーションさせたいことがあります。

これを行うには、<ViewTransition> に一意の name を追加します。

<ViewTransition name={`video-${video.id}`}>
<Thumbnail video={video} />
</ViewTransition>

これで、ビデオのサムネイルが 2 つのページをまたいでアニメーションします。

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 はビュー遷移で起動された各要素に一意の name を自動的に生成します(<ViewTransition> の動作の仕組みを参照)。name 付きの <ViewTransition> が削除され、新しい <ViewTransition> が同じ name で追加されるようなビュー遷移を React が検出すると、共通要素のビュー遷移 (shared element transition) が起動します。

詳細については、ドキュメントで共通要素のアニメーションを参照してください。

起因別のアニメーション

場合によっては、アニメーションがトリガされた起因 (cause) に基づいて、要素を異なる方法でアニメーションさせたいことがあります。このユースケースのために、ビュー遷移の起因を指定するための新しい API である addTransitionType を追加しました。

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 type) を使用することで、<ViewTransition> に渡す props を介して、カスタムアニメーションを提供できます。ヘッダの “6 Videos” と “Back” の部分に共通要素のビュー遷移を追加してみましょう。

<ViewTransition
name="nav"
share={{
'nav-forward': 'slide-forward',
'nav-back': 'slide-back',
}}>
{heading}
</ViewTransition>

ここでは、遷移タイプに基づいてどのようにアニメーションするかを定義するため、props として share を渡しています。nav-forward による共通要素ビュー遷移が起動すると、ビュー遷移クラスとして 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: ...
}

これで、サムネールだけではなくヘッダも、ナビゲーションの種類に基づいてアニメーションできるようになりました。

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>
  );
}

サスペンスバウンダリのアニメーション

サスペンスもビュー遷移を起動できます。

フォールバックからコンテンツへのアニメーションを行うには、<ViewTransition>Suspense をラップします。

<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-downslide-up を定義します。

::view-transition-old(.slide-down) {
/* Slide the fallback down */
animation: ...;
}

::view-transition-new(.slide-up) {
/* Slide the content up */
animation: ...;
}

これで、サスペンス内のコンテンツがフォールバックを置き換える際にスライドアニメーションが使われます。

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 を追加することで、上記のすべてのアニメーションを最終結果に追加することができました。

我々はビュー遷移機能に非常に期待しており、みなさんが構築できるアプリのレベルアップに役立つと考えています。React リリースの実験的チャンネルで、今日から試す準備が整っています。

遅いフェードを削除して、最終結果を見ておきましょう。

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> の動作の仕組みをチェックしてください。

ビュー遷移機能の開発経緯についての詳細は、@sebmarkbage による #31975#32105#32041#32734#32797#31999#32031#32050#32820#32029#32028、および #32038 を参照してください (thanks Seb!)。


Activity

過去の お知らせ で、コンポーネントを視覚的に隠して優先度を下げることで、UI の state を保持しつつ、アンマウントや CSS による非表示と比べてパフォーマンスコストを削減する API を研究していることをお話ししました。

この API とその仕組みを共有する準備が整い、React の実験的バージョンでテストを始められるようになりました。

<Activity> は、UI を部分的に隠したり表示したりするための新しいコンポーネントです。

<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Page />
</Activity>

Activity が visible のときは通常通りレンダーされます。Activity が hidden のときはアンマウントされますが、その state を保存し、画面上に表示されているものよりも低い優先度でレンダーを続けます。

Activity を使用して、ユーザが使用していない UI の state を保存したり、ユーザが次に使用する可能性が高い部分を事前にレンダーしておくことが可能です。

以下で、上記のビュー遷移サンプルを改善する例を見ていきましょう。

補足

Activity が非表示のとき、エフェクトはマウントされない

<Activity>hidden のとき、エフェクトはアンマウントされます。概念的にはコンポーネントはアンマウントされているのですが、React は state を後で使用するために保存します。

実際には、そのエフェクトは不要かものガイドに従っている限りは、これは期待通りに動作します。問題のあるエフェクトを積極的に見つけるために、<StrictMode> を追加することをお勧めします。これにより、予期しない副作用をキャッチするために Activity のアンマウントとマウントが積極的に行われます。

Activity で state を復元する

ユーザがページから離れる際は、以前のページのレンダーを止めてしまうのが一般的です。

function App() {
const { url } = useRouter();

return (
<>
{url === '/' && <Home />}
{url !== '/' && <Details />}
</>
);
}

しかしこれでは、ユーザが前のページに戻ったとき、以前のすべての state が失われていることになります。たとえば、<Home /> ページに <input> フィールドがある場合、ユーザがページを離れると <input> はアンマウントされ、入力したすべてのテキストは消えてしまいます。

Activity を使用すると、ユーザがページを移動しても state を保持できるため、戻ってきたときに以前の状態から再開できます。これは、ツリーの一部を <Activity> でラップし、mode を切り替えることで実現できます。

function App() {
const { url } = useRouter();

return (
<>
<Activity mode={url === '/' ? 'visible' : 'hidden'}>
<Home />
</Activity>
{url !== '/' && <Details />}
</>
);
}

以前のビュー遷移の例を、これで改善できます。以前は、ビデオを検索し、選択して戻ってくると、入力された検索フィルタが失われていました。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>

これにより、次のページのコンテンツをプリレンダーする余裕がある場合は、サスペンスフォールバックなしでアニメーションが表示されます。ビデオをクリックしてみて、詳細ページのビデオタイトルと説明がフォールバックなしで即座にレンダーされることを確認してください。

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 によるサーバサイドレンダリング

サーバサイドレンダリング (server-side rendering; SSR) を使用するページで Activity を使用する場合、追加の最適化が行われます。

ページの一部が mode="hidden" でレンダーされる場合、それは SSR のレスポンスに含まれません。代わりに React は、画面上の可視コンテンツのハイドレーションを優先しつつ、Activity 内のコンテンツのクライアントレンダーをスケジュールします。

UI の一部が mode="visible" でレンダーされる場合、React は Activity 内のコンテンツのハイドレーションを、優先度を下げて行います。これは、サスペンスのコンテンツが低い優先度でハイドレーションされるのと似ています。ユーザがページを操作した場合は、必要に応じてサスペンスバウンダリのハイドレーションを優先します。

これらは高度なユースケースですが、Activity を使用することで得られる可能性がある追加の利点となっています。

将来の Activity モード

将来的には、Activity にさらに多くのモードを追加するかもしれません。

たとえば、一般的なユースケースはモーダルのレンダーです。「アクティブ」なモーダルビューの背後に、「非アクティブ」なページが表示され続けます。このケースで “hidden” モードを使うと、表示自体がされず SSR にも含まれないことになるため、うまくいきません。

代わりに、コンテンツを表示したままにして SSR にも含めるが、アンマウント状態に保って更新の優先度を下げる、という新しいモードを検討しています。モーダルが開いている間に背後のコンテンツが更新されると気が散るため、このモードでは DOM の更新を「一時停止」するようにするかもしれません。

Activity の別のモードとして我々が考慮しているのは、メモリ使用量が多すぎる場合に非表示の Activity の state を自動的に破棄する機能です。コンポーネントはすでにアンマウントされているため、アプリの非表示の部分のうち最近使用されていない部分から state を破棄していくことは、リソースを過剰に消費するよりも好ましいかもしれません。

これらはまだ探求中の領域であり、進展があれば共有します。現段階で Activity に含まれている機能の詳細は、ドキュメントをチェックしてください


開発中の機能

他にも、一般的な問題を解決するために、以下のような機能を開発中です。

考えうるソリューションを私たちが検討していく中で、マージ予定の PR 内でテスト中の API 候補が共有されることがあります。様々なアイデアが試された後に、これらのソリューションは変更されたり削除されたりすることがよくあることをご承知おきください。

私たちが取り組んでいるソリューションが時期尚早に共有されると、コミュニティに混乱を招く可能性があります。透明性を保ちつつ混乱を最小限に抑えるために、私たちは現在解決法を開発しようとしている問題点については共有しますが、具体的なソリューションについては共有しないこととします。

これらの機能に進捗があれば、ブログでの発表とドキュメントの公開を行い、試していただけるようになる予定です。

React パフォーマンストラック

現在、ブラウザ API を使用してパフォーマンスプロファイラにカスタムのトラック (track) を追加できる機能を開発しており、これにより React アプリケーションのパフォーマンスに関するより多くの情報を提供できるようになります。

この機能はまだ開発中でありドキュメントの準備が整っていないため、実験的機能として完全にリリースできてはいません。ですが React の実験的バージョンを使用すれば、パフォーマンストラックが自動的にプロファイルに追加されるため、一足早く試すことが可能です。

対応予定の既知の問題がいくつか存在しています。例えば、パフォーマンスに関する問題や、スケジューラーのトラックがサスペンドされたツリー間で作業を「接続」できない場合がある、といった問題です。そのため、まだ試せる段階にはありません。また、トラックのデザインと使いやすさを改善するために、初期ユーザからのフィードバックを収集している最中です。

これらの問題を解決後に、実験的機能としてドキュメントを公開し、試す準備が整ったことをお知らせする予定です。


エフェクト依存配列の自動化

フックをリリースしたとき、私たちには 3 つの動機がありました。

  • コンポーネント間でのコード共有:フックがレンダープロップや高階コンポーネントといったパターンを置き換えたことで、コンポーネントの階層構造を変更せずにステートフルなロジックを再利用できるようになりました。
  • ライフサイクルではなく関数指向で考える:コンポーネントのコードをライフサイクルメソッドに基づいて無理矢理分割する代わりに、フックのおかげでコードの意味的な関連性(サブスクリプションの設定やデータフェッチ)に基づいて 1 つのコンポーネントをより小さな関数に分割できるようになりました。
  • 事前コンパイルのサポート:フックは、事前コンパイルをサポートし、ライフサイクルメソッドやクラスの制限によって引き起こされる最適化漏れといった落とし穴を減らせるように設計されました。

リリース以来、フックはコンポーネント間でのコード共有という点では成功しています。フックは、コンポーネント間でロジックを共有するための望ましい方法となり、レンダープロップや高階コンポーネントの使用例は減少しています。フックはまた、クラスコンポーネントでは不可能だった Fast Refresh のような機能をサポートすることにも成功しています。

エフェクトは難しい

残念ながら一部のフックは、ライフサイクルではなく関数の観点で考えることがいまだに困難です。特にエフェクトは理解しにくく、この辛さは開発者から最もよく聞かれるところとなっています。昨年私たちは、エフェクトの使われ方や、エフェクトのユースケースを簡素化し理解しやすくするための方法について、多くの時間を費やし研究を行いました。

多くの場合に混乱の原因は、必要もなくエフェクトが使われていることにあるとわかりました。そのエフェクトは不要かものガイドは、エフェクトがソリューションとして適切ではないパターンの多くをカバーしています。しかし、ある問題に対してエフェクトが適切であるという場合ですら、エフェクトはクラスコンポーネントのライフサイクルよりも理解しにくい場合があります。

混乱の一因は、開発者がエフェクトをエフェクト自体の視点(エフェクトが何をするか)ではなく、ライフサイクルのようなコンポーネントの視点から考えてしまっていることにあると考えています。

ドキュメントにあるこちらの例を見てみましょう。

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 が変更されるたびに古いルームから切断して接続を再確立する」のように読んでしまいます。しかしこれでは、コンポーネントのライフサイクルの視点から考えてしまっています。つまり、エフェクトを正しく書くためにコンポーネントライフサイクルの全状態を考える必要があるのです。これは難しいことです。コンポーネントの視点で考えていると、エフェクトがクラスのライフサイクルよりも難しいと感じてしまうのは理解できます。

依存配列のないエフェクト

代わりに、エフェクトの視点から考える方がベターです。エフェクトはコンポーネントのライフサイクルについて知りません。同期を開始する方法と停止する方法が記述されているだけです。ユーザがこのようにエフェクトを考えることでエフェクトは書きやすくなり、必要次第で何度も開始・停止されることに対して、より頑強になります。

エフェクトをコンポーネントの視点から考えてしまう理由について時間をかけて調査し、その一因が依存配列にあると考えるようになりました。常に目の前にあって書かなければならないもののため、コードが何に「反応」しているのかを意識せざるを得ず、だから「これらの値が変わったらこれを行え」式のメンタルモデルに誘い込まれてしまうのです。

フックをリリースした当時から、事前コンパイルでこの使いやすやを改善できることは分かっていました。React Compiler を使用すると、ほとんどの場合、自分で useCallbackuseMemo を書く必要がなくなります。エフェクトの場合、コンパイラが依存配列を自動的に挿入できるようになります。

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}); // compiler inserted dependencies.

このコードでは、React Compiler が依存配列を自動的に推論して挿入するため、見る必要も書く必要もありません。IDE 拡張useEffectEvent のような機能を使用することで、デバッグが必要なときや依存値を削除して最適化したい時のために、コンパイラが挿入したものを表示する CodeLens を提供できます。これにより、エフェクトを書くための正しいメンタルモデルが強化され、コンポーネントやフックの state を他のものと同期させるために任意のタイミングで実行できるエフェクトが書けるようになるでしょう。

依存配列を自動的に挿入することにより我々が期待しているのは、ただ書きやすくなるというだけのことではありません。コンポーネントのライフサイクルではなく「エフェクトが何をするのか」という視点で考えることを強制し、理解がしやすくなることを期待しています。


コンパイラの IDE 拡張

今週初めに React Compiler のリリース候補版を共有しました。今後の数か月でコンパイラの最初の SemVer 安定版をリリースすることを目指しています。

また、React Compiler を使用してコードの理解やデバッグ体験を改善するための情報を提供する方法を模索し始めました。私たちが探求し始めたアイデアのひとつは、Lauren Tan の React Conf での講演で使用された拡張に似た、React Compiler によって駆動される新しい実験的な LSP ベースの React IDE 拡張です。

考え方としては、IDE 内で情報やサジェスチョン、最適化候補を直接表示するために、コンパイラの静的解析を活用する、というものです。たとえば、React のルールに違反しているコードに対する診断、コンポーネントやフックがコンパイラによって最適化されたかどうかを示すホバー、または自動挿入されたエフェクトの依存配列を表示する CodeLens といったものを提供できます。

IDE 拡張はまだ初期の探求段階ですが、今後の更新で進捗を共有していきます。


フラグメント ref

イベント管理、位置決め、フォーカスのために使われる DOM API の多くは、React ではうまく組み合わせて書くことが困難です。このため開発者はよく、エフェクトを使用したり、複数の ref を取り回したり、React 19 で削除された findDOMNode のような API を使用したりしています。

フラグメントに ref を追加し、単一の DOM 要素ではなく DOM 要素のグループを参照できるようにすることを検討しています。これにより、複数の子を管理することが簡単になり、DOM API を呼び出すときにより組み合わせしやすい React コードが書きやすくなることを期待しています。

フラグメント ref はまだ研究中です。最終的な API が完成に近づいたらお知らせします。


ジェスチャーアニメーション

ビュー遷移機能を強化して、メニューをスワイプして開く、またはフォトカルーセルをスクロールする、といったジェスチャーアニメーションをサポートする方法についても研究しています。

ジェスチャーには以下のような幾つかの新たな課題があります。

  • ジェスチャーは連続的:スワイプの最中、アニメーションはユーザの指の位置と結びついており、起動したらただ最後まで再生されるというわけではありません。
  • ジェスチャーは完了しない場合がある:指を離した際にジェスチャーアニメーションは最後まで再生されるかもしれませんが、進んだ距離によっては元の状態に戻る(メニューを少しだけ開いた際のように)かもしれません。
  • ジェスチャーでは「新」と「旧」が逆:アニメーション中は、アニメーション元のページを「生きた」状態でインタラクティブに保ちたいはずです。ブラウザのビュー遷移モデルでは「古い」状態がスナップショットで「新しい」状態が生きた DOM ですので、動作が逆転しています。

私たちはうまく機能するアプローチを発見できたと考えているため、ジェスチャー遷移をトリガする新しい API を導入するかもしれません。今のところは <ViewTransition> のリリースに集中していますが、その後にジェスチャーを再検討する予定です。


並行ストア

React 18 を並行レンダー (concurrent rendering) 機能と共にリリースした際、useSyncExternalStore もリリースしました。これは、React の state やコンテクストを使用しない外部ストアライブラリが、当該ストアが更新されたときに同期レンダーを強制することで、並列レンダーをサポートできるようにするためのものでした。

しかし useSyncExternalStore の使用にはコストが伴います。トランジションのような並行レンダー機能からの離脱を強制し、既存のコンテンツにサスペンスのフォールバックを表示させてしまうからです。

React 19 がリリースされましたので、use API を使用して並行レンダー対応の外部ストアを完全にサポートするプリミティブを作成できないか、この問題領域について再検討を行っています。

const value = use(store);

私たちの目標は、レンダー中に不整合 (tearing) を起こさず外部状態を読み取れるようにし、React が提供するすべての並行レンダー機能とシームレスに連携できるようにすることです。

この研究はまだ初期段階です。進展があれば、どのような新しい API になるかを共有する予定です。


この投稿をレビューしていただいた 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, Tim Yung に感謝します。