Reactで同じコンポーネントを使い回す|再利用の基本

Reactコンポーネントの再利用性を高める方法を詳しく解説。props、children、カスタムフックを活用した効率的なコンポーネント設計を初心者向けに説明します。

Learning Next 運営
36 分で読めます

みなさん、Reactで同じようなコンポーネントを何度も作っていませんか?

「似たようなボタンを毎回作り直している」 「カードレイアウトが微妙に違うだけで別コンポーネントにしている」

こんな状況になったことはありませんか? 実は、これらの問題はコンポーネントの再利用性を高めることで解決できるんです。

この記事では、Reactコンポーネントを効率的に使い回すための基本的な手法と実践的な応用例を詳しく解説します。

最後まで読めば、同じコンポーネントを様々な場面で使い回せるようになり、開発効率が大幅に向上しますよ!

再利用可能なコンポーネントの基本

コンポーネントの再利用性を高めるために、まずは基本的な考え方を理解しましょう。

propsを使った柔軟性の確保

最初に、再利用性の低いコンポーネントと高いコンポーネントの違いを見てみましょう。

// 再利用性の低いコンポーネント
const LoginButton = () => {
  return (
    <button className="btn-primary" onClick={() => console.log('ログイン')}>
      ログイン
    </button>
  );
};

const LogoutButton = () => {
  return (
    <button className="btn-secondary" onClick={() => console.log('ログアウト')}>
      ログアウト
    </button>
  );
};

上記のコードでは、LoginButtonLogoutButtonが別々に作られています。 見た目やテキストが違うだけで、基本的な機能は同じボタンです。

これを再利用可能なコンポーネントに変更してみましょう。

// 再利用性の高いコンポーネント
const Button = ({ children, variant = 'primary', onClick, disabled = false }) => {
  const getButtonClass = () => {
    switch (variant) {
      case 'primary': return 'btn-primary';
      case 'secondary': return 'btn-secondary';
      case 'danger': return 'btn-danger';
      default: return 'btn-primary';
    }
  };
  
  return (
    <button
      className={getButtonClass()}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
};

このButtonコンポーネントでは、以下の要素をpropsで受け取っています。

  • children: ボタンに表示するテキスト
  • variant: ボタンのスタイル(primary、secondary、danger)
  • onClick: クリック時の処理
  • disabled: ボタンの無効化状態

これにより、一つのコンポーネントで様々なボタンを作成できます。

// 使用例
const App = () => {
  return (
    <div>
      <Button variant="primary" onClick={() => console.log('ログイン')}>
        ログイン
      </Button>
      <Button variant="secondary" onClick={() => console.log('ログアウト')}>
        ログアウト
      </Button>
      <Button variant="danger" onClick={() => console.log('削除')}>
        削除
      </Button>
    </div>
  );
};

同じコンポーネントを使って、異なるスタイルと機能のボタンを作成できました。 これが再利用性の高いコンポーネントの基本的な考え方です。

実践的な再利用パターン

次に、実際の開発でよく使われる再利用パターンを見てみましょう。

カードコンポーネントの作成

カードレイアウトは、多くのWebアプリケーションで使用される共通的なデザインパターンです。

// 基本的なCardコンポーネント
const Card = ({ 
  title, 
  children, 
  footer,
  variant = 'default',
  onClick 
}) => {
  const getCardClass = () => {
    const baseClass = 'card';
    const variantClass = `card-${variant}`;
    const clickableClass = onClick ? 'card-clickable' : '';
    
    return `${baseClass} ${variantClass} ${clickableClass}`.trim();
  };
  
  return (
    <div className={getCardClass()} onClick={onClick}>
      {title && (
        <div className="card-header">
          <h3>{title}</h3>
        </div>
      )}
      
      <div className="card-body">
        {children}
      </div>
      
      {footer && (
        <div className="card-footer">
          {footer}
        </div>
      )}
    </div>
  );
};

このCardコンポーネントは以下の特徴を持っています。

  • title: カードのタイトル(オプション)
  • children: カードの内容
  • footer: フッター部分(オプション)
  • variant: カードのスタイル
  • onClick: クリック時の処理(オプション)

様々な用途で使用できるようにしてみましょう。

// 様々な用途で使用
const CardExamples = () => {
  return (
    <div>
      {/* ユーザープロフィールカード */}
      <Card
        title="ユーザープロフィール"
        variant="primary"
        footer={<Button>プロフィール編集</Button>}
      >
        <img src="avatar.jpg" alt="アバター" />
        <p>田中太郎</p>
        <p>tanaka@example.com</p>
      </Card>
      
      {/* 商品カード */}
      <Card
        title="商品名"
        onClick={() => console.log('商品詳細へ')}
        footer={<span>¥1,980</span>}
      >
        <img src="product.jpg" alt="商品画像" />
        <p>商品の説明文がここに入ります。</p>
      </Card>
      
      {/* お知らせカード */}
      <Card variant="warning">
        <p>重要なお知らせがあります。</p>
      </Card>
    </div>
  );
};

同じCardコンポーネントを使って、ユーザープロフィール、商品、お知らせなど、全く異なる用途のカードを作成できました。

フォーム入力コンポーネントの作成

フォーム入力も、多くのアプリケーションで共通的に使用される要素です。

// 再利用可能な入力フィールド
const FormField = ({
  label,
  type = 'text',
  value,
  onChange,
  placeholder,
  required = false,
  error,
  helpText
}) => {
  return (
    <div className="form-field">
      {label && (
        <label className="form-label">
          {label}
          {required && <span className="required">*</span>}
        </label>
      )}
      
      <input
        type={type}
        value={value}
        onChange={onChange}
        placeholder={placeholder}
        className={`form-input ${error ? 'error' : ''}`}
        required={required}
      />
      
      {error && (
        <div className="error-message">{error}</div>
      )}
      
      {helpText && (
        <div className="help-text">{helpText}</div>
      )}
    </div>
  );
};

このFormFieldコンポーネントは以下の機能を持っています。

  • ラベル表示
  • 必須項目の表示
  • エラーメッセージの表示
  • ヘルプテキストの表示
  • 様々な入力タイプに対応

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

// 使用例
const ContactForm = () => {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });
  const [errors, setErrors] = useState({});
  
  const handleChange = (field) => (e) => {
    setFormData(prev => ({
      ...prev,
      [field]: e.target.value
    }));
  };
  
  return (
    <form>
      <FormField
        label="お名前"
        value={formData.name}
        onChange={handleChange('name')}
        placeholder="田中太郎"
        required
        error={errors.name}
      />
      
      <FormField
        label="メールアドレス"
        type="email"
        value={formData.email}
        onChange={handleChange('email')}
        placeholder="example@email.com"
        required
        error={errors.email}
        helpText="返信用のメールアドレスを入力してください"
      />
      
      <FormField
        label="メッセージ"
        value={formData.message}
        onChange={handleChange('message')}
        placeholder="お問い合わせ内容"
        required
        error={errors.message}
      />
    </form>
  );
};

同じコンポーネントを使って、名前、メールアドレス、メッセージなど、異なる種類の入力フィールドを作成できました。

リストアイテムコンポーネントの作成

リスト表示も、多くのアプリケーションで共通的に使用される要素です。

// 汎用的なリストアイテム
const ListItem = ({
  avatar,
  title,
  subtitle,
  description,
  actions,
  onClick,
  selected = false
}) => {
  return (
    <div
      className={`list-item ${selected ? 'selected' : ''} ${onClick ? 'clickable' : ''}`}
      onClick={onClick}
    >
      {avatar && (
        <div className="list-item-avatar">
          {typeof avatar === 'string' ? (
            <img src={avatar} alt="アバター" />
          ) : (
            avatar
          )}
        </div>
      )}
      
      <div className="list-item-content">
        <div className="list-item-title">{title}</div>
        {subtitle && (
          <div className="list-item-subtitle">{subtitle}</div>
        )}
        {description && (
          <div className="list-item-description">{description}</div>
        )}
      </div>
      
      {actions && (
        <div className="list-item-actions">
          {actions}
        </div>
      )}
    </div>
  );
};

このListItemコンポーネントは以下の要素を持っています。

  • avatar: アバター画像またはアイコン
  • title: メインタイトル
  • subtitle: サブタイトル
  • description: 説明文
  • actions: アクションボタン
  • onClick: クリック時の処理
  • selected: 選択状態

様々なリストで使用してみましょう。

// 様々なリストで使用
const ListExamples = () => {
  const users = [
    { id: 1, name: '田中太郎', email: 'tanaka@example.com', avatar: 'avatar1.jpg' },
    { id: 2, name: '佐藤花子', email: 'sato@example.com', avatar: 'avatar2.jpg' }
  ];
  
  const notifications = [
    { id: 1, title: '新しいメッセージ', time: '2分前', type: 'message' },
    { id: 2, title: 'システム更新', time: '1時間前', type: 'system' }
  ];
  
  return (
    <div>
      {/* ユーザーリスト */}
      <h3>ユーザー一覧</h3>
      {users.map(user => (
        <ListItem
          key={user.id}
          avatar={user.avatar}
          title={user.name}
          subtitle={user.email}
          onClick={() => console.log('ユーザー詳細:', user.id)}
          actions={
            <Button variant="secondary" size="small">
              詳細
            </Button>
          }
        />
      ))}
      
      {/* 通知リスト */}
      <h3>通知</h3>
      {notifications.map(notification => (
        <ListItem
          key={notification.id}
          avatar={<span className={`icon-${notification.type}`} />}
          title={notification.title}
          subtitle={notification.time}
          onClick={() => console.log('通知詳細:', notification.id)}
        />
      ))}
    </div>
  );
};

同じコンポーネントを使って、ユーザーリストと通知リストという全く異なるリストを作成できました。

childrenプロパティを活用した柔軟性

childrenプロパティを使うことで、さらに柔軟な再利用が可能になります。

レイアウトコンポーネントの作成

// 汎用的なレイアウトコンポーネント
const Layout = ({ children, sidebar, header, footer }) => {
  return (
    <div className="layout">
      {header && (
        <header className="layout-header">
          {header}
        </header>
      )}
      
      <div className="layout-body">
        {sidebar && (
          <aside className="layout-sidebar">
            {sidebar}
          </aside>
        )}
        
        <main className="layout-main">
          {children}
        </main>
      </div>
      
      {footer && (
        <footer className="layout-footer">
          {footer}
        </footer>
      )}
    </div>
  );
};

このLayoutコンポーネントは、以下の要素を受け取ります。

  • children: メインコンテンツ
  • sidebar: サイドバー(オプション)
  • header: ヘッダー(オプション)
  • footer: フッター(オプション)

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

// 使用例
const App = () => {
  return (
    <Layout
      header={<Header />}
      sidebar={<Sidebar />}
      footer={<Footer />}
    >
      <div>
        <h1>メインコンテンツ</h1>
        <p>ここにページの内容が入ります。</p>
      </div>
    </Layout>
  );
};

childrenを使うことで、レイアウトの構造を保ちながら、中身を自由に変更できるようになります。

モーダルコンポーネントの作成

モーダルも、多くのアプリケーションで使用される共通的なコンポーネントです。

// 再利用可能なモーダル
const Modal = ({
  isOpen,
  onClose,
  title,
  children,
  size = 'medium',
  showCloseButton = true
}) => {
  if (!isOpen) return null;
  
  const getSizeClass = () => {
    switch (size) {
      case 'small': return 'modal-small';
      case 'large': return 'modal-large';
      default: return 'modal-medium';
    }
  };
  
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        className={`modal-content ${getSizeClass()}`}
        onClick={(e) => e.stopPropagation()}
      >
        {(title || showCloseButton) && (
          <div className="modal-header">
            {title && <h2>{title}</h2>}
            {showCloseButton && (
              <button className="modal-close" onClick={onClose}>
                ✕
              </button>
            )}
          </div>
        )}
        
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
};

このModalコンポーネントは以下の特徴を持っています。

  • isOpen: モーダルの表示状態
  • onClose: モーダルを閉じる処理
  • title: モーダルのタイトル
  • children: モーダルの内容
  • size: モーダルのサイズ
  • showCloseButton: 閉じるボタンの表示

様々な用途で使用してみましょう。

// 様々な用途で使用
const ModalExamples = () => {
  const [modals, setModals] = useState({
    confirm: false,
    userDetail: false,
    settings: false
  });
  
  const openModal = (modalName) => {
    setModals(prev => ({ ...prev, [modalName]: true }));
  };
  
  const closeModal = (modalName) => {
    setModals(prev => ({ ...prev, [modalName]: false }));
  };
  
  return (
    <div>
      <Button onClick={() => openModal('confirm')}>
        確認ダイアログ
      </Button>
      <Button onClick={() => openModal('userDetail')}>
        ユーザー詳細
      </Button>
      <Button onClick={() => openModal('settings')}>
        設定
      </Button>
      
      {/* 確認ダイアログ */}
      <Modal
        isOpen={modals.confirm}
        onClose={() => closeModal('confirm')}
        title="確認"
        size="small"
      >
        <p>本当に削除しますか?</p>
        <div className="modal-actions">
          <Button variant="danger">削除</Button>
          <Button variant="secondary" onClick={() => closeModal('confirm')}>
            キャンセル
          </Button>
        </div>
      </Modal>
      
      {/* ユーザー詳細 */}
      <Modal
        isOpen={modals.userDetail}
        onClose={() => closeModal('userDetail')}
        title="ユーザー詳細"
      >
        <UserProfile userId={123} />
      </Modal>
      
      {/* 設定 */}
      <Modal
        isOpen={modals.settings}
        onClose={() => closeModal('settings')}
        title="設定"
        size="large"
      >
        <SettingsForm />
      </Modal>
    </div>
  );
};

同じModalコンポーネントを使って、確認ダイアログ、ユーザー詳細、設定など、全く異なる用途のモーダルを作成できました。

カスタムフックによるロジックの再利用

コンポーネントの見た目だけでなく、ロジックの再利用も重要です。

データ取得フックの作成

// 汎用的なデータ取得フック
const useApi = (url, options = {}) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const fetchData = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [url, JSON.stringify(options)]);
  
  useEffect(() => {
    fetchData();
  }, [fetchData]);
  
  return { data, loading, error, refetch: fetchData };
};

このuseApiフックは以下の機能を提供します。

  • data: 取得したデータ
  • loading: 読み込み状態
  • error: エラー情報
  • refetch: データの再取得

様々なコンポーネントで使用してみましょう。

// 様々なコンポーネントで使用
const UserList = () => {
  const { data: users, loading, error } = useApi('/api/users');
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  
  return (
    <div>
      {users?.map(user => (
        <ListItem
          key={user.id}
          title={user.name}
          subtitle={user.email}
        />
      ))}
    </div>
  );
};

const ProductList = () => {
  const { data: products, loading, error } = useApi('/api/products');
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  
  return (
    <div>
      {products?.map(product => (
        <Card key={product.id} title={product.name}>
          <p>{product.description}</p>
          <p>¥{product.price.toLocaleString()}</p>
        </Card>
      ))}
    </div>
  );
};

同じuseApiフックを使って、ユーザーリストと商品リストという異なるデータを取得できました。

フォーム管理フックの作成

// 汎用的なフォーム管理フック
const useForm = (initialValues, validationRules = {}) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  const setValue = (name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    
    // バリデーション実行
    if (validationRules[name]) {
      const error = validationRules[name](value);
      setErrors(prev => ({ ...prev, [name]: error }));
    }
  };
  
  const setTouched = (name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
  };
  
  const handleChange = (name) => (e) => {
    setValue(name, e.target.value);
  };
  
  const handleBlur = (name) => () => {
    setTouched(name);
  };
  
  const validate = () => {
    const newErrors = {};
    Object.keys(validationRules).forEach(field => {
      const error = validationRules[field](values[field]);
      if (error) newErrors[field] = error;
    });
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const reset = () => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  };
  
  return {
    values,
    errors,
    touched,
    setValue,
    handleChange,
    handleBlur,
    validate,
    reset
  };
};

このuseFormフックは以下の機能を提供します。

  • フォームの値管理
  • バリデーション
  • エラー表示
  • フォームのリセット

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

// 使用例
const ContactForm = () => {
  const form = useForm(
    { name: '', email: '', message: '' },
    {
      name: (value) => !value ? '名前は必須です' : '',
      email: (value) => {
        if (!value) return 'メールアドレスは必須です';
        if (!/\S+@\S+\.\S+/.test(value)) return '有効なメールアドレスを入力してください';
        return '';
      },
      message: (value) => !value ? 'メッセージは必須です' : ''
    }
  );
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (form.validate()) {
      console.log('フォーム送信:', form.values);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <FormField
        label="お名前"
        value={form.values.name}
        onChange={form.handleChange('name')}
        onBlur={form.handleBlur('name')}
        error={form.touched.name && form.errors.name}
        required
      />
      
      <FormField
        label="メールアドレス"
        type="email"
        value={form.values.email}
        onChange={form.handleChange('email')}
        onBlur={form.handleBlur('email')}
        error={form.touched.email && form.errors.email}
        required
      />
      
      <FormField
        label="メッセージ"
        value={form.values.message}
        onChange={form.handleChange('message')}
        onBlur={form.handleBlur('message')}
        error={form.touched.message && form.errors.message}
        required
      />
      
      <Button type="submit">送信</Button>
      <Button type="button" variant="secondary" onClick={form.reset}>
        リセット
      </Button>
    </form>
  );
};

useFormフックを使うことで、フォームの管理が非常に簡単になりました。

高階コンポーネントパターン

高階コンポーネント(HOC)を使うことで、コンポーネントに共通的な機能を追加できます。

認証機能付きコンポーネント

// 認証チェック用HOC
const withAuth = (WrappedComponent) => {
  return (props) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [loading, setLoading] = useState(true);
    
    useEffect(() => {
      // 認証状態を確認
      const checkAuth = async () => {
        try {
          const token = localStorage.getItem('token');
          if (token) {
            // トークンの有効性を確認
            const response = await fetch('/api/auth/verify', {
              headers: { Authorization: `Bearer ${token}` }
            });
            setIsAuthenticated(response.ok);
          }
        } catch (error) {
          console.error('認証エラー:', error);
        } finally {
          setLoading(false);
        }
      };
      
      checkAuth();
    }, []);
    
    if (loading) {
      return <div>認証確認中...</div>;
    }
    
    if (!isAuthenticated) {
      return <div>ログインが必要です</div>;
    }
    
    return <WrappedComponent {...props} />;
  };
};

このwithAuthHOCは、任意のコンポーネントに認証機能を追加します。

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

// 使用例
const Dashboard = () => <div>ダッシュボード</div>;
const UserProfile = () => <div>ユーザープロフィール</div>;

const AuthenticatedDashboard = withAuth(Dashboard);
const AuthenticatedProfile = withAuth(UserProfile);

これにより、DashboardUserProfileコンポーネントに認証機能が追加されます。

より良いコンポーネント設計のために

Reactコンポーネントの再利用性を高めるために、以下のポイントを心がけましょう。

設計時に考慮すべき点

適切な抽象化レベル

  • 過度に抽象化しすぎない
  • 実際の使用パターンに基づいて設計する
  • 将来の拡張性を考慮する

propsの設計

  • 必要最小限のpropsに絞る
  • デフォルト値を適切に設定する
  • propsの名前を分かりやすくする

型定義の活用

  • TypeScriptを使用する場合は適切な型定義を行う
  • propsの型を明確にする
  • 再利用性を保証する

再利用可能なコンポーネント設計の利点

開発効率の向上

  • 同じコンポーネントを何度も作る必要がない
  • 新機能の開発が早くなる
  • コードの重複を減らせる

一貫したデザインシステム

  • 見た目の統一性が保たれる
  • ブランドイメージが統一される
  • ユーザー体験が向上する

メンテナンス性の向上

  • 修正が一箇所で済む
  • バグの発生率が減る
  • テストが書きやすくなる

まとめ

Reactコンポーネントの再利用性を高めるためのポイントをまとめました。

基本的なアプローチ

  • propsを活用して柔軟性を持たせる
  • childrenプロパティでレイアウトとコンテンツを分離する
  • カスタムフックでロジックを抽出・再利用する

実践的なパターン

  • ボタン、カード、フォームなどの共通コンポーネントを作成
  • レイアウトやモーダルなどの構造的コンポーネントを設計
  • データ取得やフォーム管理などのフックを活用

設計の重要性

  • 適切な抽象化レベルを保つ
  • 実際の使用パターンに基づいて設計する
  • 将来の拡張性を考慮する

コンポーネントを設計する際は、実際の使用パターンに基づいて適切な抽象化を行うことが重要です。

過度な抽象化は避けつつ、実際に使われる形を想定した柔軟性を持たせることで、効率的なReact開発が可能になります。

ぜひ今日から、再利用可能なコンポーネントの設計を意識してみてください! きっと開発効率が向上し、より保守性の高いコードが書けるようになりますよ。

関連記事