React 19 useEffectEventとActivity徹底解説: 新API・実装パターン・面接対策2026

React 19.2で導入されたuseEffectEventとActivityコンポーネントの動作原理、実装パターン、面接頻出問題を体系的に解説します。

React 19 useEffectEvent and Activity API deep dive

React 19.2(2025年10月リリース)において、長年にわたり開発者を悩ませてきた2つの課題に対する公式ソリューションが登場しました。useEffectEventはエフェクト内のstale closure(古いクロージャ)問題を根本的に解決し、<Activity>コンポーネントはバックグラウンドでのプリレンダリングと状態保持を可能にします。

これら2つのAPIは、React開発における副作用の管理方法とナビゲーション体験を大きく変える可能性を持っています。2026年現在、多くのプロダクション環境で採用が進んでおり、Reactエンジニアの技術面接でも頻出トピックとなっています。本記事では、それぞれのAPIの背景にある課題から具体的な実装パターン、そして面接で問われるポイントまでを網羅的に解説します。

React 19.2以上が必要です

useEffectEventとActivityはReact 19.2以降で利用可能です。npm install react@latest react-dom@latestでアップデートしてください。ESLintプラグインeslint-plugin-react-hooks@6+がuseEffectEventの依存配列チェックをネイティブサポートします。

Stale Closure問題とuseRefによる従来の回避策

ReactのuseEffectにおいて、依存配列に含めない値をエフェクト内部で参照すると、その値が古いまま固定されてしまう現象が発生します。これがいわゆるstale closure問題です。

典型的な例として、チャットルームの接続管理を考えます。roomIdが変わったときだけ再接続したいが、メッセージ受信時のアナリティクス送信では常に最新のthemeを参照したいというケースです。themeを依存配列に含めると、テーマ変更のたびにWebSocket接続が切断・再接続されてしまいます。

従来、この問題に対する標準的な回避策はuseRefを使ったパターンでした。

hooks/useChatRoom.tstsx
// Before useEffectEvent: useRef workaround
import { useEffect, useRef } from 'react'

export function useChatRoom(roomId: string, theme: string) {
  // Store theme in a ref to avoid stale closure
  const themeRef = useRef(theme)
  themeRef.current = theme

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('message', (msg) => {
      // themeRef.current always has the latest value
      logAnalytics('new_message', { roomId, theme: themeRef.current })
      showNotification(msg)
    })
    connection.connect()
    return () => connection.disconnect()
  }, [roomId]) // theme intentionally excluded — but linter warns
}

このアプローチには複数の問題点があります。まず、themeを依存配列から意図的に除外しているため、ESLintのexhaustive-depsルールが警告を出します。また、useRefによる手動の値同期はボイラープレートコードが増え、コードの可読性と保守性を低下させます。さらに、チームの新しいメンバーがこのパターンの意図を理解できず、「修正」としてthemeを依存配列に追加してしまうリスクもあります。

useEffectEvent: リアクティブロジックと非リアクティブロジックの分離

useEffectEventは、エフェクト内で使用する「イベントハンドラ的な関数」を定義するためのフックです。このフックで作成された関数は、常に最新のpropsやstateを参照しますが、エフェクトの依存配列には影響を与えません。

先ほどのチャットルームの例をuseEffectEventで書き直すと、以下のようになります。

hooks/useChatRoom.tstsx
// After useEffectEvent: clean separation of concerns
import { useEffect, useEffectEvent } from 'react'

export function useChatRoom(roomId: string, theme: string) {
  // Effect Event: always reads latest theme, never triggers reconnect
  const onMessage = useEffectEvent((msg: string) => {
    logAnalytics('new_message', { roomId, theme })
    showNotification(msg)
  })

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('message', onMessage)
    connection.connect()
    return () => connection.disconnect()
  }, [roomId]) // No linter warning — onMessage is an Effect Event
}

変更点を整理すると、まずuseRefによるボイラープレートが完全に不要になりました。themeuseEffectEvent内で直接参照しているため、常に最新の値が読み取られます。そして、onMessageはEffect Eventとして定義されているため、ESLintの依存配列チェックでも警告は発生しません。

このAPIの設計思想は「関心の分離」にあります。エフェクトの同期ロジック(roomIdの変更に応じた接続管理)と、イベント発生時の処理ロジック(メッセージ受信時のアナリティクス送信)が明確に分離されます。

useEffectEventのルールと制約

useEffectEventは強力なAPIですが、正しく使用するためにいくつかの重要なルールを理解する必要があります。

第一に、useEffectEventで定義した関数はuseEffectの内部からのみ呼び出すことができます。イベントハンドラやレンダリングロジック内での直接呼び出しは禁止されています。第二に、Effect Eventを他のコンポーネントやカスタムフックにpropsとして渡すことはできません。第三に、Effect Eventは依存配列に含める必要がありません(含めてもいけません)。

以下は、これらのルールに準拠した正しい実装パターンの例です。

components/SearchTracker.tsxtsx
// Correct: Effect Event used inside useEffect
import { useEffect, useEffectEvent, useState } from 'react'

interface SearchTrackerProps {
  query: string
  userId: string
}

export function SearchTracker({ query, userId }: SearchTrackerProps) {
  const [results, setResults] = useState<string[]>([])

  // Track searches with current user context
  const onSearchComplete = useEffectEvent((resultCount: number) => {
    analytics.track('search_complete', {
      query,
      userId,       // Always latest userId
      resultCount,
      timestamp: Date.now(),
    })
  })

  useEffect(() => {
    const controller = new AbortController()
    fetchSearchResults(query, controller.signal).then((data) => {
      setResults(data)
      onSearchComplete(data.length) // Called from inside useEffect
    })
    return () => controller.abort()
  }, [query]) // userId excluded safely — lives in the Effect Event

  return <ResultsList results={results} />
}

この例では、検索結果の取得(queryに依存するリアクティブなロジック)と、アナリティクスのトラッキング(userIdを参照する非リアクティブなロジック)がuseEffectEventによって明確に分離されています。userIdが変更されても検索のリフェッチは発生しませんが、トラッキング時には常に最新のuserIdが使用されます。

useEffectEventの誤用に注意

useEffectEventは依存配列のリンター警告を消すためのエスケープハッチではありません。値がエフェクトの再実行タイミングを本当に制御すべき場合は、依存配列に含めるのが正しい対応です。useEffectEventは、ログ送信や通知など、リアクティブな値を読み取るが再トリガーの必要がない副次的なロジックにのみ使用してください。

Activityコンポーネント: バックグラウンドプリレンダリングと状態保持

<Activity>は、UIの一部をバックグラウンドでレンダリングしながら、コンポーネントの状態を保持するための新しいコンポーネントです。従来のタブ切り替えUIでは、非アクティブなタブのコンポーネントはアンマウントされ、フォームへの入力内容やスクロール位置などの状態が失われていました。

<Activity>modeプロパティには"visible""hidden"の2つの値を指定できます。それぞれのモードにおける動作の違いは以下の通りです。

| 動作 | mode="visible" | mode="hidden" | |----------|-------------------|------------------| | DOMレンダリング | 通常表示 | CSSのdisplay: noneで非表示 | | コンポーネントの状態 | アクティブ | メモリ上に保持 | | エフェクト(useEffect) | マウント済み | クリーンアップ実行 | | 更新の優先度 | 通常 | アイドル時に遅延実行 | | プリレンダリング | 該当なし | 低優先度でレンダリング |

特に重要なポイントとして、mode="hidden"ではエフェクトがクリーンアップされるという点があります。これにより、WebSocket接続やタイマーなどのリソースが非表示時に適切に解放されます。

以下は、タブレイアウトでの<Activity>の基本的な使用例です。

components/TabLayout.tsxtsx
// Activity preserves form state across tab switches
import { useState } from 'react'
import { Activity } from 'react'

interface Tab {
  id: string
  label: string
  content: React.ReactNode
}

export function TabLayout({ tabs }: { tabs: Tab[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id)

  return (
    <div>
      <nav className="flex gap-2 border-b border-border">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            className={activeTab === tab.id ? 'border-b-2 border-primary' : ''}
          >
            {tab.label}
          </button>
        ))}
      </nav>
      {tabs.map((tab) => (
        <Activity key={tab.id} mode={activeTab === tab.id ? 'visible' : 'hidden'}>
          {tab.content}
        </Activity>
      ))}
    </div>
  )
}

条件付きレンダリング(activeTab === tab.id && tab.content)ではなく、すべてのタブを常にレンダリングしつつ<Activity>modeで表示・非表示を切り替える点が重要です。これにより、タブを切り替えてもフォームの入力状態やスクロール位置が維持されます。

React / Next.jsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

Activityによるルートのプリレンダリング

<Activity>のもう一つの重要な活用パターンは、ユーザーが次に遷移する可能性が高いルートを事前にバックグラウンドでレンダリングしておくことです。

components/PrerenderedRoute.tsxtsx
// Pre-render the next likely route in the background
import { Activity, Suspense, use } from 'react'

interface PrerenderedRouteProps {
  isActive: boolean
  dataPromise: Promise<DashboardData>
}

export function PrerenderedRoute({ isActive, dataPromise }: PrerenderedRouteProps) {
  return (
    <Activity mode={isActive ? 'visible' : 'hidden'}>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent dataPromise={dataPromise} />
      </Suspense>
    </Activity>
  )
}

function DashboardContent({ dataPromise }: { dataPromise: Promise<DashboardData> }) {
  // use() reads the cached promise — works during hidden pre-render
  const data = use(dataPromise)
  return (
    <div className="grid grid-cols-3 gap-4">
      <MetricsCard data={data.metrics} />
      <ChartPanel data={data.charts} />
      <RecentActivity items={data.activity} />
    </div>
  )
}

mode="hidden"の状態では、コンポーネントは低優先度でレンダリングされ、use()によるPromiseの解決も事前に行われます。ユーザーが実際にそのルートに遷移した際、mode"visible"に切り替わると、すでにレンダリング済みのコンテンツが即座に表示されます。これにより、ユーザー体験の大幅な向上が期待できます。

ActivityとTanStack Queryの組み合わせにおける注意点

<Activity>をTanStack Query(React Query)と組み合わせて使用する際には、重要な落とし穴があります。mode="hidden"の状態ではエフェクトがクリーンアップされるため、useQueryによるデータフェッチが実行されません。

components/UserDashboard.tsxtsx
// Problem: useQuery won't fetch when Activity is hidden
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity } from 'react'

function UserStats() {
  // This useQuery will NOT run while hidden
  const { data } = useQuery({
    queryKey: ['user-stats'],
    queryFn: fetchUserStats,
  })
  return <StatsDisplay data={data} />
}

// Solution: prefetch data outside the Activity boundary
function DashboardWithPrefetch({ showStats }: { showStats: boolean }) {
  const queryClient = useQueryClient()

  // Prefetch at the parent level — runs regardless of Activity mode
  queryClient.ensureQueryData({
    queryKey: ['user-stats'],
    queryFn: fetchUserStats,
  })

  return (
    <Activity mode={showStats ? 'visible' : 'hidden'}>
      <UserStats />
    </Activity>
  )
}

解決策は、データのプリフェッチを<Activity>の外側(親コンポーネントレベル)で行うことです。queryClient.ensureQueryData()<Activity>modeに関係なく実行されるため、非表示のコンポーネントが"visible"に切り替わった際に、キャッシュ済みのデータを即座に利用できます。

このパターンは、TanStack Queryに限らず、エフェクトベースのデータフェッチライブラリ全般に共通する注意点です。

メモリのトレードオフ

Activityはスピードとメモリのトレードオフです。非表示のコンポーネントツリーは完全なDOMとともにメモリ上に保持されます。非表示のルートが多いアプリケーションでは、メモリ使用量を監視することが推奨されます。

useEffectEventとActivityの組み合わせ

2つのAPIを組み合わせることで、リアルタイムなデータストリーミングと状態保持を両立する高度なUIパターンを実装できます。以下は、複数チャンネルのライブフィードをタブ切り替えで管理する例です。

components/LiveDashboard.tsxtsx
import { useEffect, useEffectEvent, useState } from 'react'
import { Activity } from 'react'

function LiveFeed({ channel, userId }: { channel: string; userId: string }) {
  const [messages, setMessages] = useState<Message[]>([])

  // Analytics tracking with latest userId — no effect re-sync
  const onNewMessage = useEffectEvent((msg: Message) => {
    analytics.track('live_message', { channel, userId })
    setMessages((prev) => [...prev, msg])
  })

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/${channel}`)
    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data) as Message
      onNewMessage(msg)
    }
    return () => ws.close()
  }, [channel]) // Clean reconnect only when channel changes

  return <MessageList messages={messages} />
}

export function LiveDashboard({ userId }: { userId: string }) {
  const [activeChannel, setActiveChannel] = useState('general')
  const channels = ['general', 'alerts', 'metrics']

  return (
    <div>
      <nav className="flex gap-2">
        {channels.map((ch) => (
          <button key={ch} onClick={() => setActiveChannel(ch)}>
            {ch}
          </button>
        ))}
      </nav>
      {channels.map((ch) => (
        <Activity key={ch} mode={activeChannel === ch ? 'visible' : 'hidden'}>
          <LiveFeed channel={ch} userId={userId} />
        </Activity>
      ))}
    </div>
  )
}

この実装では、useEffectEventuserIdのstale closure問題を解決し、<Activity>が非アクティブなチャンネルのメッセージ履歴を保持しています。非表示のタブに切り替えた際、WebSocket接続はクリーンアップされますが(エフェクトのクリーンアップ)、messagesステートはメモリ上に保持されます。タブを再びアクティブにすると、保持されたメッセージ履歴が即座に表示され、新しいWebSocket接続が確立されます。

技術面接で問われるポイント: 6つのQ&A

React 19.2の新APIに関する技術面接では、単なるAPI仕様の暗記ではなく、設計思想の理解と実務での適用能力が問われます。以下に、頻出の質問と模範回答を示します。

Q1: useEffectEventはどのような問題を解決するのですか?

useEffectEventは、エフェクト内のstale closure問題を解決します。具体的には、エフェクトが参照するpropsやstateのうち、エフェクトの再実行トリガーにしたくない値(非リアクティブな依存)を安全に読み取る手段を提供します。従来のuseRefワークアラウンドを不要にし、リアクティブなロジックと非リアクティブなロジックの関心を明確に分離します。

Q2: useEffectEventで定義した関数をイベントハンドラとして直接使用できますか?

いいえ、使用できません。useEffectEventで定義された関数は、useEffectの内部からのみ呼び出すことが許可されています。onClickなどのイベントハンドラやレンダリングロジック内での直接使用は、Reactのルールに違反します。また、他のコンポーネントやフックにpropsとして渡すこともできません。

Q3: Activityコンポーネントのmode="hidden"では何が起こりますか?

mode="hidden"に設定されたActivityの子コンポーネントは、CSSのdisplay: noneによりDOMから視覚的に非表示になりますが、コンポーネントの状態(useState、useReducerなど)はメモリ上に保持されます。ただし、useEffectはクリーンアップが実行され、更新は低優先度(アイドル時)に遅延されます。

Q4: ActivityとTanStack Queryを組み合わせる際の注意点は何ですか?

mode="hidden"ではエフェクトがクリーンアップされるため、useQueryによるデータフェッチが実行されません。解決策は、<Activity>の外側でqueryClient.ensureQueryData()を使用してプリフェッチを行うことです。これにより、modeの状態に関係なくデータが取得され、キャッシュに格納されます。

Q5: useEffectEventとuseCallbackの違いは何ですか?

useCallbackはメモ化された関数を返し、依存配列が変化すると関数が再作成されます。依存配列に含まれる値のstale closure問題は解決しません。一方、useEffectEventは常に最新のpropsやstateを読み取る関数を返し、エフェクトの依存配列に影響を与えません。用途も異なり、useCallbackはレンダリング最適化のため、useEffectEventはエフェクト内の非リアクティブロジック定義のために使用します。

Q6: Activityはルーティングのパフォーマンス最適化にどのように活用できますか?

ユーザーが次に遷移する可能性の高いルートをmode="hidden"でプリレンダリングすることで、遷移時に即座にコンテンツを表示できます。<Suspense>use()を組み合わせることで、データフェッチも含めた事前レンダリングが可能です。Next.jsのApp Routerなどのフレームワークレベルでの統合も進んでおり、ルート遷移のユーザー体験を大幅に向上させます。

まとめ

React 19.2で導入されたuseEffectEvent<Activity>は、それぞれ異なる課題を解決するAPIですが、組み合わせることで強力なUIパターンを実現します。

  • useEffectEventは、エフェクト内のstale closure問題を根本的に解決し、リアクティブなロジックと非リアクティブなロジックを明確に分離します。useRefによる従来のワークアラウンドは不要になり、コードの可読性と保守性が向上します。
  • Activityは、コンポーネントの状態を保持しながらバックグラウンドでのプリレンダリングを可能にします。タブ切り替えUIにおけるフォーム状態の保持や、ルート遷移の事前レンダリングなど、実務で頻出するユースケースに対応します。
  • TanStack Queryとの併用では、mode="hidden"時にエフェクトがクリーンアップされる点に注意が必要です。プリフェッチは<Activity>の外側で行うことが推奨されます。
  • 技術面接では、APIの仕様だけでなく、設計思想(関心の分離、リアクティブ vs 非リアクティブ)と実務での適用パターンの理解が問われます。

これらのAPIはReactの副作用管理とUI状態管理における新しい標準となりつつあります。2026年のReact開発において、確実に押さえておくべき知識です。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#react
#react-19
#useEffectEvent
#activity
#hooks
#interview

共有

関連記事