React forwardRefの使い方|コンポーネント間の参照を理解する

React forwardRefの基本から応用まで詳しく解説。ref の転送、カスタムフック、TypeScript対応など実践的な使い方を具体例とともに紹介します。

Learning Next 運営
48 分で読めます

みなさん、Reactで開発していてこんな悩みを抱えたことはありませんか?

「カスタムコンポーネントにrefを渡したけど動かない」
「子コンポーネントの要素に直接アクセスしたい」
「forwardRefって何?どう使うの?」

カスタムコンポーネントでのref転送は、Reactの中でも少し複雑な部分です。 でも理解できれば、より柔軟で使いやすいコンポーネントが作れるようになります。

この記事では、React forwardRefの基本から実践的な使い方まで詳しく解説します。 最初は難しく感じるかもしれませんが、一緒に学んでいきましょう。

forwardRefって何?基本を理解しよう

まずは、なぜforwardRefが必要なのかを理解しましょう。 普通のrefと何が違うのかがわかると、使い方もすっきりと理解できますよ。

普通のrefの使い方

最初に、通常のrefの使い方を確認してみましょう。

import React, { useRef, useEffect } from 'react';

function BasicExample() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    // ページ読み込み時にinputにフォーカス
    inputRef.current.focus();
  }, []);
  
  const handleClick = () => {
    // ボタンクリック時に入力をクリア
    inputRef.current.value = '';
    inputRef.current.focus();
  };
  
  return (
    <div>
      <input ref={inputRef} placeholder="ここに入力してください" />
      <button onClick={handleClick}>クリア</button>
    </div>
  );
}

このようにHTML要素に直接refを使うのは、とても簡単ですね。 inputRef.currentで要素にアクセスできます。

カスタムコンポーネントでの問題

ところが、カスタムコンポーネントにrefを渡そうとすると問題が発生します。

// ❌ この方法は動きません
function CustomInput({ placeholder }) {
  return <input placeholder={placeholder} />;
}

function App() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    // エラー:inputRef.current は null
    inputRef.current.focus(); // TypeError!
  }, []);
  
  return (
    <div>
      {/* refがカスタムコンポーネントには渡されない */}
      <CustomInput ref={inputRef} placeholder="カスタム入力" />
    </div>
  );
}

このコードを実行すると、エラーが発生します。 なぜなら、カスタムコンポーネントは通常のpropsとしてrefを受け取れないからです。

forwardRefで解決!

forwardRefを使うことで、この問題を解決できます。

import React, { forwardRef, useRef, useEffect } from 'react';

// ✅ forwardRef を使ってref転送
const CustomInput = forwardRef((props, ref) => {
  return <input ref={ref} {...props} />;
});

function App() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    // 正常に動作:input要素にアクセス可能
    inputRef.current.focus();
  }, []);
  
  return (
    <div>
      <CustomInput ref={inputRef} placeholder="カスタム入力" />
    </div>
  );
}

forwardRefでコンポーネントをラップすることで、refを正しく転送できます。 これで、親コンポーネントからカスタムコンポーネント内の要素にアクセスできるようになりました。

forwardRefの基本的な仕組み

forwardRefの構造を詳しく見てみましょう。

// forwardRef の基本構造
const MyComponent = forwardRef((props, ref) => {
  // props: 通常のprops
  // ref: 親から渡されたref
  
  return <div ref={ref}>コンテンツ</div>;
});

// 使用例
function ParentComponent() {
  const myRef = useRef(null);
  
  return <MyComponent ref={myRef} />;
}

forwardRefは第1引数にprops、第2引数にrefを受け取ります。 この順番は決まっているので、間違えないようにしましょう。

実用的なforwardRefの使い方

基本がわかったところで、実際のプロジェクトで使えるパターンを見てみましょう。 どれも実用的で、覚えておくと開発が楽になりますよ。

高機能な入力コンポーネント

まずは、よく使われる入力コンポーネントを作ってみましょう。

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

// 高機能な入力コンポーネント
const CustomInput = forwardRef(({ 
  label, 
  error, 
  helperText, 
  required = false,
  ...props 
}, ref) => {
  const [isFocused, setIsFocused] = useState(false);
  
  return (
    <div className={`form-group ${error ? 'error' : ''} ${isFocused ? 'focused' : ''}`}>
      {label && (
        <label className="form-label">
          {label}
          {required && <span className="required">*</span>}
        </label>
      )}
      
      <input
        ref={ref}
        className="form-input"
        onFocus={() => setIsFocused(true)}
        onBlur={() => setIsFocused(false)}
        {...props}
      />
      
      {error && <div className="error-message">{error}</div>}
      {helperText && !error && (
        <div className="helper-text">{helperText}</div>
      )}
    </div>
  );
});

このコンポーネントには、以下の機能が含まれています。

  • ラベル表示: labelプロップで見出しを設定
  • エラー表示: errorプロップでエラーメッセージを表示
  • ヘルプテキスト: helperTextで説明文を表示
  • フォーカス状態: フォーカス時にスタイルが変わる

次に、このコンポーネントを使ったフォームを作ってみましょう。

function ContactForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const messageRef = useRef(null);
  
  const [errors, setErrors] = useState({});
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    const formData = {
      name: nameRef.current.value,
      email: emailRef.current.value,
      message: messageRef.current.value
    };
    
    // バリデーション
    const newErrors = {};
    if (!formData.name.trim()) {
      newErrors.name = '名前は必須です';
      nameRef.current.focus(); // エラーフィールドにフォーカス
      return;
    }
    
    if (!formData.email.trim()) {
      newErrors.email = 'メールアドレスは必須です';
      emailRef.current.focus();
      return;
    }
    
    // フォーム送信処理
    console.log('送信データ:', formData);
    
    // フォームリセット
    nameRef.current.value = '';
    emailRef.current.value = '';
    messageRef.current.value = '';
    nameRef.current.focus();
  };
  
  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <h2>お問い合わせ</h2>
      
      <CustomInput
        ref={nameRef}
        label="お名前"
        placeholder="山田太郎"
        required
        error={errors.name}
      />
      
      <CustomInput
        ref={emailRef}
        label="メールアドレス"
        type="email"
        placeholder="example@email.com"
        required
        error={errors.email}
        helperText="返信用のメールアドレスを入力してください"
      />
      
      <CustomInput
        ref={messageRef}
        label="メッセージ"
        as="textarea"
        rows={5}
        placeholder="お問い合わせ内容をご記入ください"
        required
      />
      
      <button type="submit" className="btn btn-primary">
        送信
      </button>
    </form>
  );
}

forwardRefを使うことで、以下のことができるようになりました。

  • 直接的な値取得: refを使って入力値を直接取得
  • フォーカス制御: エラー時に該当フィールドにフォーカス
  • フォームリセット: 送信後に全フィールドをクリア

モーダルコンポーネント

次に、外部から操作できるモーダルコンポーネントを作ってみましょう。

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

// モーダルコンポーネント
const Modal = forwardRef(({ 
  title, 
  onClose, 
  children 
}, ref) => {
  const [isOpen, setIsOpen] = useState(false);
  
  // 親コンポーネントから呼び出せるメソッドを定義
  useImperativeHandle(ref, () => ({
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
    toggle: () => setIsOpen(prev => !prev),
    isOpen: () => isOpen
  }));
  
  // ESCキーでモーダルを閉じる
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === 'Escape' && isOpen) {
        setIsOpen(false);
        onClose?.();
      }
    };
    
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);
  
  if (!isOpen) return null;
  
  const handleBackdropClick = (e) => {
    if (e.target === e.currentTarget) {
      setIsOpen(false);
      onClose?.();
    }
  };
  
  return (
    <div className="modal-backdrop" onClick={handleBackdropClick}>
      <div className="modal-content">
        <div className="modal-header">
          <h2>{title}</h2>
          <button 
            className="modal-close"
            onClick={() => {
              setIsOpen(false);
              onClose?.();
            }}
          >
            ×
          </button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
});

useImperativeHandleを使うことで、親から呼び出せるメソッドを定義できます。

  • open(): モーダルを開く
  • close(): モーダルを閉じる
  • toggle(): 開閉状態を切り替え
  • isOpen(): 現在の状態を取得

このモーダルの使用例を見てみましょう。

function App() {
  const modalRef = useRef(null);
  
  const handleDeleteClick = () => {
    modalRef.current.open();
  };
  
  return (
    <div className="app">
      <h1>モーダル使用例</h1>
      
      <button 
        onClick={() => modalRef.current.open()}
        className="btn btn-primary"
      >
        モーダルを開く
      </button>
      
      <Modal 
        ref={modalRef}
        title="サンプルモーダル"
        onClose={() => console.log('モーダルが閉じられました')}
      >
        <p>これはモーダルの内容です。</p>
        <p>ESCキーで閉じることもできます。</p>
      </Modal>
    </div>
  );
}

このようにrefを使うことで、ボタンクリック時にモーダルを開くことができます。 stateを親で管理する必要がなく、コンポーネントがより独立性を保てます。

アニメーション対応ボタン

最後に、アニメーション機能付きのボタンコンポーネントを作ってみましょう。

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

// アニメーション機能付きボタン
const AnimatedButton = forwardRef(({ 
  children, 
  onClick, 
  loading = false,
  variant = 'primary',
  ...props 
}, ref) => {
  const buttonRef = useRef(null);
  const [isAnimating, setIsAnimating] = useState(false);
  
  // 親から呼び出せるメソッド
  useImperativeHandle(ref, () => ({
    focus: () => buttonRef.current?.focus(),
    blur: () => buttonRef.current?.blur(),
    click: () => buttonRef.current?.click(),
    startPulse: () => setIsAnimating(true),
    stopPulse: () => setIsAnimating(false),
    getElement: () => buttonRef.current
  }));
  
  const handleClick = async (e) => {
    if (loading) return;
    
    // クリックアニメーション
    const button = buttonRef.current;
    button.style.transform = 'scale(0.95)';
    
    setTimeout(() => {
      button.style.transform = 'scale(1)';
    }, 150);
    
    // 親のonClickを実行
    if (onClick) {
      await onClick(e);
    }
  };
  
  return (
    <button
      ref={buttonRef}
      className={`animated-btn animated-btn--${variant} ${isAnimating ? 'pulse' : ''}`}
      onClick={handleClick}
      disabled={loading}
      {...props}
    >
      {loading ? (
        <span className="loading-spinner">⏳</span>
      ) : (
        children
      )}
    </button>
  );
});

このボタンは以下の機能を持っています。

  • クリックアニメーション: クリック時に縮小→拡大
  • パルスアニメーション: 外部から開始・停止可能
  • ローディング状態: 処理中の表示
  • フォーカス制御: 外部からフォーカス操作

使用例を見てみましょう。

function ButtonDemo() {
  const primaryBtnRef = useRef(null);
  const successBtnRef = useRef(null);
  
  const [loading, setLoading] = useState(false);
  
  const handleSave = async () => {
    setLoading(true);
    
    try {
      // 保存処理をシミュレート
      await new Promise(resolve => setTimeout(resolve, 2000));
      
      // 成功時のアニメーション
      successBtnRef.current.startPulse();
      setTimeout(() => {
        successBtnRef.current.stopPulse();
      }, 1000);
      
      console.log('保存が完了しました');
    } catch (error) {
      console.error('保存に失敗しました:', error);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div className="button-demo">
      <h2>アニメーションボタンデモ</h2>
      
      <div className="button-group">
        <AnimatedButton
          ref={primaryBtnRef}
          variant="primary"
          onClick={handleSave}
          loading={loading}
        >
          {loading ? '保存中...' : '保存'}
        </AnimatedButton>
        
        <AnimatedButton
          ref={successBtnRef}
          variant="success"
          onClick={() => console.log('成功ボタンがクリックされました')}
        >
          成功
        </AnimatedButton>
      </div>
      
      <div className="controls">
        <button 
          onClick={() => primaryBtnRef.current.startPulse()}
          className="btn btn-secondary"
        >
          プライマリボタンをパルス
        </button>
        
        <button 
          onClick={() => {
            primaryBtnRef.current.stopPulse();
            successBtnRef.current.stopPulse();
          }}
          className="btn btn-secondary"
        >
          全アニメーション停止
        </button>
      </div>
    </div>
  );
}

このように、forwardRefとuseImperativeHandleを組み合わせることで、非常に柔軟なコンポーネントが作れます。

useImperativeHandleの活用法

useImperativeHandleは、forwardRefと組み合わせて使う重要なフックです。 親コンポーネントから呼び出せるメソッドを自由に定義できます。

基本的な使い方

シンプルなカウンターコンポーネントで使い方を見てみましょう。

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

// カウンターコンポーネント
const Counter = forwardRef((props, ref) => {
  const [count, setCount] = useState(0);
  
  // 親コンポーネントから呼び出せるメソッドを定義
  useImperativeHandle(ref, () => ({
    // カウンターをリセット
    reset: () => setCount(0),
    
    // 指定した値に設定
    setValue: (value) => setCount(value),
    
    // 現在の値を取得
    getValue: () => count,
    
    // 値を増加
    increment: (step = 1) => setCount(prev => prev + step),
    
    // 値を減少
    decrement: (step = 1) => setCount(prev => prev - step),
    
    // 値が特定の条件を満たすかチェック
    isEven: () => count % 2 === 0,
    isPositive: () => count > 0
  }));
  
  return (
    <div className="counter">
      <h3>カウンター: {count}</h3>
      <div className="counter-controls">
        <button onClick={() => setCount(prev => prev - 1)}>-1</button>
        <button onClick={() => setCount(prev => prev + 1)}>+1</button>
      </div>
    </div>
  );
});

このカウンターコンポーネントでは、以下のメソッドを外部に公開しています。

  • reset(): カウンターを0にリセット
  • setValue(value): 指定した値に設定
  • getValue(): 現在の値を取得
  • increment(step): 指定した分だけ増加
  • decrement(step): 指定した分だけ減少
  • isEven(): 偶数かどうかを判定
  • isPositive(): 正の数かどうかを判定

使用例を見てみましょう。

function CounterApp() {
  const counter1Ref = useRef(null);
  const counter2Ref = useRef(null);
  
  const handleReset = () => {
    counter1Ref.current.reset();
    counter2Ref.current.reset();
  };
  
  const handleRandomValues = () => {
    const randomValue1 = Math.floor(Math.random() * 100);
    const randomValue2 = Math.floor(Math.random() * 100);
    
    counter1Ref.current.setValue(randomValue1);
    counter2Ref.current.setValue(randomValue2);
  };
  
  const handleGetInfo = () => {
    const value1 = counter1Ref.current.getValue();
    const value2 = counter2Ref.current.getValue();
    
    const info = {
      counter1: {
        value: value1,
        isEven: counter1Ref.current.isEven(),
        isPositive: counter1Ref.current.isPositive()
      },
      counter2: {
        value: value2,
        isEven: counter2Ref.current.isEven(),
        isPositive: counter2Ref.current.isPositive()
      },
      sum: value1 + value2
    };
    
    console.log('カウンター情報:', info);
    alert(`合計: ${info.sum}`);
  };
  
  return (
    <div className="counter-app">
      <h2>カウンター管理アプリ</h2>
      
      <div className="counters">
        <Counter ref={counter1Ref} />
        <Counter ref={counter2Ref} />
      </div>
      
      <div className="app-controls">
        <button onClick={handleReset} className="btn btn-secondary">
          全リセット
        </button>
        
        <button onClick={handleRandomValues} className="btn btn-primary">
          ランダム値設定
        </button>
        
        <button onClick={handleGetInfo} className="btn btn-info">
          情報取得
        </button>
        
        <button 
          onClick={() => {
            counter1Ref.current.increment(5);
            counter2Ref.current.decrement(3);
          }}
          className="btn btn-success"
        >
          カウンター1を+5、カウンター2を-3
        </button>
      </div>
    </div>
  );
}

useImperativeHandleを使うことで、コンポーネント内部の状態や機能を外部から制御できるようになります。

フォームバリデーションの実例

より実用的な例として、バリデーション機能付きのフォームフィールドを作ってみましょう。

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

// バリデーション機能付きフォームフィールド
const ValidatedField = forwardRef(({ 
  label, 
  type = 'text', 
  rules = [], 
  ...props 
}, ref) => {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');
  const [touched, setTouched] = useState(false);
  const inputRef = useRef(null);
  
  // バリデーション実行
  const validate = (val = value) => {
    for (const rule of rules) {
      const result = rule(val);
      if (result !== true) {
        setError(result);
        return false;
      }
    }
    setError('');
    return true;
  };
  
  // 親から呼び出せるメソッド
  useImperativeHandle(ref, () => ({
    getValue: () => value,
    setValue: (val) => {
      setValue(val);
      if (touched) validate(val);
    },
    validate: () => validate(),
    focus: () => inputRef.current?.focus(),
    clear: () => {
      setValue('');
      setError('');
      setTouched(false);
    },
    isValid: () => !error && touched,
    hasError: () => !!error,
    getError: () => error
  }));
  
  const handleChange = (e) => {
    const newValue = e.target.value;
    setValue(newValue);
    if (touched) {
      validate(newValue);
    }
  };
  
  const handleBlur = () => {
    setTouched(true);
    validate();
  };
  
  return (
    <div className={`form-field ${error ? 'error' : ''} ${touched && !error ? 'valid' : ''}`}>
      <label>{label}</label>
      <input
        ref={inputRef}
        type={type}
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        {...props}
      />
      {error && <div className="error-message">{error}</div>}
    </div>
  );
});

このコンポーネントは以下のメソッドを公開しています。

  • getValue(): 現在の入力値を取得
  • setValue(val): 値を設定
  • validate(): バリデーションを実行
  • focus(): フィールドにフォーカス
  • clear(): フィールドをクリア
  • isValid(): バリデーション状態をチェック
  • hasError(): エラーがあるかチェック
  • getError(): エラーメッセージを取得

バリデーションルールも定義してみましょう。

// バリデーションルール
const validationRules = {
  required: (value) => value.trim() ? true : 'この項目は必須です',
  minLength: (min) => (value) => 
    value.length >= min ? true : `${min}文字以上で入力してください`,
  maxLength: (max) => (value) => 
    value.length <= max ? true : `${max}文字以下で入力してください`,
  email: (value) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(value) ? true : '有効なメールアドレスを入力してください';
  }
};

これらを使った登録フォームの例です。

function RegistrationForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    // 全フィールドのバリデーション
    const fields = [nameRef, emailRef, passwordRef];
    const isValid = fields.every(fieldRef => fieldRef.current.validate());
    
    if (!isValid) {
      // 最初のエラーフィールドにフォーカス
      const firstErrorField = fields.find(fieldRef => fieldRef.current.hasError());
      if (firstErrorField) {
        firstErrorField.current.focus();
      }
      return;
    }
    
    // フォーム送信
    setIsSubmitting(true);
    
    try {
      const formData = {
        name: nameRef.current.getValue(),
        email: emailRef.current.getValue(),
        password: passwordRef.current.getValue()
      };
      
      console.log('送信データ:', formData);
      
      // API送信をシミュレート
      await new Promise(resolve => setTimeout(resolve, 2000));
      
      alert('登録が完了しました!');
      
      // フォームクリア
      fields.forEach(fieldRef => fieldRef.current.clear());
      nameRef.current.focus();
      
    } catch (error) {
      alert('登録に失敗しました');
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit} className="registration-form">
      <h2>ユーザー登録</h2>
      
      <ValidatedField
        ref={nameRef}
        label="お名前"
        placeholder="山田太郎"
        rules={[
          validationRules.required,
          validationRules.minLength(2),
          validationRules.maxLength(50)
        ]}
      />
      
      <ValidatedField
        ref={emailRef}
        label="メールアドレス"
        type="email"
        placeholder="example@email.com"
        rules={[
          validationRules.required,
          validationRules.email
        ]}
      />
      
      <ValidatedField
        ref={passwordRef}
        label="パスワード"
        type="password"
        placeholder="8文字以上で入力"
        rules={[
          validationRules.required,
          validationRules.minLength(8)
        ]}
      />
      
      <div className="form-actions">
        <button 
          type="submit" 
          className="btn btn-primary"
          disabled={isSubmitting}
        >
          {isSubmitting ? '登録中...' : '登録'}
        </button>
      </div>
    </form>
  );
}

このように、useImperativeHandleを使うことで非常に柔軟なフォーム処理が実現できます。

TypeScriptでのforwardRef

TypeScriptを使っている場合の、forwardRefの型定義方法を見てみましょう。 正しく型を定義することで、より安全にコンポーネントを使えます。

基本的な型定義

まず、シンプルなボタンコンポーネントの型定義から見てみましょう。

import React, { forwardRef } from 'react';

// props の型定義
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  loading?: boolean;
}

// forwardRef with TypeScript
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'medium', loading = false, children, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={`btn btn--${variant} btn--${size} ${loading ? 'loading' : ''}`}
        disabled={loading}
        {...props}
      >
        {loading ? 'Loading...' : children}
      </button>
    );
  }
);

Button.displayName = 'Button';

TypeScriptでforwardRefを使う時のポイントは以下の通りです。

  • 第1ジェネリック: ref の型(HTMLButtonElement)
  • 第2ジェネリック: props の型(ButtonProps)
  • displayName: React DevToolsでの表示名

useImperativeHandleの型定義

useImperativeHandleを使う場合の型定義方法を見てみましょう。

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

// 公開するメソッドの型定義
interface CounterHandle {
  getValue: () => number;
  setValue: (value: number) => void;
  increment: (step?: number) => void;
  decrement: (step?: number) => void;
  reset: () => void;
}

interface CounterProps {
  initialValue?: number;
  min?: number;
  max?: number;
  onValueChange?: (value: number) => void;
}

const Counter = forwardRef<CounterHandle, CounterProps>(
  ({ initialValue = 0, min, max, onValueChange }, ref) => {
    const [count, setCount] = useState(initialValue);
    
    const updateCount = (newValue: number) => {
      let clampedValue = newValue;
      
      if (min !== undefined && clampedValue < min) {
        clampedValue = min;
      }
      if (max !== undefined && clampedValue > max) {
        clampedValue = max;
      }
      
      setCount(clampedValue);
      onValueChange?.(clampedValue);
    };
    
    useImperativeHandle(ref, () => ({
      getValue: () => count,
      setValue: (value: number) => updateCount(value),
      increment: (step: number = 1) => updateCount(count + step),
      decrement: (step: number = 1) => updateCount(count - step),
      reset: () => updateCount(initialValue)
    }));
    
    return (
      <div className="counter">
        <span>Count: {count}</span>
        <button onClick={() => updateCount(count - 1)}>-</button>
        <button onClick={() => updateCount(count + 1)}>+</button>
      </div>
    );
  }
);

Counter.displayName = 'Counter';

使用例はこんな感じになります。

function CounterApp() {
  const counterRef = useRef<CounterHandle>(null);
  
  const handleReset = () => {
    counterRef.current?.reset();
  };
  
  const handleSetRandom = () => {
    const randomValue = Math.floor(Math.random() * 100);
    counterRef.current?.setValue(randomValue);
  };
  
  return (
    <div>
      <Counter 
        ref={counterRef}
        initialValue={10}
        min={0}
        max={100}
        onValueChange={(value) => console.log('Value changed:', value)}
      />
      <button onClick={handleReset}>Reset</button>
      <button onClick={handleSetRandom}>Random</button>
    </div>
  );
}

TypeScriptを使うことで、メソッドの引数や戻り値の型が保証され、より安全にコンポーネントを使えるようになります。

よくある間違いと対処法

forwardRefを使う時によくある間違いと、その解決方法をご紹介します。 事前に知っておくことで、スムーズに開発を進められますよ。

forwardRefを忘れてしまう

これは最もよくある間違いです。

// ❌ forwardRef を使用していない
function CustomInput({ placeholder }, ref) {
  return <input ref={ref} placeholder={placeholder} />;
}

// 使用時に警告が出る
function App() {
  const inputRef = useRef(null);
  return <CustomInput ref={inputRef} placeholder="入力" />; // Warning!
}

この場合、以下のような警告が表示されます。

Warning: Function components cannot be given refs. 
Attempts to access this ref will fail.

解決方法

// ✅ forwardRef を使用
const CustomInput = forwardRef(({ placeholder }, ref) => {
  return <input ref={ref} placeholder={placeholder} />;
});

forwardRefでコンポーネントをラップすることで、正しくrefが転送されます。

useImperativeHandleの依存配列を忘れる

useImperativeHandleでは、依存配列の指定が重要です。

// ❌ 依存配列が不適切
const Counter = forwardRef((props, ref) => {
  const [count, setCount] = useState(0);
  
  useImperativeHandle(ref, () => ({
    getValue: () => count, // count が古い値を参照する可能性
    increment: () => setCount(count + 1)
  })); // 依存配列がない
  
  return <div>{count}</div>;
});

この場合、countが古い値を参照してしまう可能性があります。

解決方法

// ✅ 適切な依存配列と関数の使用
const Counter = forwardRef((props, ref) => {
  const [count, setCount] = useState(0);
  
  useImperativeHandle(ref, () => ({
    getValue: () => count,
    increment: () => setCount(prev => prev + 1) // 関数形式を使用
  }), [count]); // count を依存配列に含める
  
  return <div>{count}</div>;
});

依存配列を正しく指定し、必要に応じて関数形式のsetStateを使いましょう。

displayNameの設定を忘れる

React DevToolsでの表示が分かりにくくなります。

// ❌ displayName が設定されていない
const CustomButton = forwardRef((props, ref) => {
  return <button ref={ref} {...props} />;
});

// React DevTools で "ForwardRef" と表示される

解決方法

// ✅ displayName を設定
const CustomButton = forwardRef((props, ref) => {
  return <button ref={ref} {...props} />;
});

CustomButton.displayName = 'CustomButton';

displayNameを設定することで、React DevToolsで見つけやすくなります。

まとめ:forwardRefをマスターしよう!

React forwardRefについて、基本から実践的な使い方まで詳しく解説しました。

重要なポイント

  1. forwardRefの必要性: カスタムコンポーネントでrefを使うために必要
  2. 基本的な使い方: propsとrefを受け取る構造
  3. useImperativeHandle: 公開するメソッドを自由に定義
  4. TypeScript対応: 適切な型定義で安全性を向上

活用できる場面

  • フォーム操作: 入力フィールドへの直接アクセス
  • アニメーション制御: 外部からアニメーションを操作
  • モーダル管理: 開閉状態を外部から制御
  • フォーカス制御: エラー時の自動フォーカス

実装時のコツ

  1. 必要な機能のみ公開: useImperativeHandleで適切なメソッドを選択
  2. 型安全性の確保: TypeScriptでの型定義
  3. 依存配列の管理: useImperativeHandleの正しい依存関係
  4. デバッグしやすさ: displayNameの設定

forwardRefをマスターすることで、より柔軟で再利用可能なReactコンポーネントが作れるようになります。 最初は複雑に感じるかもしれませんが、一度覚えてしまえば非常に強力な機能です。

ぜひ実際のプロジェクトで活用してみてください。 きっと、より使いやすいコンポーネントライブラリが作れるようになりますよ!

関連記事