⚛️

【React 19】useImperativeHandleが格段に使いやすくなった!子コンポーネントの機能を「公開」する方法

に公開

なぜ useImperativeHandle が必要になったのか?

実体験:フォームビルダー開発での課題

先日、ドラッグ&ドロップでコンポーネントを配置するフォームビルダーを Next.js で開発していました。その際に直面した課題が、複雑な状態管理でした。

最初の実装:state のバケツリレー地獄

最初は従来通り、state のバケツリレーでの実装を考えていました:

// 親コンポーネントで全ての状態を管理
const [formLayout, setFormLayout] = useState({});
const [saveStatus, setSaveStatus] = useState("idle");
const [tempSaveData, setTempSaveData] = useState({});

// 深い階層の子コンポーネントまでpropsでバケツリレー
<FormBuilder
  formLayout={formLayout}
  onLayoutChange={setFormLayout}
  saveStatus={saveStatus}
  onSave={handleSave}
  tempSaveData={tempSaveData}
  onTempSave={handleTempSave}
  // さらに多くのpropsが必要...
>
  <LayoutRenderer
    layout={formLayout}
    onLayoutChange={setFormLayout}
    // 同じpropsを再度渡す必要...
  >
    <ComponentPalette
      onSave={handleSave}
      onTempSave={handleTempSave}
      // またも同じpropsを...
    />
  </LayoutRenderer>
</FormBuilder>;

問題点:可読性とメンテナンス性の悪化

この方法では以下の問題がありました:

  • フォームレイアウトの状態管理が複数の階層を跨いでバケツリレー
  • 保存機能一時保存機能レイアウト反映機能の状態がすべて親で管理される
  • 深い階層のコンポーネントまで同じ props を何度も渡す必要
  • コードの可読性が著しく低下し、メンテナンスが困難に

解決策の模索:「Store のようにどこでも関数を実行したい」

そこで「Store のようにどこでも関数を実行できる方法はないか?」と検索していたところ、useImperativeHandleを発見しました。

最初の懸念:本当に使って良いのか?

ただし、最初は非常に懐疑的でした。理由は以下の通りです:

  1. React の宣言的パラダイムに反するのではないか?

    • React は「データが上から下に流れる」という原則を重視している
    • useImperativeHandleは明らかに命令的なアプローチ
  2. React 公式ドキュメントの記載

    • ref は「避難ハッチ(escape hatch)」として使用するよう記載されている
    • 「通常の React のデータフローの外で何かを行う必要がある場合の最後の手段」という位置づけ
    • 積極的に推奨されているわけではない

しかし、props と state だけでは解決できない複雑な状態管理の問題がありました。やりたいことを実現するためには必要だと判断し、試してみることにしました。

結果:期待以上の簡素な実装

実際に使ってみると、バケツリレーよりもはるかに簡単で、煩雑さもなく実装できました。
場合にもよるけど、とても有用だと感じました!


React 19 で useImperativeHandle が劇的に簡単になった!

そして今回、React 19 でuseImperativeHandleの使い方が大幅に簡素化されました。これまで React 18 ではforwardRefという複雑な概念を理解する必要がありましたが、React 19 では通常の props と同じようにrefを扱えるようになり、格段に使いやすくなりました

useImperativeHandleは、親コンポーネントから子コンポーネントの内部機能にアクセスできるようにする Hook です。通常の React の「上から下へのデータフロー」とは逆方向の操作を可能にします。

基本的な構文

useImperativeHandle(ref, createHandle, [deps]);
  • ref: 親から渡されるrefオブジェクト
  • createHandle: 公開したい機能を返す関数
  • deps: 依存配列(省略可能)

React 19 での劇的な変化:ref as prop

React 18 以前の複雑さ

React 18 では、関数コンポーネントで ref を受け取るためにforwardRefという特別な関数でラップする必要がありました:

// React 18での複雑な書き方
import { forwardRef, useImperativeHandle, useRef } from "react";

const CustomInput = forwardRef<InputHandle, {}>((props, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
  }));

  return <input ref={inputRef} type="text" />;
});

React 19 での簡潔な書き方

React 19 では、refが通常の props として扱えるため、forwardRefが不要になりました:

// React 19でのシンプルな書き方
import { useImperativeHandle, useRef } from "react";

interface CustomInputProps {
  ref?: React.Ref<InputHandle>;
}

const CustomInput = ({ ref }: CustomInputProps) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
  }));

  return <input ref={inputRef} type="text" />;
};

これだけでも、学習コストが大幅に下がりました!

useImperativeHandle で公開すると何が嬉しいの?

1. コンポーネントの再利用性が向上する

通常の props では実現が困難な、より柔軟なコンポーネント操作が可能になります。

// 悪い例:propsで全ての操作を制御しようとする場合
<CustomInput
  shouldFocus={shouldFocus}
  shouldClear={shouldClear}
  onValueRequest={handleValueRequest}
/>

// 良い例:useImperativeHandleを使用
<CustomInput ref={inputRef} />
// 必要な時に直接操作
inputRef.current?.focus();

2. 複雑な状態管理を避けられる

親コンポーネントで複雑な状態を管理する必要がなくなり、コードがシンプルになります。

// 従来の方法では親で状態管理が必要
const [shouldShowModal, setShouldShowModal] = useState(false);
const [modalContent, setModalContent] = useState("");

// useImperativeHandleを使えば直接操作
modalRef.current?.show("エラーが発生しました");
modalRef.current?.hide();

3. 命令的な操作が自然に表現できる

フォーカス、スクロール、アニメーションなど、本質的に命令的な操作を自然に表現できます。

// フォーカス制御の例
const handleSubmit = () => {
  if (!isValid) {
    errorInputRef.current?.focus(); // エラーがある入力欄にフォーカス
    errorInputRef.current?.highlight(); // 入力欄をハイライト
  }
};

4. 第三者ライブラリとの統合が簡単

外部ライブラリの API を直接呼び出すような操作を、React コンポーネントから簡単に実行できます。

// 地図ライブラリの例
interface MapComponentProps {
  ref?: React.Ref<MapHandle>;
}

const MapComponent = ({ ref }: MapComponentProps) => {
  const mapInstance = useRef<MapAPI>(null);

  useImperativeHandle(ref, () => ({
    zoomTo: (level: number) => mapInstance.current?.setZoom(level),
    panTo: (lat: number, lng: number) => mapInstance.current?.panTo(lat, lng),
    addMarker: (marker: Marker) => mapInstance.current?.addMarker(marker),
  }));

  // 使用側
  // mapRef.current?.zoomTo(15);
  // mapRef.current?.panTo(35.6762, 139.6503);
};

5. パフォーマンスの向上

不要な再レンダリングを避けることができます。

// propsで制御する場合、親の状態変更で子が再レンダリング
const [triggerAction, setTriggerAction] = useState(0);

// useImperativeHandleなら再レンダリングなしで操作可能
const handleAction = () => {
  childRef.current?.performAction(); // 再レンダリングなし
};

実践例:入力フィールドコンポーネント

コンポーネント定義

import { useImperativeHandle, useRef } from "react";

// 公開したい機能の型定義
export interface InputHandle {
  focus: () => void;
  clear: () => void;
  getValue: () => string;
  setValue: (value: string) => void;
}

interface CustomInputProps {
  ref?: React.Ref<InputHandle>;
  placeholder?: string;
}

const CustomInput = ({ ref, placeholder }: CustomInputProps) => {
  const inputRef = useRef<HTMLInputElement>(null);

  // 親コンポーネントに公開する機能を定義
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current?.focus();
    },
    clear: () => {
      if (inputRef.current) {
        inputRef.current.value = "";
      }
    },
    getValue: () => {
      return inputRef.current?.value || "";
    },
    setValue: (value: string) => {
      if (inputRef.current) {
        inputRef.current.value = value;
      }
    },
  }));

  return (
    <input
      ref={inputRef}
      type="text"
      placeholder={placeholder}
      className="border p-2 rounded"
    />
  );
};

export default CustomInput;

親コンポーネントでの使用

import { useRef } from "react";
import CustomInput, { InputHandle } from "./CustomInput";

const ParentComponent = () => {
  const inputRef = useRef<InputHandle>(null);

  const handleFocus = () => {
    inputRef.current?.focus();
  };

  const handleClear = () => {
    inputRef.current?.clear();
  };

  const handleGetValue = () => {
    const value = inputRef.current?.getValue();
    console.log("現在の値:", value);
  };

  const handleSetValue = () => {
    inputRef.current?.setValue("プリセット値");
  };

  return (
    <div className="space-y-4">
      <CustomInput ref={inputRef} placeholder="何か入力してください" />
      <div className="space-x-2">
        <button
          onClick={handleFocus}
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          フォーカス
        </button>
        <button
          onClick={handleClear}
          className="bg-red-500 text-white px-4 py-2 rounded"
        >
          クリア
        </button>
        <button
          onClick={handleGetValue}
          className="bg-green-500 text-white px-4 py-2 rounded"
        >
          値を取得
        </button>
        <button
          onClick={handleSetValue}
          className="bg-purple-500 text-white px-4 py-2 rounded"
        >
          値をセット
        </button>
      </div>
    </div>
  );
};

実践例:動画プレーヤーコンポーネント

import { useImperativeHandle, useRef } from "react";

export interface VideoPlayerHandle {
  play: () => void;
  pause: () => void;
  setCurrentTime: (time: number) => void;
  getCurrentTime: () => number;
  getDuration: () => number;
  setVolume: (volume: number) => void;
}

interface VideoPlayerProps {
  ref?: React.Ref<VideoPlayerHandle>;
  src: string;
}

const VideoPlayer = ({ ref, src }: VideoPlayerProps) => {
  const videoRef = useRef<HTMLVideoElement>(null);

  useImperativeHandle(ref, () => ({
    play: () => {
      videoRef.current?.play();
    },
    pause: () => {
      videoRef.current?.pause();
    },
    setCurrentTime: (time: number) => {
      if (videoRef.current) {
        videoRef.current.currentTime = time;
      }
    },
    getCurrentTime: () => {
      return videoRef.current?.currentTime || 0;
    },
    getDuration: () => {
      return videoRef.current?.duration || 0;
    },
    setVolume: (volume: number) => {
      if (videoRef.current) {
        videoRef.current.volume = Math.max(0, Math.min(1, volume));
      }
    },
  }));

  return (
    <video
      ref={videoRef}
      src={src}
      controls={false}
      className="w-full h-auto"
    />
  );
};

export default VideoPlayer;

主な使用用途

1. フォーム操作

  • 入力フィールドのフォーカス制御
  • バリデーション結果の取得
  • フォームデータのクリア

2. メディア制御

  • 動画や音声の再生・停止
  • シークバーの制御

3. アニメーション制御

  • アニメーションの開始・停止
  • 特定のタイミングでのアニメーション実行

4. 複雑なコンポーネントの操作

  • モーダルの開閉
  • ドロワーメニューの制御
  • カスタムコンポーネントの内部状態操作

使用時の注意点

1. 過度な使用は避ける

useImperativeHandleは便利ですが、React の宣言的なパラダイムに反する場合があります。通常の props と state 管理で解決できる場合は、そちらを優先しましょう。

2. TypeScript での型安全性

公開する機能の型を明確に定義することで、型安全性を保つことができます。

3. React 18 からの移行

React 18 から移行する場合は、forwardRefを削除して、ref を props として受け取るように変更するだけです。

// React 18 → React 19への移行例
// Before (React 18)
const Component = forwardRef<Handle, Props>((props, ref) => { ... });

// After (React 19)
interface ComponentProps extends Props {
  ref?: React.Ref<Handle>;
}
const Component = ({ ref, ...props }: ComponentProps) => { ... };

なぜ今まで浸透しなかったのか?

  1. forwardRef の複雑さ: React 18 以前ではforwardRefの理解が必要で、学習コストが高かった
  2. React の哲学との矛盾: 宣言的 UI の原則に反する命令的アプローチ
  3. ドキュメントでの扱い: 「escape hatch」として紹介され、積極的に推奨されていなかった

React 19 のref as propにより、これらの障壁が大幅に低くなりました!

まとめ

React 19 でuseImperativeHandleは格段に使いやすくなりました:

  • React 19: forwardRef不要でシンプルな実装が可能
  • React 18: forwardRefが必須で複雑だった
  • 「公開する」: コンポーネント内部の機能を外部から呼び出せるようにすること
  • 活用場面: フォーム操作、メディア制御、アニメーション制御など
  • 注意点: 過度な使用は避け、適切な場面で使用することが重要

React 19 の新機能により、これまで敬遠されがちだったuseImperativeHandleが、より身近で実用的なツールになりました。適切に活用して、より柔軟で再利用可能なコンポーネントを作成しましょう!

GitHubで編集を提案
うぐいすソリューションズTechBlog

Discussion