株式会社HRBrain
🪝

【React】【そして玄人になる】「マイナーな」Hooksたち

に公開

useStateuseEffect と聞いてもみなさん鼻を鳴らすだけでしょう。
しかし、ドキュメントの少し奥にある useIduseDeferredValue などのマイナー Hooksたちならどうでしょうか?
玄人[1]たるもの、これらを知らずして日々のReactを語ることはできません。
Major Versionが19になってからというもの、私もこれらHooksは見聞きするばかりでしっかりと理解していませんでしたが、この記事執筆によってさきっちょくらいはわかるようになりました。

さあ、これであなたも玄人です。

「Hooksたち」と銘打っておいて恐縮ですが、完全に私の独断と偏見で「マイナーな」コンポーネント、API、Hooksを紹介、解説しています。

今日ご紹介したHooksやAPIなどは実際に以下のリポジトリで動きますのでぜひみなさん試してみてください。
Webページ自体も近日公開予定です!

https://github.com/Ahoxa/ahoxa-play-ground/tree/main/front


1. Suspense

Suspense では、uselazy、その他データフェッチライブラリ(SWR, TanStack Query)と組み合わせて、「読み込み中のUI」の管理を宣言的に行うことができます。
めっちゃ便利じゃん!と使ってみても思い通りに行かないコンポーネント代表ですね。

どんな時に使う?

  • コンポーネントが必要なデータやコードを読み込んでいる最中に、ローディング(スピナーやスケルトン)を表示したい時。
  • 非同期処理の状態管理(isLoading...)を各コンポーネントから排除したい時。
段階的なローディング.tsx
import { Suspense } from 'react';

function UserProfilePage() {
  return (
    <div className="layout">
      <h1>ユーザープロフィール</h1>

      {/* 基本情報の読み込み待機 */}
      <Suspense fallback={<SkeletonHeader />}>
        <UserDetails />

        {/* タイムラインの読み込み待機(詳細はさらに時間がかかる想定) */}
        <Suspense fallback={<SkeletonTimeline />}>
          <UserTimeline />
        </Suspense>
      </Suspense>
    </div>
  );
}

2. cache (React 19 / Server)

主にServer Componentsで使用されるAPIです。
同じリクエスト内で、同じ引数で関数が呼ばれた場合、重複してデータ取得(fetchなど)を行わず、キャッシュされた値を返します。
これにより「Propsのバケツリレー」を減らすことができます。
ちょっと使い所が難しいかもしれませんが、さらっと触れてみてください。

cache をどんな時に使う?

  • 現在のユーザー情報など、複数のコンポーネント(ヘッダー、サイドバー、メイン)で同じデータが必要な時。
DBアクセスの重複排除.tsx
import { cache } from 'react';
// DB接続ライブラリの仮定
import { db } from './db';

// この関数を Layout と Page の両方で呼んでも、クエリは1回しか発行されない
export const getUser = cache(async (id: string) => {
  console.log('Fetching user...');
  return await db.user.findUnique({ where: { id } });
});

// Layout.tsx
export default async function Layout({
  children
}: {
  children: React.ReactNode
}) {
  const user = await getUser('123'); // 1回目の呼び出し(実行される)
  return <section>User: {user?.name}{children}</section>;
}

// Page.tsx
export default async function Page() {
  const user = await getUser('123'); // 2回目の呼び出し(キャッシュが返る)
  return <h1>Welcome, {user?.name}</h1>;
}

3. useActionState (React 19〜)

フォーム送信やServer Actionsの実行状態(保留中、成功時の値、エラー)を管理するためのHookです。以前は useFormState と呼ばれていました。

useFormState との違い

useFormState はReact Canaryで提供されていたAPIですが、React 19で useActionState にリネームされました。
大きな変更点として、返り値のタプルに isPending(保留中フラグ)が追加されました。
以前は useFormStatus を使って子コンポーネントでpending状態を取得する必要がありましたが、useActionState では同じコンポーネント内で完結できるようになりました。

  • useFormState: [state, formAction] = useFormState(fn, initialState)
  • useActionState: [state, formAction, isPending] = useActionState(fn, initialState)

useActionState をどんな時に使う?

  • フォーム送信の結果(バリデーションエラーなど)を画面に表示したい時。
  • 送信中のローディング状態を管理したい時。
カウンターのインクリメント.tsx
import { useActionState } from 'react';

// Server Action (本来は別ファイルや "use server" ブロックに記述)
async function increment(previousState: number, formData: FormData) {
  return previousState + 1;
}

function Counter() {
  // [現在の状態, アクション実行関数, 保留中フラグ]
  const [state, formAction, isPending] = useActionState(increment, 0);

  return (
    <form action={formAction}>
      <p>Count: {state}</p>
      <button type="submit" disabled={isPending}>
        {isPending ? '更新中...' : '増やす'}
      </button>
    </form>
  );
}

4. Activity (React 19.2〜)

React 19.2で正式に追加されたコンポーネントです(以前はOffscreenと呼ばれていました)。
通常、コンポーネントは非表示(アンマウント)になるとStateが消えますが、Activity を使うと、画面からは見えなくなってもStateやDOMをメモリ上に保持し続けます。

Activity をどんな時に使う?

  • タブ切り替えで、裏に回ったタブのスクロール位置や入力中のフォーム内容を維持したい時。
  • 表示・非表示の切り替えが頻繁で、再レンダリングのコストを抑えたい時。
Stateを維持するタブ.tsx
import { Activity, useState } from 'react';

function App() {
  const [currentTab, setCurrentTab] = useState('tab1');

  return (
    <div>
      <button onClick={() => setCurrentTab('tab1')}>Tab 1</button>
      <button onClick={() => setCurrentTab('tab2')}>Tab 2</button>

      {/* CSSで display: none にするのではなく、Reactが管理する非表示モード */}
      <Activity mode={currentTab === 'tab1' ? 'visible' : 'hidden'}>
        <Counter name="Counter A" />
      </Activity>

      <Activity mode={currentTab === 'tab2' ? 'visible' : 'hidden'}>
        <Counter name="Counter B" />
      </Activity>
    </div>
  );
}

5. useOptimistic (React 19〜)

useActionState と組み合わせて、サーバーへの送信完了を待たずに、成功したと仮定して即座に画面を切り替え、後で結果と同期します。

useOptimistic をどんな時に使う?

  • サーバーからの応答を待たずに、UIを即座に更新してユーザー体験を向上させたい時(楽観的更新)。
  • メッセージ送信やステータス変更など、成功する可能性が高いアクションの結果を先行して表示したい時。
import { useOptimistic } from "react";

type Message = { text: string; sending?: boolean };

function MessageList({
  messages,
  sendMessageAction,
}: {
  messages: Message[];
  sendMessageAction: (msg: string) => void;
}) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic<
    Message[],
    Message
  >(messages, (state, newMessage) => [...state, newMessage]);

  async function formAction(formData: FormData) {
    const message = formData.get("message") as string;
    // 即座にUI更新(楽観的更新)
    addOptimisticMessage({ text: message, sending: true });
    // サーバー処理
    await sendMessageAction(message);
  }

  return (
    <div>
      {optimisticMessages.map((m, i) => (
        <div key={i} style={{ opacity: m.sending ? 0.5 : 1 }}>
          {m.text}
        </div>
      ))}
      <form action={formAction}>
        <input name="message" />
        <button>送信</button>
      </form>
    </div>
  );
}

6. useTransition / startTransition

状態更新を「急ぎではない(Transition)」としてマークします。
これにより、重いレンダリング処理が走っても、クリックや入力などのユーザー操作(優先度:高)がブロックされなくなります。useTransition は保留中のフラグも提供します。

useTransition をどんな時に使う?

  • タブ切り替えや画面遷移(クリックへの反応は即座に行い、中身の描画は遅れてもいい場合)。
  • フィルタリングなどの重い再計算。
タブ切り替え.tsx
import { useState, useTransition } from 'react';
import TabButton from './TabButton';
import HeavyTabContent from './HeavyTabContent';

function TabContainer() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  function selectTab(nextTab: string) {
    // 状態更新をTransitionでラップする
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <div>
      <div className="tabs">
        <TabButton onClick={() => selectTab('home')}>Home</TabButton>
        <TabButton onClick={() => selectTab('posts')}>Posts</TabButton>
      </div>

      {/* isPending中は古いUIを表示しつつ、ローディング表示などを重ねることも可能 */}
      {isPending && <p>Loading tab...</p>}

      <HeavyTabContent tab={tab} />
    </div>
  );
}

7. useDeferredValue

useTransitionが「状態更新の処理」をラップするのに対し、useDeferredValue は「値そのもの」をラップして、その値に基づいた再レンダリングの優先度を下げます。

useDeferredValue をどんな時に使う?

  • 検索ボックス入力時のリストフィルタリング(debounceの代わり)。
  • コード例:重いリストの検索
リスト検索.tsx
import { useState, useDeferredValue } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  // queryの「遅れて更新されるバージョン」を作成
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      {/* inputは即座に更新される */}
      <input value={query} onChange={e => setQuery(e.target.value)} />

      {/* リストは deferredQuery が変わるまで古いまま(Stale)でもよい */}
      <div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
        <SlowList text={deferredQuery} />
      </div>
    </div>
  );
}

8. useId

フォーム要素とそのラベルを紐付ける際、id属性を使います。
しかし、コンポーネントが再利用される場合、固定のID文字列を書くと重複してしまいます。かといって Math.random() などを使うと、SSRとクライアント側で生成されるIDが異なり、ハイドレーションエラーが発生します。

useId をどんな時に使う?

  • アクセシビリティ対応(aria-describedbyやhtmlFor)を正しく行いたい時。
  • Next.jsなどのSSR環境で、一意なIDが必要な時。
import { useId } from "react";

function PasswordField() {
  const passwordHintId = useId();

  return (
    <div className="flex flex-col gap-2">
      <label>
        パスワード:
        <input type="password" aria-describedby={passwordHintId} />
      </label>
      {/* 生成されたIDを紐付けに使用 */}
      <p id={passwordHintId}>パスワードは8文字以上で入力してください。</p>
    </div>
  );
}

9. useSyncExternalStore

最近少しづつ名前を聞く機会が増えてきたように感じるHooksですね。
Reactの状態管理外にあるデータ(ブラウザのAPI、localStorage、ZustandやReduxなどの外部ストア)を購読する際、Tearing(並行レンダリングによる表示の不整合)を防ぎます。

useSyncExternalStore をどんな時に使う?

  • window.innerWidthやnavigator.onLineなどのブラウザAPIをリアクティブに使いたい時。
  • 外部ストアと連携するカスタムフックを作る時。
ネットワーク接続状態の監視.tsx
import { useSyncExternalStore } from 'react';

// 1. スナップショット(現在の値)を取得する関数
function getSnapshot(): boolean {
  return navigator.onLine;
}

// 2. 購読するための関数(変更があったらcallbackを呼ぶ)
function subscribe(callback: () => void): () => void {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function NetworkStatus() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  return <div>{isOnline ? '🟢 オンライン' : '🔴 オフライン'}</div>;
}

10. useImperativeHandle

Reactの基本は「Propsによるデータフロー(親から子へ)」ですが、稀に親コンポーネントから子コンポーネントの関数を直接呼び出したいケースがあります。
useImperativeHandle は、forwardRefと組み合わせて、親に公開する値をカスタマイズできます。

useImperativeHandle をどんな時に使う?

  • 動画プレーヤーの play() / pause() メソッドを親から叩きたい時。
  • モーダルの open() / close() メソッドをライブラリとして提供したい時。
外部から操作できる動画プレーヤー
import { useImperativeHandle, useRef, forwardRef } from 'react';

// 親に公開したいメソッドの型定義
export interface VideoPlayerHandle {
  play: () => void;
  pause: () => void;
}

type VideoPlayerProps = {
  src: string;
};

// forwardRef<公開するHandleの型, Propsの型>
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
  (props, ref) => {
  const videoRef = useRef<HTMLVideoElement>(null);

  // 親コンポーネントが受け取る ref の中身を定義
  useImperativeHandle(ref, () => ({
    play() {
      videoRef.current?.play();
    },
    pause() {
      videoRef.current?.pause();
    }
  }));

  return <video ref={videoRef} src={props.src} width="300" />;
});

// 親コンポーネントでの使用
function App() {
  const playerRef = useRef<VideoPlayerHandle>(null);

  return (
    <>
      <VideoPlayer ref={playerRef} src="movie.mp4" />
      <button onClick={() => playerRef.current?.play()}>再生</button>
      <button onClick={() => playerRef.current?.pause()}>停止</button>
    </>
  );
}

まとめ

意外と知らないHooksが盛りだくさんだったのではないでしょうか?

これらの機能は、一見複雑に見えるかもしれません。
しかし適切に使うことでコードが宣言的になり、バグが減り、ユーザー体験が向上します。
ぜひあなたのプロジェクトで、習うより慣れよ!の精神でぜひ触ってみてください。

あなたのReact Lifeに光あれ!

脚注
  1. 玄人。https://dic.nicovideo.jp/a/ふぅ(実況プレイヤー) ↩︎

株式会社HRBrain
株式会社HRBrain

Discussion