React forwardRefの実務的な使い方|実装コード付き解説

React / Next.js

React forwardRefの実務的な使い方|実装コード付き解説

Reactで開発していると、カスタムコンポーネントからDOM要素に直接アクセスしたいという場面が必ず出てきます。そんな時に活躍するのがforwardRefです。本記事では、教科書的な説明ではなく、実務で本当に使うコードを交えながら解説します。

1. forwardRefの簡易的な解説

Reactでは、デフォルトではrefをpropsとして渡すことができません。これはReactの設計思想に基づいているのですが、実務では「特定のDOM要素にアクセスしたい」という要求は頻繁に出現します。

forwardRefは、その名の通り親コンポーネントから受け取ったrefを子コンポーネント内のDOM要素に「転送」する機能です。

基本的なイメージは以下の通りです:

import { forwardRef } from 'react';

const CustomInput = forwardRef<HTMLInputElement, { placeholder: string }>(
  (props, ref) => {
    return <input ref={ref} placeholder={props.placeholder} />;
  }
);

ここで重要なのは、関数コンポーネントの第2引数としてrefが渡されるということです。通常のpropsではrefは渡されませんが、forwardRefを使うことで可能になります。

2. 業務でのユースケース

forwardRefは「何でも使えばいい」というものではなく、実務では以下のような場面で活躍します。

2.1 フォーム入力値を直接取得したい場合

特に大規模なフォーム処理では、React Hookformなどのライブラリと組み合わせて、カスタム入力コンポーネントのDOM要素にアクセスすることがあります。

2.2 メディア要素の再生制御

動画や音声プレイヤーのカスタムコンポーネントを作成する際、<video><audio>要素のplay()メソッドを親から呼び出す必要があります。

2.3 フォーカス管理

モーダルやドロップダウンのカスタムコンポーネントで、特定のタイミングで要素にフォーカスを当てたい場合に使用します。

2.4 ポップアップやトーストの制御

通知系コンポーネントで、親から「表示する」「非表示にする」といった命令的な操作をしたい場合があります。

3. 実装コード:実務で使える例

では、実際のビジネスロジックに基づいた実装例を見ていきましょう。

3.1 React Hook Formと組み合わせたカスタム入力コンポーネント

ユーザー登録フォームで、バリデーション付きの入力フィールドを作成する例です。

import { forwardRef, InputHTMLAttributes } from 'react';

interface CustomInputProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
  ({ label, error, className = '', ...props }, ref) => {
    return (
      <div className="form-group">
        <label className="form-label">{label}</label>
        <input
          ref={ref}
          className={`form-input ${error ? 'border-red-500' : 'border-gray-300'} ${className}`}
          {...props}
        />
        {error && <span className="text-red-500 text-sm">{error}</span>}
      </div>
    );
  }
);

CustomInput.displayName = 'CustomInput';

export default CustomInput;

このコンポーネントを親から使用する場合:

import { useForm, Controller } from 'react-hook-form';
import CustomInput from './CustomInput';

interface FormData {
  email: string;
  password: string;
}

function LoginForm() {
  const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
    defaultValues: {
      email: '',
      password: ''
    }
  });

  const onSubmit = (data: FormData) => {
    console.log('Form submitted:', data);
    // APIリクエスト処理など
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="email"
        control={control}
        rules={{
          required: 'メールアドレスは必須です',
          pattern: {
            value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
            message: '有効なメールアドレスを入力してください'
          }
        }}
        render={({ field }) => (
          <CustomInput
            {...field}
            label="メールアドレス"
            type="email"
            error={errors.email?.message}
            placeholder="user@example.com"
          />
        )}
      />
      <button type="submit">ログイン</button>
    </form>
  );
}

3.2 動画プレイヤーのカスタムコンポーネント

実務で動画プレイヤーのカスタマイズが必要な場合があります。

import { forwardRef, useImperativeHandle, useRef } from 'react';

interface VideoPlayerHandle {
  play: () => void;
  pause: () => void;
  seek: (time: number) => void;
  getCurrentTime: () => number;
}

interface VideoPlayerProps {
  src: string;
  autoplay?: boolean;
  poster?: string;
}

const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
  ({ src, autoplay = false, poster }, ref) => {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
      play: () => videoRef.current?.play(),
      pause: () => videoRef.current?.pause(),
      seek: (time: number) => {
        if (videoRef.current) {
          videoRef.current.currentTime = time;
        }
      },
      getCurrentTime: () => videoRef.current?.currentTime ?? 0
    }));

    return (
      <video
        ref={videoRef}
        src={src}
        autoPlay={autoplay}
        poster={poster}
        controls
        style={{ width: '100%', maxWidth: '800px' }}
      />
    );
  }
);

VideoPlayer.displayName = 'VideoPlayer';

export default VideoPlayer;

このコンポーネントを使用する親コンポーネント:

import { useRef } from 'react';
import VideoPlayer from './VideoPlayer';

function VideoPlayerPage() {
  const videoRef = useRef<VideoPlayerHandle>(null);

  const handlePlayButtonClick = () => {
    videoRef.current?.play();
  };

  const handleSkip30Seconds = () => {
    const currentTime = videoRef.current?.getCurrentTime() ?? 0;
    videoRef.current?.seek(currentTime + 30);
  };

  return (
    <div>
      <VideoPlayer
        ref={videoRef}
        src="https://example.com/video.mp4"
        poster="https://example.com/poster.jpg"
      />
      <div style={{ marginTop: '20px' }}>
        <button onClick={handlePlayButtonClick}>再生</button>
        <button onClick={() => videoRef.current?.pause()}>一時停止</button>
        <button onClick={handleSkip30Seconds}>30秒スキップ</button>
      </div>
    </div>
  );
}

3.3 トーストメッセージコンポーネント

通知を命令的に表示したい場合の実装:

import { forwardRef, useImperativeHandle, useState } from 'react';

interface ToastHandle {
  show: (message: string, type: 'success' | 'error' | 'info') => void;
  hide: () => void;
}

const Toast = forwardRef<ToastHandle, {}>((_, ref) => {
  const [isVisible, setIsVisible] = useState(false);
  const [message, setMessage] = useState('');
  const [type, setType] = useState<'success' | 'error' | 'info'>('info');

  useImperativeHandle(ref, () => ({
    show: (msg: string, msgType: 'success' | 'error' | 'info') => {
      setMessage(msg);
      setType(msgType);
      setIsVisible(true);
      // 3秒後に自動的に消える
      setTimeout(() => setIsVisible(false), 3000);
    },
    hide: () => setIsVisible(false)
  }));

  if (!isVisible) return null;

  const bgColor = {
    success: 'bg-green-500',
    error: 'bg-red-500',
    info: 'bg-blue-500'
  }[type];

  return (
    <div className={`${bgColor} text-white px-4 py-3 rounded fixed bottom-4 right-4`}>
      {message}
    </div>
  );
});

Toast.displayName = 'Toast';

export default Toast;

使用例:

import { useRef } from 'react';
import Toast from './Toast';

function DataFormPage() {
  const toastRef = useRef<ToastHandle>(null);

  const handleFormSubmit = async () => {
    try {
      // API処理
      await submitForm();
      toastRef.current?.show('データを保存しました', 'success');
    } catch (error) {
      toastRef.current?.show('エラーが発生しました', 'error');
    }
  };

  return (
    <>
      <form onSubmit={(e) => { e.preventDefault(); handleFormSubmit(); }}>
        {/* フォーム内容 */}
        <button type="submit">送信</button>
      </form>
      <Toast ref={toastRef} />
    </>
  );
}

4. よくある応用パターン

4.1 useImperativeHandleとの組み合わせ

forwardRefでDOM要素そのものではなく、カスタムメソッドを公開したい場合は、useImperativeHandleと組み合わせます。前述の動画プレイヤーの例がこれに該当します。

4.2 複数のrefを転送する

複数のDOM要素を親に公開したい場合:

interface MultiElementHandle {
  inputRef: HTMLInputElement | null;
  buttonRef: HTMLButtonElement | null;
}

const MultiElementComponent = forwardRef<MultiElementHandle, {}>((_, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

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

  return (
    <>
      <input ref={inputRef} />
      <button ref={buttonRef}>Click</button>
    </>
  );
});

4.3 条件付きでrefを転送する

特定の条件でのみrefの転送をしたい場合:

const ConditionalRefComponent = forwardRef<HTMLDivElement, { shouldForwardRef: boolean }>(
  ({ shouldForwardRef }, ref) => {
    const internalRef = useRef<HTMLDivElement>(null);

    return (
      <div ref={shouldForwardRef ? ref : internalRef}>
        Content
      </div>
    );
  }
);

5. 実務での注意点

5.1 displayNameを必ず設定する

forwardRefでラップされたコンポーネントは、Reactのデベロッパーツールで「Anonymous」と表示されてしまいます。デバッグのしやすさのため、必ずdisplayNameを設定してください。

const MyComponent = forwardRef((props, ref) => {
  return <div ref={ref}>Content</div>;
});

MyComponent.displayName = 'MyComponent'; // これが必須

5.2 ref は通常のpropsではない

親コンポーネントからrefを渡す際、通常のpropsとしてアクセスすることはできません。forwardRefでラップされたコンポーネントのみが、refを受け取れます。

5.3 過度な使用を避ける

forwardRefは便利ですが、責務が曖昧になるため、むやみに使いすぎるべきではありません。まずはpropsやコールバックで解決できないか検討してください。

5.4 TypeScriptでの型安全性

TypeScriptを使用している場合、refの型を正確に指定することで、不正なメソッドへのアクセスを事前に防げます。

// ❌ 悪い例:型を指定していない
const Component = forwardRef((props, ref: any) => {});

// ✅ 良い例:型を明確に指定
const Component = forwardRef<HTMLInputElement, Props>((props, ref) => {});

5.5 メモリリークの防止

useImperativeHandleでタイマーやイベントリスナーを設定する場合、クリーンアップ関数を必ず実装してください:

useImperativeHandle(ref, () => ({
  startTimer: () => {
    const timerId = setInterval(() => {
      // 何か処理
    }, 1000);
    return () => clearInterval(timerId);
  }
}));

6. TypeScript を使った型定義のベストプラクティス

大規模プロジェクトでは、ref の型を再利用できるようにしておくと便利です:

// types/components.ts
export interface CustomInputHandle extends HTMLInputElement {}

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

export interface ModalHandle {
  open: (content: React.ReactNode) => void;
  close: () => void;
  isOpen: () => boolean;
}

// components/VideoPlayer.tsx
import { VideoPlayerHandle } from '../types/components';

const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>((props, ref) => {
  // 実装
});

7. まとめ

React の forwardRef は、カスタムコンポーネントから DOM 要素に直接アクセスが必要な場合に非常に有効なツールです。本記事で解説した実務的なユースケースは以下の通りです:

  • フォーム入力管理:React Hook Form との組み合わせで、バリデーション付き入力フィールドを構築
  • メディア制御:動画・音声プレイヤーの再生、停止、シーク機能を親から制御
  • フォーカス管理:特定のタイミングで要素にフォーカスを当てる
  • 命令的 UI 制御:トーストメッセージやモーダルなど、親から制御したいコンポーネント

実装する際の重要なポイントは:

  • TypeScript で型を正確に指定し、型安全性を確保する
  • displayName を必ず設定してデバッグしやすくする
  • useImperativeHandle でカスタムメソッドを公開し、実装の詳細を隠蔽する
  • 過度に使用しすぎず、必要な場面に限定して利用する
  • メモリリークやクリーンアップを正しく実装する

forwardRef は「最後の手段」という位置付けで使うのが大原則です。まずはコンポーネント間の通常の props やコールバック、Context API などで解決できないか検討し、どうしても必要な場合に限って使用することが、保守性の高いコードにつながります。

タイトルとURLをコピーしました