確立されたUIパターンによるReactアプリケーションのモジュール化

確立されたUIパターンは、UIデザインにおける複雑な問題解決における有効性が証明されているにもかかわらず、フロントエンド開発の世界では十分に活用されていません。この記事では、確立されたUI構築パターンをReactの世界に適用する方法を検討し、リファクタリングの過程を示すコード例を用いてそのメリットを示します。重点は、階層化アーキテクチャがどのようにReactアプリケーションの組織化を支援し、応答性と将来的な変更への対応を改善するかです。

2023年2月16日


Photo of Juntao QIU | 邱俊涛

Juntaoは、テスト駆動開発、リファクタリング、クリーンコードに情熱を持つThoughtworksのソフトウェア開発者です。彼は自分の知識を共有し、他の開発者の成長を支援することを楽しんでおり、この分野に関するいくつかの書籍を出版した著者でもあります。さらに、彼はブロガー、YouTuber、コンテンツクリエイターとして、より良いコードを書くための支援をしています。

Juntaoは、この分野に関するいくつかの書籍を出版した著者でもあります。さらに、彼はブロガー、YouTuber、コンテンツクリエイターとして、より良いコードを書くための支援をしています。


Reactアプリケーションと記載しましたが、Reactアプリケーションというものは実際には存在しません。つまり、ビューとしてReactを使用するJavaScriptまたはTypeScriptで記述されたフロントエンドアプリケーションが存在するということです。しかし、Java EEアプリケーションをJSPアプリケーションと呼ばないのと同じように、それらをReactアプリケーションと呼ぶのは不適切だと考えます。

多くの場合、人々はアプリケーションを動作させるために、さまざまなものをReactコンポーネントやフックに詰め込んでいます。アプリケーションが小さく、ビジネスロジックがほとんどない場合は、この種の組織化されていない構造は問題になりません。しかし、多くの場合、より多くのビジネスロジックがフロントエンドに移行するにつれて、このすべてをコンポーネントに含める方法に問題が生じます。具体的には、この種のコードを理解するための努力は比較的大きく、コード変更のリスクも増加します。

この記事では、「Reactアプリケーション」を通常のアプリケーションに、そしてビューとしてReactのみを使用したものに再形成するために使用できるいくつかのパターンとテクニックについて説明します(これらのビューを他のビューライブラリにそれほど苦労せずに交換することもできます)。

ここで重要なのは、コードの各部分がアプリケーション内でどのような役割を果たしているかを分析することです(表面上は同じファイルにパックされている場合でも)。ビューと非ビューロジックを分離し、非ビューロジックをさらに責任別に分割して、適切な場所に配置します。

この分離の利点は、基盤となるドメインロジックの変更を、表面ビューをあまり心配することなく行うことができることです。また、他の部分に結合されていないため、他の場所でドメインロジックの再利用性を高めることができます。

Reactはビュー構築のための簡素なライブラリです

Reactは本質的に、ユーザーインターフェースを構築するのに役立つライブラリ(フレームワークではありません)であることを忘れてしまいがちです。

このコンテキストでは、ReactはWeb開発の特定の側面、つまりUIコンポーネントに焦点を当てたJavaScriptライブラリであり、アプリケーションの設計と全体的な構造に関して十分な自由度を提供していることが強調されています。

ユーザーインターフェース構築のためのJavaScriptライブラリ

-- Reactホームページ

非常に簡単そうに聞こえるかもしれませんが、データの取得や整形ロジックを消費される場所で直接記述している多くのケースを見てきました。たとえば、Reactコンポーネント内で、レンダリングの直上のuseEffectブロックでデータを取得したり、サーバー側から応答を受け取るとすぐにデータのマッピング/変換を実行したりすることです。

useEffect(() => {
  fetch("https://address.service/api")
    .then((res) => res.json())
    .then((data) => {
      const addresses = data.map((item) => ({
        street: item.streetName,
        address: item.streetAddress,
        postcode: item.postCode,
      }));

      setAddresses(addresses);
    });
}, []);

// the actual rendering...

おそらく、フロントエンドの世界に普遍的な標準がないか、単なる悪いプログラミング習慣のためでしょう。フロントエンドアプリケーションは、通常のソフトウェアアプリケーションとはあまりにも異なって扱うべきではありません。フロントエンドの世界では、コード構造を整理するために、依然として懸念事項の分離を一般的に使用します。そして、すべての有用な設計パターンは依然として適用されます。

現実世界のReactアプリケーションへようこそ

ほとんどの開発者は、Reactのシンプルさと、ユーザーインターフェースをデータからDOMへの純粋関数として表現できるという考えに感銘を受けました。そして、ある程度までは、事実です

しかし、バックエンドへのネットワークリクエストの送信やページナビゲーションの実行が必要になると、開発者は苦労し始めます。これらの副作用により、コンポーネントは「純粋」ではなくなります。そして、これらのさまざまな状態(グローバル状態またはローカル状態)を考慮すると、事態はすぐに複雑になり、ユーザーインターフェースの暗い面が現れます。

ユーザーインターフェース以外に

React自体は、計算やビジネスロジックをどこに配置するかについてはあまり気にしません。ユーザーインターフェース構築のためのライブラリにすぎないためです。そして、そのビューレイヤーを超えて、フロントエンドアプリケーションには他にも多くの部分があります。アプリケーションを動作させるには、ルーター、ローカルストレージ、さまざまなレベルのキャッシュ、ネットワークリクエスト、サードパーティ統合、サードパーティログイン、セキュリティ、ロギング、パフォーマンス調整などが必要です。

これらすべての追加コンテキストを考慮すると、すべてをReactコンポーネントやフックに詰め込もうとするのは、一般的に良い考えではありません。理由は、1つの場所に概念を混ぜ込むと、一般的に混乱を招くためです。最初は、コンポーネントが注文状況のネットワークリクエストを設定し、次に文字列の先頭にあるスペースをトリミングするロジックがあり、別の場所に移動します。読者は絶えずロジックの流れをリセットし、さまざまな詳細レベルを行ったり来たりする必要があります。

すべてのコードをコンポーネントにパックすることは、Todoや1つのフォームアプリケーションなどの小さなアプリケーションでは機能する可能性がありますが、あるレベルに達すると、そのようなアプリケーションを理解するための労力は大きくなります。新しい機能の追加や既存の欠陥の修正について言うまでもありません。

構造のあるファイルやフォルダーに懸念事項を分離できれば、アプリケーションを理解するために必要なメンタルロードを大幅に削減できます。そして、一度に1つのことにのみ集中できます。幸いなことに、Web以前の時代から既にいくつかのよく実証されたパターンがあります。これらの設計原則とパターンは、一般的なユーザーインターフェースの問題を解決するために調査および議論されてきました(ただし、デスクトップGUIアプリケーションのコンテキストでは)。

Martin Fowlerは、ビュー・モデル・データの階層化の概念を素晴らしい要約で説明しています。

全体として、私はこれを多くのアプリケーションにとって効果的なモジュール化の方法だと感じており、定期的に使用し、推奨しています。最大の利点は、ビュー、モデル、データの3つのトピックを比較的独立して考えることができるため、集中力を高めることができることです。

-- Martin Fowler

階層化アーキテクチャは、大規模なGUIアプリケーションの課題に対処するために使用されてきました。そして、確かにこれらの確立されたフロントエンド組織のパターンを「Reactアプリケーション」で使用できます。

Reactアプリケーションの進化

小規模なプロジェクトや一度限りのプロジェクトでは、すべてのロジックがReactコンポーネント内に記述されていることがわかります。合計で1つまたは少数のコンポーネントしか表示されない場合があります。コードはHTMLとほぼ同じように見え、ページを「動的」にするために使用される変数または状態がいくつかあるだけです。一部は、コンポーネントがレンダリングされた後にuseEffectでデータを取得するためのリクエストを送信する可能性があります。

アプリケーションが成長し、ますます多くのコードがコードベースに追加されます。適切な組織化方法がないと、すぐにコードベースはメンテナンス不可能な状態になり、小さな機能を追加するだけでも時間がかかるようになります。開発者はコードを読むのに多くの時間をかける必要があるためです。

そこで、メンテナンスの問題を軽減するのに役立ついくつかの手順をリストします。一般的に、もう少し努力が必要ですが、アプリケーションに構造があると報われます。スケーラブルなフロントエンドアプリケーションを構築するためのこれらの手順を簡単に確認しましょう。

単一コンポーネントアプリケーション

これは、単一コンポーネントアプリケーションとほぼ呼ぶことができます

図1:単一コンポーネントアプリケーション

しかしすぐに、単一のコンポーネントで何が起こっているのかを読むだけで多くの時間がかかると気付きます。たとえば、リストを反復処理して各アイテムを生成するロジックがあります。また、他のロジックとは別に、いくつかの構成コードのみを使用してサードパーティコンポーネントを使用するためのロジックもあります。

複数コンポーネントアプリケーション

結果のHTMLで何が起こっているかを反映した構造を持つ複数のコンポーネントにコンポーネントを分割することを決定しました。これは良いアイデアであり、一度に1つのコンポーネントに集中するのに役立ちます。

図2:複数コンポーネントアプリケーション

そして、アプリケーションが成長するにつれて、ビューとは別に、ネットワークリクエストの送信、ビューが消費するためにデータを変換する、サーバーに送り返すデータの収集などがあります。これらのコードをコンポーネント内に配置するのは適切ではありません。それらは実際にはユーザーインターフェースに関するものではないからです。また、一部のコンポーネントには内部状態が多すぎます。

フックによる状態管理

このロジックを別の場所に分割することをお勧めします。幸いなことに、Reactでは独自のフックを定義できます。これは、これらの状態と状態が変更されたときのロジックを共有する優れた方法です。

図3:フックによる状態管理

素晴らしい!単一コンポーネントアプリケーションから多くの要素を抽出し、純粋なプレゼンテーションコンポーネントと、他のコンポーネントをステートフルにするための再利用可能なフックをいくつか作成しました。唯一の問題は、フックでは、副作用と状態管理とは別に、一部のロジックが状態管理に属していないように見える純粋な計算であることです。

ビジネスモデルの出現

このロジックを別の場所に抽出することで多くのメリットが得られることに気づき始めたとしましょう。例えば、この分割によって、ロジックをまとまりがあり、ビューに依存しないものにすることができます。そして、いくつかのドメインオブジェクトを抽出します。

これらの単純なオブジェクトは、データマッピング(あるフォーマットから別のフォーマットへの変換)、null チェック、必要に応じたフォールバック値の使用などを処理できます。また、これらのドメインオブジェクトが増えるにつれて、継承やポリモーフィズムを使ってさらにクリーンにする必要が出てきます。このように、他の場所から役立つと感じた多くのデザインパターンを、ここフロントエンドアプリケーションに適用しました。

図4:ビジネスモデル

階層化されたフロントエンドアプリケーション

アプリケーションは進化を続け、いくつかのパターンが浮かび上がってきます。ユーザーインターフェースに属さないオブジェクトの束があり、それらは基になるデータがリモートサービス、ローカルストレージ、またはキャッシュのいずれから来ているかにも関心がありません。そこで、それらを異なるレイヤーに分割したいと考えます。レイヤー分割の詳細については、プレゼンテーション・ドメイン・データ・レイヤリングを参照してください。

図5:レイヤー化されたフロントエンドアプリケーション

上記の進化プロセスは概要であり、コードの構造、あるいは少なくとも進むべき方向について理解できるはずです。しかし、アプリケーションに理論を適用する前に考慮すべき多くの詳細があります。

以降のセクションでは、実際のプロジェクトから抽出した機能を例に、大規模なフロントエンドアプリケーションに役立つと考えるパターンと設計原則をすべて説明します。

支払い機能の導入

出発点として、単純化されたオンライン注文アプリケーションを使用しています。このアプリケーションでは、顧客はいくつかの商品を選択して注文に追加し、その後、支払い方法を選択して続行する必要があります。

図6:支払いセクション

これらの支払い方法の選択肢はサーバー側で設定されており、国によって異なる選択肢が表示される場合があります。例えば、Apple Payは一部の国でのみ人気があるかもしれません。ラジオボタンはデータ駆動型です - バックエンドサービスからフェッチされたものは何でも表示されます。唯一の例外は、設定された支払い方法が返されない場合、何も表示せず、デフォルトで「現金払い」として扱うことです。

簡潔にするために、実際の支払いプロセスはスキップし、Paymentコンポーネントに焦点を当てます。Reactのhello worldドキュメントとStack Overflowの検索をいくつか行った後、このようなコードを作成したとしましょう。

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return (
      <div>
        <h3>Payment</h3>
        <div>
          {paymentMethods.map((method) => (
            <label key={method.provider}>
              <input
                type="radio"
                name="payment"
                value={method.provider}
                defaultChecked={method.provider === "cash"}
              />
              <span>{method.label}</span>
            </label>
          ))}
        </div>
        <button>${amount}</button>
      </div>
    );
  };

上記のコードは非常に典型的です。どこかの入門チュートリアルで見たことがあるかもしれません。そして、必ずしも悪いわけではありません。しかし、上記のように、このコードは異なる懸念事項を単一のコンポーネントに混在させており、可読性をやや低下させています。

初期実装の問題点

最初に解決したい問題は、コンポーネントの多忙さです。つまり、Paymentはさまざまなことを処理し、コンテキストを切り替える必要があるため、コードの可読性が低下します。

変更を行うには、ネットワークリクエストの初期化方法コンポーネントが理解できるローカルフォーマットへのデータマッピング方法各支払い方法のレンダリング方法、そしてPaymentコンポーネント自体のレンダリングロジックを理解する必要があります。

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return (
      <div>
        <h3>Payment</h3>
        <div>
          {paymentMethods.map((method) => (
            <label key={method.provider}>
              <input
                type="radio"
                name="payment"
                value={method.provider}
                defaultChecked={method.provider === "cash"}
              />
              <span>{method.label}</span>
            </label>
          ))}
        </div>
        <button>${amount}</button>
      </div>
    );
  };

この単純な例では、現時点では大きな問題ではありません。しかし、コードが大きくなり複雑になるにつれて、リファクタリングする必要があります。

ビューコードとビュー以外のコードを別々に分割するのが良い習慣です。一般的に、ビューはビュー以外のロジックよりも頻繁に変更されるためです。また、アプリケーションの異なる側面を扱うため、それらを分離することで、特定の自己完結型モジュールに焦点を当てることができ、新機能の実装がはるかに容易になります。

ビューコードと非ビューコードの分離

Reactでは、カスタムフックを使用してコンポーネントの状態を維持しながら、コンポーネント自体をほぼステートレスに保つことができます。関数抽出を使用して、usePaymentMethodsという関数(プレフィックスuseは、関数がフックであり、内部でいくつかの状態を処理することを示すReactの慣例です)を作成できます。

src/Payment.tsx…

  const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return {
      paymentMethods,
    };
  };

これは、内部状態としてpaymentMethods配列(LocalPaymentMethod型)を返し、レンダリングで使用できるようになります。そのため、Paymentのロジックは次のように簡素化できます。

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    return (
      <div>
        <h3>Payment</h3>
        <div>
          {paymentMethods.map((method) => (
            <label key={method.provider}>
              <input
                type="radio"
                name="payment"
                value={method.provider}
                defaultChecked={method.provider === "cash"}
              />
              <span>{method.label}</span>
            </label>
          ))}
        </div>
        <button>${amount}</button>
      </div>
    );
  };

これにより、Paymentコンポーネントの問題を軽減できます。しかし、paymentMethodsを反復処理するブロックを見ると、ここで概念が欠けているように見えます。つまり、このブロックは独自のコンポーネントを持つに値します。理想的には、各コンポーネントは1つのことだけに焦点を当てるべきです。

サブコンポーネント抽出によるビューの分割

また、コンポーネントを純粋関数(つまり、任意の入力に対して出力が確定する)にすることができれば、テストの記述、コードの理解、そして他の場所でのコンポーネントの再利用に非常に役立ちます。結局のところ、コンポーネントが小さければ小さいほど、再利用される可能性が高くなります。

もう一度関数抽出を使用できます(「コンポーネント抽出」と呼ぶべきかもしれませんが、Reactではコンポーネントは関数なので)。

src/Payment.tsx…

  const PaymentMethods = ({
    paymentMethods,
  }: {
    paymentMethods: LocalPaymentMethod[];
  }) => (
    <>
      {paymentMethods.map((method) => (
        <label key={method.provider}>
          <input
            type="radio"
            name="payment"
            value={method.provider}
            defaultChecked={method.provider === "cash"}
          />
          <span>{method.label}</span>
        </label>
      ))}
    </>
  );

PaymentコンポーネントはPaymentMethodsを直接使用できるため、以下のように簡素化できます。

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods paymentMethods={paymentMethods} />
        <button>${amount}</button>
      </div>
    );
  };

PaymentMethodsは状態を持たない純粋関数(純粋コンポーネント)であることに注意してください。基本的に、文字列フォーマット関数です。

ロジックをカプセル化するデータモデリング

これまでの変更はすべて、ビューコードとビュー以外のコードを異なる場所に分割することに関するものです。うまく機能します。フックはデータのフェッチと整形を処理します。PaymentPaymentMethodsの両方とも比較的小さく、理解しやすいです。

しかし、よく見ると、まだ改善の余地があります。まず、純粋関数コンポーネントPaymentMethodsでは、支払い方法をデフォルトでチェックする必要があるかどうかを判断するロジックが少しあります。

src/Payment.tsx…

  const PaymentMethods = ({
    paymentMethods,
  }: {
    paymentMethods: LocalPaymentMethod[];
  }) => (
    <>
      {paymentMethods.map((method) => (
        <label key={method.provider}>
          <input
            type="radio"
            name="payment"
            value={method.provider}
            defaultChecked={method.provider === "cash"}
          />
          <span>{method.label}</span>
        </label>
      ))}
    </>
  );

ビュー内のこれらのテストステートメントは、ロジックのリークと見なすことができ、徐々にさまざまな場所に散らばり、変更が難しくなります。

潜在的なロジックリークのもう1つのポイントは、データを変換してフェッチする場所です。

src/Payment.tsx…

  const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return {
      paymentMethods,
    };
  };

methods.map内の匿名関数がサイレントに変換を実行しており、このロジックと上記のmethod.provider === "cash"はクラスに抽出できます。

データと動作を1か所に集約したPaymentMethodクラスを作成できます。

src/PaymentMethod.ts…

  class PaymentMethod {
    private remotePaymentMethod: RemotePaymentMethod;
  
    constructor(remotePaymentMethod: RemotePaymentMethod) {
      this.remotePaymentMethod = remotePaymentMethod;
    }
  
    get provider() {
      return this.remotePaymentMethod.name;
    }
  
    get label() {
      if(this.provider === 'cash') {
        return `Pay in ${this.provider}`
      }
      return `Pay with ${this.provider}`;
    }
  
    get isDefaultMethod() {
      return this.provider === "cash";
    }
  }

クラスを使用すると、デフォルトの現金支払い方法を定義できます。

const payInCash = new PaymentMethod({ name: "cash" });

そして、変換中に - 支払い方法をリモートサービスからフェッチした後 - その場でPaymentMethodオブジェクトを構築できます。あるいは、convertPaymentMethodsという小さな関数を抽出することもできます。

src/usePaymentMethods.ts…

  const convertPaymentMethods = (methods: RemotePaymentMethod[]) => {
    if (methods.length === 0) {
      return [];
    }
  
    const extended: PaymentMethod[] = methods.map(
      (method) => new PaymentMethod(method)
    );
    extended.push(payInCash);
  
    return extended;
  };

また、PaymentMethodsコンポーネントでは、method.provider === "cash"を使用してチェックしなくなり、代わりにgetterを呼び出します。

src/PaymentMethods.tsx…

  export const PaymentMethods = ({ options }: { options: PaymentMethod[] }) => (
    <>
      {options.map((method) => (
        <label key={method.provider}>
          <input
            type="radio"
            name="payment"
            value={method.provider}
            defaultChecked={method.isDefaultMethod}
          />
          <span>{method.label}</span>
        </label>
      ))}
    </>
  );

これで、Paymentコンポーネントを、連携して作業を完了する小さなパーツの束に再構築しています。

図7:より簡単に構成できるパーツを追加した、リファクタリングされたPayment

新構造のメリット

  • クラスを使用すると、支払い方法に関するすべてのロジックをカプセル化できます。これはドメインオブジェクトであり、UI関連の情報は含まれていません。そのため、ビューに埋め込まれている場合よりも、テストやロジックの変更がはるかに容易です。
  • 新しく抽出されたコンポーネントPaymentMethodsは純粋関数であり、ドメインオブジェクト配列のみに依存するため、テストと他の場所での再利用が非常に簡単です。onSelectコールバックを渡す必要があるかもしれませんが、それでも純粋関数であり、外部の状態に触れる必要はありません。
  • 機能の各部分が明確です。新しい要件が来た場合、すべてのコードを読むことなく適切な場所に移動できます。

この記事の例を十分に複雑にする必要があり、多くのパターンを抽出できます。これらのパターンと原則はすべて、コードの変更を簡素化するために存在します。

新しい要件:慈善団体への寄付

アプリケーションへのさらなる変更を加えて、ここで説明する理論を検討しましょう。新しい要件は、顧客が注文と一緒に慈善団体に少額の寄付をするオプションを提供することです。

例えば、注文金額が19.80ドルの場合、0.20ドルの寄付をしたいかどうかを尋ねます。ユーザーが寄付に同意した場合、ボタンに合計金額を表示します。

図8:慈善団体への寄付

変更を加える前に、現在のコード構造を簡単に見てみましょう。大きくなったときに簡単に移動できるように、異なるパーツをそれぞれのフォルダに配置することを好みます。

      src
      ├── App.tsx
      ├── components
      │   ├── Payment.tsx
      │   └── PaymentMethods.tsx
      ├── hooks
      │   └── usePaymentMethods.ts
      ├── models
      │   └── PaymentMethod.ts
      └── types.ts
      

App.tsxはメインエントリであり、Paymentコンポーネントを使用し、Paymentは異なる支払いオプションをレンダリングするためにPaymentMethodsを使用します。フックusePaymentMethodsは、リモートサービスからデータを取得し、labelisDefaultCheckedフラグを保持するPaymentMethodドメインオブジェクトに変換する役割を担っています。

内部状態:寄付への同意

Paymentに変更を加えるには、ユーザーがページのチェックボックスを選択したかどうかを示すブール値の状態agreeToDonateが必要です。

src/Payment.tsx…

  const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate ? Math.floor(amount + 1) : amount,
      tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
    }),
    [amount, agreeToDonate]
  );

関数Math.floorは数値を切り下げるので、ユーザーがagreeToDonateを選択したときに正しい金額を取得でき、切り上げ値と元の金額の差はtipに割り当てられます。

そして、ビューの場合、JSXはチェックボックスと短い説明になります。

src/Payment.tsx…

  return (
    <div>
      <h3>Payment</h3>
      <PaymentMethods options={paymentMethods} />
      <div>
        <label>
          <input
            type="checkbox"
            onChange={handleChange}
            checked={agreeToDonate}
          />
          <p>
            {agreeToDonate
              ? "Thanks for your donation."
              : `I would like to donate $${tip} to charity.`}
          </p>
        </label>
      </div>
      <button>${total}</button>
    </div>
  );

これらの新しい変更により、コードは再び複数のことを処理し始めます。ビューコードとビュー以外のコードの不要な混在に注意することが重要です。不要な混在が見つかった場合は、それらを分割する方法を探します。

これは絶対的なルールではないことに注意してください。小さくまとまりのあるコンポーネントについては、すべてをきれいにまとめておくことで、全体的な動作を理解するために複数の場所を見る必要がなくなります。一般的に、コンポーネントファイルが大きくなりすぎて理解できなくなるのを避けるように注意する必要があります。

レスキューのためのフックの抽出

ここでは、チップと金額を計算するオブジェクトが必要で、ユーザーが考えを変えたときはいつでも、オブジェクトは更新された金額とチップを返す必要があります。

つまり、次のようなオブジェクトが必要なようです。

  • 元の金額を入力として受け取る
  • agreeToDonateが変更されるたびにtotaltipを返す。

またカスタムフックを使うのに最適な場所ですね。

src/hooks/useRoundUp.ts…

  export const useRoundUp = (amount: number) => {
    const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
  
    const {total, tip} = useMemo(
      () => ({
        total: agreeToDonate ? Math.floor(amount + 1) : amount,
        tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
      }),
      [amount, agreeToDonate]
    );
  
    const updateAgreeToDonate = () => {
      setAgreeToDonate((agreeToDonate) => !agreeToDonate);
    };
  
    return {
      total,
      tip,
      agreeToDonate,
      updateAgreeToDonate,
    };
  };

そしてビューでは、初期amountとともにこのフックを呼び出し、これらの状態をすべて外部で定義できます。updateAgreeToDonate関数はフック内の値を更新し、再レンダリングをトリガーします。

src/components/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <div>
          <label>
            <input
              type="checkbox"
              onChange={updateAgreeToDonate}
              checked={agreeToDonate}
            />
            <p>{formatCheckboxLabel(agreeToDonate, tip)}</p>
          </label>
        </div>
        <button>${total}</button>
      </div>
    );
  };

なお、メッセージのフォーマット部分をヘルパー関数formatCheckboxLabelに抽出することで、コンポーネント内のコードを簡素化できます。

const formatCheckboxLabel = (agreeToDonate: boolean, tip: number) => {
  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate $${tip} to charity.`;
};

そしてPaymentコンポーネントは大幅に簡素化できます。状態はuseRoundUpフックで完全に管理されるようになりました。

UIで何か変化が起こったとき(例えば、チェックボックスの変更イベント)には、フックをビューの背後にあるステートマシンとして考えることができます。イベントはステートマシンに送られ、新しい状態が生成され、新しい状態によって再レンダリングがトリガーされます。

したがって、ここでのパターンは、状態管理をコンポーネントから離し、提示関数にすることです(これにより、これらの単純なユーティリティ関数のように、簡単にテストして再利用できます)。Reactフックは異なるコンポーネントから再利用可能なロジックを共有するために設計されましたが、1つの用途しかない場合でも、コンポーネントでのレンダリングに集中し、状態とデータをフックに保持するのに役立つため、有益だと考えています。

寄付のチェックボックスがより独立したものになるにつれて、独自のピュア関数コンポーネントに移動できます。

src/components/DonationCheckbox.tsx…

  const DonationCheckbox = ({
    onChange,
    checked,
    content,
  }: DonationCheckboxProps) => {
    return (
      <div>
        <label>
          <input type="checkbox" onChange={onChange} checked={checked} />
          <p>{content}</p>
        </label>
      </div>
    );
  };

Paymentでは、Reactの宣言型UIのおかげで、単純なHTMLのようにコードを読むことができます。

src/components/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <DonationCheckbox
          onChange={updateAgreeToDonate}
          checked={agreeToDonate}
          content={formatCheckboxLabel(agreeToDonate, tip)}
        />
        <button>${total}</button>
      </div>
    );
  };

そしてこの時点で、私たちのコード構造は下記の図のようなものになり始めます。異なる部分がそれぞれのタスクに焦点を当て、プロセスを機能させるためにどのように連携しているかに注目してください。

図9:寄付を含むリファクタリングされた支払い

端数処理ロジックに関するさらなる変更

四捨五入は今のところうまくいっていますが、ビジネスが他の国々に拡大すると、新たな要件が伴います。日本の市場では、0.1円は寄付としては少なすぎるため、同じロジックは機能せず、日本円の場合は最寄りの百円に繰り上げる必要があります。そしてデンマークでは、最寄りの十円に繰り上げる必要があります。

簡単な修正のようです。必要なのは、Paymentコンポーネントに渡されるcountryCodeだけです。

<Payment amount={3312} countryCode="JP" />;

そして、すべてのロジックがuseRoundUpフックで定義されているため、countryCodeもフックに渡すことができます。

const useRoundUp = (amount: number, countryCode: string) => {
  //...

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate
        ? countryCode === "JP"
          ? Math.floor(amount / 100 + 1) * 100
          : Math.floor(amount + 1)
        : amount,
      //...
    }),
    [amount, agreeToDonate, countryCode]
  );
  //...
};

新しいcountryCodeuseEffectブロックに追加されるたびに、if-elseが延々と続くことに気付くでしょう。そしてgetTipMessageでは、異なる国が異なる通貨記号(デフォルトのドル記号ではなく)を使用する可能性があるため、同じif-elseチェックが必要です。

const formatCheckboxLabel = (
  agreeToDonate: boolean,
  tip: number,
  countryCode: string
) => {
  const currencySign = countryCode === "JP" ? "¥" : "$";

  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate ${currencySign}${tip} to charity.`;
};

最後に変更する必要があるのは、ボタンの通貨記号です。

<button>
  {countryCode === "JP" ? "¥" : "$"}
  {total}
</button>;

ショットガンサージェリー問題

このシナリオは、多くの場所で(特にReactアプリケーションでは)見られる有名な「ショットガンサージェリー」の臭いです。これは本質的に、バグ修正や新機能の追加のためにコードを変更する必要があるときはいつでも、複数のモジュールに触れる必要があることを示しています。そして実際、これほど多くの変更を加えると、特にテストが不十分な場合は、間違いを犯しやすくなります。

図10:ショットガンサージェリー臭

上記のように、色付きの線は、多くのファイルを横断する国コードチェックの分岐を示しています。ビューでは、異なる国コードに対して別々の処理を行う必要がありますが、フックでは同様の分岐が必要になります。そして、新しい国コードを追加する必要があるときはいつでも、これらのすべての部分に触れる必要があります。

例えば、ビジネスが拡大する新しい国としてデンマークを考慮する場合、次のような多くの場所にコードが追加されます。

const currencySignMap = {
  JP: "¥",
  DK: "Kr.",
  AU: "$",
};

const getCurrencySign = (countryCode: CountryCode) =>
  currencySignMap[countryCode];

異なる場所に散らばっている分岐の問題に対する可能な解決策の1つは、ポリモーフィズムを使用してこれらのswitch文またはテーブルルックアップロジックを置き換えることです。これらのプロパティに対してExtract Classを使用し、次にReplace Conditional with Polymorphismを使用できます。

レスキューのためのポリモーフィズム

最初にできることは、すべてのバリエーションを調べて、クラスに抽出する必要があるものを確認することです。例えば、異なる国には異なる通貨記号があるため、getCurrencySignは公開インターフェースに抽出できます。また、国によって異なる四捨五入アルゴリズムがあるため、getRoundUpAmountgetTipをインターフェースに移動できます。

export interface PaymentStrategy {
  getRoundUpAmount(amount: number): number;

  getTip(amount: number): number;
}

戦略インターフェースの具体的な実装は、次のコードスニペットPaymentStrategyAUのようになります。

export class PaymentStrategyAU implements PaymentStrategy {
  get currencySign(): string {
    return "$";
  }

  getRoundUpAmount(amount: number): number {
    return Math.floor(amount + 1);
  }

  getTip(amount: number): number {
    return parseFloat((this.getRoundUpAmount(amount) - amount).toPrecision(10));
  }
}

ここでインターフェースとクラスは、UIとは直接関係ありません。このロジックはアプリケーションの他の場所でも共有したり、バックエンドサービスに移動したりできます(バックエンドがNodeで記述されている場合など)。

国ごとにサブクラスを作成し、それぞれに国固有の四捨五入ロジックを持たせることができます。しかし、関数はJavaScriptではファーストクラスシチズンであるため、サブクラスを使用せずにコードのオーバーヘッドを少なくするために、四捨五入アルゴリズムを戦略の実装に渡すことができます。そして、インターフェースの実装が1つしかないため、Inline Classを使用して、単一実装インターフェースを削減できます。

src/models/CountryPayment.ts…

  export class CountryPayment {
    private readonly _currencySign: string;
    private readonly algorithm: RoundUpStrategy;
  
    public constructor(currencySign: string, roundUpAlgorithm: RoundUpStrategy) {
      this._currencySign = currencySign;
      this.algorithm = roundUpAlgorithm;
    }
  
    get currencySign(): string {
      return this._currencySign;
    }
  
    getRoundUpAmount(amount: number): number {
      return this.algorithm(amount);
    }
  
    getTip(amount: number): number {
      return calculateTipFor(this.getRoundUpAmount.bind(this))(amount);
    }
  }

以下に示すように、コンポーネントとフック内の散在するロジックに依存する代わりに、これらはPaymentStrategyという単一のクラスのみに依存するようになりました。(赤、緑、青の正方形はPaymentStrategyクラスの異なるインスタンスを示しています)

図11:ロジックをカプセル化するクラスの抽出

そしてuseRoundUpフックでは、コードは次のように簡素化できます。

src/hooks/useRoundUp.ts…

  export const useRoundUp = (amount: number, strategy: PaymentStrategy) => {
    const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
  
    const { total, tip } = useMemo(
      () => ({
        total: agreeToDonate ? strategy.getRoundUpAmount(amount) : amount,
        tip: strategy.getTip(amount),
      }),
      [agreeToDonate, amount, strategy]
    );
  
    const updateAgreeToDonate = () => {
      setAgreeToDonate((agreeToDonate) => !agreeToDonate);
    };
  
    return {
      total,
      tip,
      agreeToDonate,
      updateAgreeToDonate,
    };
  };

Paymentコンポーネントでは、propsから戦略をフックに渡します。

src/components/Payment.tsx…

  export const Payment = ({
    amount,
    strategy = new PaymentStrategy("$", roundUpToNearestInteger),
  }: {
    amount: number;
    strategy?: PaymentStrategy;
  }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(
      amount,
      strategy
    );
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <DonationCheckbox
          onChange={updateAgreeToDonate}
          checked={agreeToDonate}
          content={formatCheckboxLabel(agreeToDonate, tip, strategy)}
        />
        <button>{formatButtonLabel(strategy, total)}</button>
      </div>
    );
  };

そして、ラベルを生成するためのいくつかのヘルパー関数を抽出するための少しのクリーンアップを行いました。

src/utils.ts…

  export const formatCheckboxLabel = (
    agreeToDonate: boolean,
    tip: number,
    strategy: CountryPayment
  ) => {
    return agreeToDonate
      ? "Thanks for your donation."
      : `I would like to donate ${strategy.currencySign}${tip} to charity.`;
  };

ビュー以外のコードを個別の場所に直接抽出したり、新しいメカニズムを抽象化してよりモジュール化されたものにすることを試みていることに気づかれたと思います。

このように考えてみてください。Reactビューは、ビュー以外のコードのコンシューマーの1つにすぎません。例えば、Vueやコマンドラインツールなどで新しいインターフェースを構築する場合、現在の実装でどの程度のコードを再利用できますか?

設計の更なる拡張:ネットワーククライアントの抽出

この「懸念事項の分離」の考え方(ビューとビュー以外のロジックを分割したり、より広範には異なる責任を独自の関数/クラス/オブジェクトに分割したりするため)を維持すると、次のステップはusePaymentMethodsフックの混在を解消することです。

現時点では、そのフックにはそれほど多くのコードはありません。エラー処理や再試行などの機能を追加すると、簡単に肥大化する可能性があります。また、フックはReactの概念であり、次の素晴らしいVueビューで直接再利用することはできません。

src/hooks/usePaymentMethods.ts…

  export const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        setPaymentMethods(convertPaymentMethods(methods));
      };
  
      fetchPaymentMethods();
    }, []);
  
    return {
      paymentMethods,
    };
  };

ここでconvertPaymentMethodsをグローバル関数として抽出しました。ネットワーク関連のすべての頭痛の種を処理するためにReact Queryなどのライブラリを使用できるように、フェッチロジックを個別の関数に移動したいと思います。

src/hooks/usePaymentMethods.ts…

  const fetchPaymentMethods = async () => {
    const response = await fetch("https://5a2f495fa871f00012678d70.mockapi.io/api/payment-methods?countryCode=AU");
    const methods: RemotePaymentMethod[] = await response.json();
  
    return convertPaymentMethods(methods)
  }

この小さなクラスは、フェッチと変換の2つのことを行います。アンチコラップションレイヤー(またはゲートウェイ[1])として機能し、PaymentMethod構造への変更を単一のファイルに限定できます。この分割の利点は、再び、このクラスが必要なときにいつでも使用できること、上記で見た戦略オブジェクトのように、バックエンドサービスでも使用できることです。

そしてusePaymentMethodsフックでは、コードは非常にシンプルになりました。

src/hooks/usePaymentMethods.ts…

  export const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      fetchPaymentMethods().then(methods => setPaymentMethods(methods))
    }, []);
  
    return {
      paymentMethods,
    };
  };

そして、クラス図は下記のようなものに変更されました。ほとんどのコードは、他の場所でも使用できるビュー以外のファイルに移動されました。

図12:より粒度の細かい分割により、各部分の責任が明確になります

これらのレイヤーを持つことのメリット

上記のように、これらのレイヤーは多くの利点をもたらします。

  1. メンテナンス性の向上:コンポーネントを個別の部分に分割することにより、コードの特定の部分にある欠陥を容易に特定して修正できます。これにより、時間と労力を節約し、変更中に新しいバグが発生するリスクを軽減できます。
  2. モジュール性の向上:レイヤー構造はよりモジュール化されているため、コードの再利用と新機能の構築が容易になります。各レイヤーでも、ビューなど、よりコンポーザブルになりがちです。
  3. 可読性の向上:コードのロジックを理解し、追跡することがはるかに容易になります。これは、コードを読んだり作業したりしている他の開発者にとって特に役立ちます。これはコードベースに変更を加えるための核心です。
  4. スケーラビリティの向上:個々のモジュールの複雑さが軽減されるため、アプリケーションは通常よりスケーラブルになります。新しい機能を追加したり、変更を加える際に、システム全体に影響を与えることなく行うことができるためです。これは、時間の経過とともに進化することが予想される大規模で複雑なアプリケーションにとって特に重要です。
  5. 他のテクノロジスタックへの移行:ほとんどのプロジェクトでは非常にまれですが、基盤となるモデルとロジックを変更せずにビューレイヤーを置き換えることができます。これは、ドメインロジックが純粋なJavaScript(またはTypeScript)コードでカプセル化されており、ビューの存在を認識していないためです。

結論

Reactアプリケーション、またはReactをビューとして使用するフロントエンドアプリケーションの構築は、新しいタイプのソフトウェアとして扱うべきではありません。従来のユーザーインターフェースを構築するためのほとんどのパターンと原則は依然として適用されます。バックエンドでヘッドレスサービスを構築するためのパターンも、フロントエンドの分野で有効です。フロントエンドでレイヤーを使用し、ユーザーインターフェースを可能な限り薄くし、ロジックをサポートするモデルレイヤーに、データアクセスを別のレイヤーに沈めることができます。

フロントエンドアプリケーションにこれらのレイヤーを持つことの利点は、他のことを心配することなく、1つの部分だけを理解する必要があることです。また、再利用性の向上により、既存のコードへの変更は以前よりも比較的管理しやすくなります。


謝辞

ドラフト版のレビューと文法および言語の問題の修正をしてくださったAndy MarksとHannah Bourkeに感謝します。

詳細な技術レビューと記事の構造に関する素晴らしい提案をしてくださったCam Jacksonに感謝します。

すべての技術的な詳細を案内していただき、このサイトに記事を公開することを可能にしてくださった私のロールモデルであるMartin Fowlerに感謝します。

脚注

1: ゲートウェイは、外部システムまたはリソースへのアクセスをカプセル化するオブジェクトです。すべての採用ロジックをコードベースに散らしたくない場合に役立ち、外部システムが変更されたときに1つの場所で変更するのが容易になります。

重要な改訂

2023年2月16日:記事の残りの部分を公開

2023年2月14日:新しい要件セクションの最初の部分を公開

2023年2月8日:2回目の記事を公開:支払い機能の紹介。

2023年2月7日:Reactアプリケーションの進化まで公開