toggle Engineer Blog

トグルホールディングス株式会社のエンジニアブログでは、私たちの技術的な挑戦やプロジェクトの裏側、チームの取り組みをシェアします。

Reactデザインパターン:Compound Component

こんにちは。トグルホールディングスプロダクトユニットのラファエルです。 トグルホールディングスエンジニアアドベントカレンダーの22日目の記事です!

はじめに

React デザインパターンは、React 開発におけるよくある問題に対する、実績のある解決策です。これらは、問題を効率的に解決しつつ、コードをきれいで保守しやすい状態に保つのに役立ちます。React が進化するにつれて、新しい課題に対応し、より良いアプリケーションを構築するために、新しいデザインパターンが登場しています。

今回は、開発者が直面しがちな共通の課題、大きなコンポーネントの扱い方についてお話しします。

課題

例えば、ユーザーフォーム のようなコンポーネントを構築しているとします。ユーザーは、名前メールアドレスといった基本的な情報を入力できます。

最初はサービスが立ち上げられたばかりなので、必要な情報はそれほど多くありません。シンプルなフォームで十分です。

しかし、サービスが成長し、より多くのユーザーを獲得すると、性別年齢といった、より詳細な情報を収集することで製品を改善したいと考えるようになります。

こうして、フォームコンポーネントは新しい要件を満たすために拡張されていきます。

さらにサービスが大成功を収めると、ユーザーへの感謝の印としてギフトを贈りたいと考えるかもしれません🎉。そのためには、ユーザーの住所を収集する必要があります。

import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";

// フォームデータの型を定義
type FormData = {
  name: string;
  email: string;
  gender: string;
  age: number;
  address: string;
  postalCode: string;
};

const UserForm: React.FC = () => {
  const {
    register, // フォームフィールドを登録
    handleSubmit, // フォームの送信を処理
    formState: { errors }, // バリデーションエラーの状態を管理
  } = useForm<FormData>();

  // フォーム送信ハンドラー
  const onSubmit: SubmitHandler<FormData> = (data) => {
    console.log("Form Data:", data);
    alert("フォームが正常に送信されました!");
  };

  return (
    <div style={{ maxWidth: "400px", margin: "0 auto", padding: "1rem" }}>
      <h2>ユーザーフォーム</h2>
      <form onSubmit={handleSubmit(onSubmit)}>
        {/* 名前フィールド */}
        <div style={{ marginBottom: "1rem" }}>
          <label>名前:</label>
          <input
            {...register("name", { required: "名前は必須項目です" })}
            placeholder="名前を入力してください"
            style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
          />
          {errors.name && (
            <span style={{ color: "red", fontSize: "0.875rem" }}>
              {errors.name.message}
            </span>
          )}
        </div>

        {/* メールフィールド */}
        <div style={{ marginBottom: "1rem" }}>
          <label>メールアドレス:</label>
          <input
            {...register("email", {
              required: "メールアドレスは必須項目です",
              pattern: {
                value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
                message: "有効なメールアドレスを入力してください",
              },
            })}
            placeholder="メールアドレスを入力してください"
            style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
          />
          {errors.email && (
            <span style={{ color: "red", fontSize: "0.875rem" }}>
              {errors.email.message}
            </span>
          )}
        </div>

        {/* 性別フィールド */}
        <div style={{ marginBottom: "1rem" }}>
          <label>性別:</label>
          <select
            {...register("gender", { required: "性別を選択してください" })}
            style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
          >
            <option value="">性別を選択</option>
            <option value="male">男性</option>
            <option value="female">女性</option>
            <option value="other">その他</option>
          </select>
          {errors.gender && (
            <span style={{ color: "red", fontSize: "0.875rem" }}>
              {errors.gender.message}
            </span>
          )}
        </div>

        {/* 年齢フィールド */}
        <div style={{ marginBottom: "1rem" }}>
          <label>年齢:</label>
          <input
            type="number"
            {...register("age", {
              required: "年齢は必須項目です",
              min: { value: 1, message: "1歳以上を入力してください" },
              max: { value: 120, message: "120歳以下を入力してください" },
            })}
            placeholder="年齢を入力してください"
            style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
          />
          {errors.age && (
            <span style={{ color: "red", fontSize: "0.875rem" }}>
              {errors.age.message}
            </span>
          )}
        </div>

        {/* 住所フィールド */}
        <div style={{ marginBottom: "1rem" }}>
          <label>住所:</label>
          <textarea
            {...register("address", {
              required: "住所は必須項目です",
              minLength: { value: 10, message: "住所は10文字以上で入力してください" },
            })}
            placeholder="住所を入力してください"
            style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem", height: "80px" }}
          />
          {errors.address && (
            <span style={{ color: "red", fontSize: "0.875rem" }}>
              {errors.address.message}
            </span>
          )}
        </div>

        {/* 郵便番号フィールド */}
        <div style={{ marginBottom: "1rem" }}>
          <label>郵便番号:</label>
          <input
            type="text"
            {...register("postalCode", {
              required: "郵便番号は必須項目です",
              pattern: {
                value: /^[0-9]{5,6}$/,
                message: "郵便番号は5桁または6桁で入力してください",
              },
            })}
            placeholder="郵便番号を入力してください"
            style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
          />
          {errors.postalCode && (
            <span style={{ color: "red", fontSize: "0.875rem" }}>
              {errors.postalCode.message}
            </span>
          )}
        </div>

        {/* 送信ボタン */}
        <button
          type="submit"
          style={{
            padding: "0.5rem 1rem",
            backgroundColor: "blue",
            color: "white",
            border: "none",
            cursor: "pointer",
          }}
        >
          送信
        </button>
      </form>
    </div>
  );
};

export default UserForm;

このような課題が生じる理由

新しい機能やフィールドをフォームに追加していくと、次のような問題が発生します:

  • サイズが大きくなる: ロジックやUI要素が増え、コンポーネントが肥大化します
  • 読みにくくなる: コードが煩雑になり、理解や変更が困難になります
  • 再利用が難しくなる: コンポーネントの一部を他の場所で再利用するのが難しくなります

解決策

このような複雑さを解消するための方法の1つが、Compound Component パターンを使用して、コンポーネントを小さく管理しやすいパーツに分割することです。

Compound Component パターンを使用することで、親コンポーネントと、それを補完する複数の子コンポーネントをシームレスに組み合わせることができます。この方法を使えば、コードがよりモジュール化され、保守性が向上します。

では、この大きなフォームをクリーンでモジュール化された Compound Component にリファクタリングしてみましょう! 🚀

状態を共有可能にする

この場合、formState はフォームの状態を管理しています。この状態とフォームメソッドを複数のコンポーネント間で共有するには、React Context APIを利用することができます。

こうすることで、フォーム全体の状態 (formState) と必要なメソッドを中央集約したUser Form Contextを作成できます。これにより、すべてのサブコンポーネントが親コンポーネントに依存せずに、フォームの状態を操作できるようになります。

実際に実装してみましょう!

// フォームデータ型を定義
export type UserFormData = {
  name: string;
  email: string;
  gender: string;
  age: number;
  address: string;
  postalCode: string;
};

// コンテキストの型を定義
type UserFormContextType = {
  formMethods: UseFormReturn<UserFormData>;
  onSubmit: SubmitHandler<UserFormData>;
};

// コンテキストを作成
const FormContext = createContext<UserFormContextType | null>(null);

// フォームコンテキストを使用するためのカスタムフック
export const useFormContext = () => {
  const context = useContext(FormContext);
  if (!context) {
    throw new Error("useFormContext は FormProvider 内でのみ使用できます");
  }
  return context;
};

// メインの UserForm コンポーネント
export const UserForm = ({ children }: { children: ReactNode }) => {
  const formMethods = useForm<UserFormData>();

  const onSubmit: SubmitHandler<UserFormData> = (data) => {
    console.log("フォームデータ:", data);
    alert("フォームが正常に送信されました!");
  };

  return (
    <FormContext.Provider value={{ formMethods, onSubmit }}>
      <div style={{ maxWidth: "400px", margin: "0 auto", padding: "1rem" }}>
        <h2>ユーザーフォーム</h2>
        <form onSubmit={formMethods.handleSubmit(onSubmit)}>{children}</form>
      </div>
    </FormContext.Provider>
  );
};

コンポーネントをサブコンポーネントに分割する

UserForm コンポーネントを拡張し、次の3つの汎用的なサブコンポーネントに分割します:

Basic: 名前やメールアドレスなどの基本的な情報を処理します。 Profile: 性別や年齢などのプロフィール情報を処理します。 Address: ユーザーの住所や郵便番号などの詳細情報を収集します。 この構造により、フォームがモジュール化され、管理が容易になります。

UserForm.Basic = () => {
  const {
    formMethods: { register, formState: { errors } },
  } = useFormContext();

  return (
    <>
      {/* 名前フィールド */}
      <div style={{ marginBottom: "1rem" }}>
        <label>名前:</label>
        <input
          {...register("name", { required: "名前は必須項目です" })}
          placeholder="名前を入力してください"
          style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
        />
        {errors.name && (
          <span style={{ color: "red", fontSize: "0.875rem" }}>
            {errors.name.message}
          </span>
        )}
      </div>

      {/* メールアドレスフィールド */}
      <div style={{ marginBottom: "1rem" }}>
        <label>メールアドレス:</label>
        <input
          {...register("email", {
            required: "メールアドレスは必須項目です",
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: "有効なメールアドレスを入力してください",
            },
          })}
          placeholder="メールアドレスを入力してください"
          style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
        />
        {errors.email && (
          <span style={{ color: "red", fontSize: "0.875rem" }}>
            {errors.email.message}
          </span>
        )}
      </div>
    </>
  );
};

UserForm.Profile = () => {
  const {
    formMethods: { register, formState: { errors } },
  } = useFormContext();

  return (
    <>
      {/* 性別フィールド */}
      <div style={{ marginBottom: "1rem" }}>
        <label>性別:</label>
        <select
          {...register("gender", { required: "性別を選択してください" })}
          style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
        >
          <option value="">性別を選択</option>
          <option value="male">男性</option>
          <option value="female">女性</option>
          <option value="other">その他</option>
        </select>
        {errors.gender && (
          <span style={{ color: "red", fontSize: "0.875rem" }}>
            {errors.gender.message}
          </span>
        )}
      </div>

      {/* 年齢フィールド */}
      <div style={{ marginBottom: "1rem" }}>
        <label>年齢:</label>
        <input
          type="number"
          {...register("age", {
            required: "年齢は必須項目です",
            min: { value: 1, message: "1歳以上を入力してください" },
            max: { value: 120, message: "120歳以下を入力してください" },
          })}
          placeholder="年齢を入力してください"
          style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
        />
        {errors.age && (
          <span style={{ color: "red", fontSize: "0.875rem" }}>
            {errors.age.message}
          </span>
        )}
      </div>
    </>
  );
};

UserForm.Address = () => {
  const {
    formMethods: { register, formState: { errors } },
  } = useFormContext();

  return (
    <>
      {/* 住所フィールド */}
      <div style={{ marginBottom: "1rem" }}>
        <label>住所:</label>
        <textarea
          {...register("address", {
            required: "住所は必須項目です",
            minLength: { value: 10, message: "住所は10文字以上で入力してください" },
          })}
          placeholder="住所を入力してください"
          style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem", height: "80px" }}
        />
        {errors.address && (
          <span style={{ color: "red", fontSize: "0.875rem" }}>
            {errors.address.message}
          </span>
        )}
      </div>

      {/* 郵便番号フィールド */}
      <div style={{ marginBottom: "1rem" }}>
        <label>郵便番号:</label>
        <input
          {...register("postalCode", {
            required: "郵便番号は必須項目です",
            pattern: {
              value: /^[0-9]{5,6}$/,
              message: "郵便番号は5桁または6桁で入力してください",
            },
          })}
          placeholder="郵便番号を入力してください"
          style={{ width: "100%", padding: "0.5rem", marginTop: "0.25rem" }}
        />
        {errors.postalCode && (
          <span style={{ color: "red", fontSize: "0.875rem" }}>
            {errors.postalCode.message}
          </span>
        )}
      </div>
    </>
  );
};

使い方

UserForm とそのサブコンポーネントを実装すれば、以下のように簡単に使えるようになります:

import React from "react";
import UserForm from "./UserForm";

const App: React.FC = () => {
  return (
    <UserForm>
      <UserForm.Basic />
      <UserForm.Profile />
      <UserForm.Address />
    </UserForm>
  );
};

export default App;

この方法により、フォームの各部分が独立しつつ、React Context API を通じて状態とロジックが一元管理されます。

Compound Component パターンのメリット

  • モジュール化: サブコンポーネントは独立しており、コードが読みやすく保守しやすい
  • 再利用性: 個々のサブコンポーネントを、アプリケーションの他の部分で再利用可
  • スケーラビリティ: 新しいフィールドやセクションを簡単に追加可能
  • 状態の集中管理: React Context API を使用することで、すべての状態とメソッドをシームレスに共有

Compound Component パターンを使用することで、フォームをクリーンでモジュール化された状態に保ちつつ、React アプリケーションの複雑さを軽減することができます。次回のプロジェクトでぜひ試してみてください! 🚀