Reactで表示とロジックのコンポーネントを分割する

プログラム
スポンサーリンク

Reactの開発環境をVS Codeで構築で、コンテナ・プレゼンテーションパターンというのを少し紹介しました。

これは、表示に関するコードをプレゼンテーション・コンポーネントに、ロジックをコンテナ・コンポーネントに分けて書きましょうというものです。本記事のタイトルのとおり、表示とロジックは分割したほうが良いという考えに基づきます。

Reactの開発環境をVS Codeで構築では他のサートへリンクを貼るだけでお茶を濁していましたが、なぜ表示とロジックを分割するのが良いのかを自分でも整理するためにこの記事を書いてみます。

(元ネタのDan Abramovのブログ記事ではPresentational Component, Container Componentですが、Presentationalは少し長いのでプレゼンテーションとしています)

なお、私がこの考え方を知ったのは、りあクト! TypeScript で始めるつらくない React 開発 第3.1版を読んだことがきっかけです。よいReactとTypeScriptの解説本なので、興味があったら読んで見るもの良いと思います。React18対応の新しい版を書き始めるらしいので、そちらを待つのも選択肢かもしれません。

この記事でわかること
  • Reactを表示とロジックで分けるメリット
  • 具体的な処理の分け方
  • 具体的に効果が出ることの例示
前提条件
  • TypeScriptとReactの基礎がわかっていること
  • Reactのバージョンは17
  • TypeScriptのバージョンは4.5
  • yarnのバージョンは1.22

この記事のソースコード

この記事のソースコードはGithubに公開しています。

GitHub - gsg0222/presentational-and-container-component
Contribute to gsg0222/presentational-and-container-component development by creating an account on GitHub.

任意のフォルダでGit cloneしてください。

git clone https://github.com/gsg0222/presentational-and-container-component.git

yarnを前提にしているので、各種コマンドは以下のとおりです。

# サンプルコードの実行
yarn start
# テストを実行
yarn test
# storybookを表示
yarn storybook

Reactで表示とロジックでコンポーネントを分けることのメリット

Reactのコンポーネントで表示とロジックを分けると良いことがあるよという記事を書いたのは、React界で有名なDan Abramovです。(その記事はこちら→Dan Abramovのブログ記事

実は2019年になってこの記事の内容はもう古いから参考にしなくていいよという注釈が入っています。

しかし、それでもなお表示とロジックの分割にはメリットがあると私は考えています。その理由は以下の通りです。

修正対象のコンポーネントがわかりやすい

ロジックと表示のコンポーネントを分けることで、どのコンポーネントを修正対象とするのかわかりやすくなります。

当然ですが、画面表示を変えたいのなら表示コンポーネント、情報の取得方法や処理方法などロジックを変えたいならロジックコンポーネントが修正対象です。

表示とロジックを1つのコンポーネントにまとめていると、どちらを対象とした修正なのかわかりにくくなります。

ユニットテストの対象を明確にできる

ユニットテストの対象は基本的にロジックに対してだけ行うことになるはずです。

表示とロジックを切り分けておけば、テストの対象はロジックの部分だけだと容易に理解できます。

また、必要に応じて表示部分のコンポーネントをモックにできるので、テスト自体もやりやすくなります。

storybookが使いやすくなる

Reactなどのフロントエンド開発では、表示コンポーネントの管理にStorybookというツールをよく使います。(Storybookのチュートリアル

簡単に説明すると、コンポーネントがどのように表示されるか、Propを変更するとどの様になるのかをブラウザ上で確認できるツールです。

表示とロジックを分割しておくと、Storybookで管理するべきコンポーネントが明確になります。また、表示コンポーネントがロジックを持たない、要するに変数となるものは基本的にPropとして渡されることになるため、入力を変更して表示を確認することも容易です。

表示とロジックが混ざっているコンポーネントの分け方

それでは、どのように表示とロジックを分割するのか例を上げたいと思います。

未分割のコード

まずは表示とロジックを分割せず一つのコンポーネントにまとめてある例です。

src/User.tsx:

import { useEffect, useState, VFC } from 'react';
import ky from 'ky';

type User = {
  login: string;
  avatar_url?: string;
};

const isUser = (arg: unknown): arg is User => {
  const u = arg as User;

  return typeof u?.login === 'string';
};

const User: VFC = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [user, setUser] = useState<User>({ login: 'Loading' });

  useEffect(() => {
    const fetch = async () => {
      setIsLoading(true);
      const response = await ky
        .get('https://api.github.com/users/defunkt')
        .json();
      if (isUser(response)) {
        setUser(response);
      } else {
        setUser({ login: 'Error' } as User);
      }
      setIsLoading(false);
    };
    void fetch();
  }, []);
  if (isLoading) return <p>Loading</p>;

  return (
    <>
      <p>Login ID : {user.login}</p>
      {user.avatar_url && <img alt="user" src={user.avatar_url} />}
    </>
  );
};

export default User;

Githubから特定のユーザ情報を取得して、ユーザIDとアバターを表示します。

もちろんこのコードでも問題なく動作します。しかし、いくつかの問題があることも事実です。

  1. 表示とロジックが別れていないので、表示だけを確認することが難しい。
  2. 単体テストがやりにくい
  3. storybookが使いにくい

表示だけの確認が難しい

例えば、異なるアバター画像のURLを渡したらどうなるのかを確認するのは非常に手間になります。

また、ローディング画面がしっかり表示されるのか目視で確認したいケースがあったとしても、表示は一瞬で終わるのでなかなか難しいです。

単体テストがやりにくい

例えばJestを使ってkyをモックして、getメソッドの戻り値を変更したとします。

その場合、確認方法は実際にレンダリングされた結果をHTMLとしてパースして、実際に渡した値が表示されていることを確認することになるでしょう。

テスト自体は可能に思えますが、表示に渡した値を直接確認するわけではないので、ちょっとまだるっこしいです。

Storybookが使いにくい

このコンポーネントでも、Storybookで表示を確認することは可能です。しかし、1つのコンポーネント内でロジックと表示が完結しているため、任意の値を表示することが難しくなっています。

表示だけのコンポーネント

それでは、表示とロジックを分割してみましょう。まずは表示だけのコンポーネントから。

src/components/pages/UserPresentation.tsx:

import { VFC } from 'react';

export type User = {
  login: string;
  avatar_url?: string;
};

type Prop = {
  user: User;
  isLoading: boolean;
};
const UserPresentation: VFC<Prop> = ({ user, isLoading }) => {
  if (isLoading) return <p>Loading</p>;

  return (
    <>
      <p>Login ID : {user.login}</p>
      {user.avatar_url && <img alt="user" src={user.avatar_url} />}
    </>
  );
};

export default UserPresentation;

見ての通りロジックを持たず、いくつかの値をPropsとして受け取りそれをもとにして表示だけを行います。

表示を変更したい場合、修正対象はこのコンポーネントだけです。

こうすることで、未分割では問題であった「表示だけの確認が難しい」と「Storybookが使いにくい」を解決できます。

問題点の解決:Storybookを使う

表示だけのコンポーネントはStorybookの利用が容易です。Storybookの使い方は今回の記事のスコープではないのでStorybookのチュートリアルを見ていただくとして、実際にStorybookを使うとどうなるか確認してみましょう。(一応、src/components/pages/UserPresentation.stories.tsxがStorybookの設定を行っているコードです)

Githubからソースをダウンロードしていたら、以下のコマンドを実行してください。

yarn storybook

少し時間が経過すると、自動的に次の画像がブラウザに表示されるはずです。

StorybookでUserPresontationを表示

画面下部の値をPropsとして渡すと、それをもとにコンポーネントのレンダリングがされます。初期値はUserPresentation.stories.tsxで設定したものになっていますが、任意の値を設定可能です。

例えばisLoadingをtrueにするとローディング画面が表示されますし

ローディング画面を表示

userのloginとavatar_urlを変更すると指定した内容を表示します。

loginとavatar_urlを変更

表示の確認がStorybookで簡単にできるようになったことを確認できたと思います。

ロジックだけのコンポーネント

続いてロジックだけのコンポーネントです。

src/containers/pages/UserContainer.tsx:

import UserPresentation, { User } from 'components/pages/UserPresentation';
import ky from 'ky';
import { useEffect, useState, VFC } from 'react';

const isUser = (arg: unknown): arg is User => {
  const u = arg as User;

  return typeof u?.login === 'string';
};
const UserContainer: VFC = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [user, setUser] = useState<User>({ login: 'Loading' });

  useEffect(() => {
    const fetch = async () => {
      setIsLoading(true);
      const response = await ky
        .get('https://api.github.com/users/defunkt')
        .json();
      if (isUser(response)) {
        setUser(response);
      } else {
        setUser({ login: 'Error' } as User);
      }
      setIsLoading(false);
    };
    void fetch();
  }, []);

  return <UserPresentation user={user} isLoading={isLoading} />;
};

export default UserContainer;

こちらはGithubからユーザ情報を取得して、表示コンポーネントに値をPropsとして渡す部分だけを切り出しています。

ロジックを変更する場合は、このコンポーネントだけた対象です。

こうすることで、「単体テストがやりにくい」が解決します。

問題点の解決:表示コンポーネントをモック化

表示とロジックでコンポーネントを分離することで、表示コンポーネントをモック化することが可能になりました。

UserContainerのユニットテストをJestで書いたものが以下のコードです。

src/containers/pages/UserContainer.spec.tsx:

import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { User } from 'components/pages/UserPresentation';
// eslint-disable-next-line
import HttpRequestMock from 'http-request-mock';
import UserContainer from './UserContainer';

// eslint-disable-next-line
const mocker = HttpRequestMock.setup();

// eslint-disable-next-line
mocker.get(
  'https://api.github.com/users/defunkt',
  '{"login": "test", "avatar_url": "http://example.com"}',
);
const mockPresentation = jest.fn();
jest.mock('components/pages/UserPresentation', () => (props: User) => {
  mockPresentation(props);

  return 'mockPresentation';
});
afterEach(() => {
  jest.restoreAllMocks();
});
test('コンポーネントとkyをモックにしてテスト', async () => {
  render(<UserContainer />);

  await waitFor(async () => {
    expect(mockPresentation).toHaveBeenCalledWith(
      await expect.objectContaining({
        user: { login: 'test', avatar_url: 'http://example.com' },
        isLoading: false,
      }),
    );
  });
});

Jestの使い方も本記事のスコープではないので行いません。簡単に説明すると、通信を行う機能とUserPresontationコンポーネントをモックにして、UserPresontationに想定の通りの値が渡されているかどうかを確認しています。

UserPresontationがどのように表示されるかではなく、どのような値が渡されるのかをテストしているため、仮にUserPresontationで表示方法が変更されてもこのテストが壊れる可能性がないのがメリットです。

表示とロジックにコンポーネントを分けた際のデメリット

これまで書いてきたとおり、表示とロジックを分けることにはメリットが多いのですが、デメリットも存在します。

  • ファイル数が多くなる
  • コンポーネントのネストが深くなる

この2つです。

私はこれらのデメリットは小さいと感じています。しかし、表示とロジックの切り分けを行うかどうか検討する際は、これらのデメリットも考慮に入れるべきです。

まとめ:よりメンテナンスしやすいコードを目指して

表示とロジックを分割しないコンポーネントには

  1. 表示とロジックが別れていないので、表示だけを確認することが難しい。
  2. 単体テストがやりにくい
  3. storybookが使いにくい

という欠点があります。コンポーネントを表示とロジックに分けることで、この問題点を解決できるということを説明してきました。

これら3つの問題点を解決することで、コードのメンテナンス性の向上が見込めます。

単体テストがやりやすくなることでよりリファクタリングしやすくなりますし、表示を変更しても単体テストが壊れません。

また、表示の確認がしやすいので、修正も容易になります。

本家のDan Abramovのブログ記事ではこの考え方は古くなっているとされていますが、依然として有効であるというのが私の考えです。

すべてのプロジェクトで導入するべきとまでは言えませんが、テストやメンテナンス性を優先するのであれば検討してみてください。

コメント

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