🐙

役割駆動開発のすすめ[フロントエンド編]

2021/11/25に公開

役割に注目するコンポーネントの実装

あなたはhogeとfugaをサーバーから取得して、画面に表示するコンポーネントを実装しているとします。
つぎのうち、どちらのコンポーネントの書き方がよいと思いますか?

パターン1

const HogeFugaComponent = () => {
  const [isLoadingHoge, setIsLoadingHoge] = useState(false);
  const [hoge, setHoge] = useState(null);
  const [isLoadingFuga, setIsLoadingFuga] = useState(false);
  const [fuga, setFuga] = useState(null);

  useEffect(() => {
    fetchHoge()
    .then(hoge => {
      setHoge(hoge);
    })
  })

 useEffect(() => {
    fetchFuga()
    .then(resFuga => {
      setFuga(resFuga);
    })
  })

  return <div>{fuga}{hoge}</div>
}

パターン2

const HogeFugaComponent = () => {
  // hogeを表示する機能
  const [isLoadingHoge, setIsLoadingHoge] = useState(false);
  const [hoge, setHoge] = useState(null);

  useEffect(() => {
    fetchHoge()
    .then(hoge => {
      setHoge(hoge);
    })
  })

// fugaを表示する機能
const [isLoadingFuga, setIsLoadingFuga] = useState(false);
  const [fuga, setFuga] = useState(null);

 useEffect(() => {
    fetchFuga()
    .then(resFuga => {
      setFuga(resFuga);
    })
  })

  // 表示
  return <div>{fuga}{hoge}</div>
}

Reactに馴染みのない方に説明すると、useStateというのはReactにおける状態管理関数です。返り値は配列なのですが、1つ目に実際の状態、2つ目にsetterが格納されております。そしてuseEffectというのは、Reactコンポーネントにおいてサーバーとの通信など、副作用が生じるコードを書く場所になります。useEffectはコンポーネントがマウントされると実行されます。

僕は、パターン2をおすすめします。なぜかというとパターン2のように関連するものを近くに集めたほうがモジュール化がしやすいからです。Reactにおけるモジュール化はコンポーネント化やカスタムフックなどで実現できます。

const HogeFugaComponent = () => {
  // hogeを表示する機能
  const [hoge, isLoadingHoge] =  useHoge(); // カスタムフック。実装は省略

// fugaを表示する機能
const [fuga, isLoadingFuga] = useFuga(); // カスタムフック。実装は省略

  // 表示
  return <div>{fuga}{hoge}</div>
}]

パターン2のように書くために

パターン1もパターン2も、どちらも似たような機能を集めています。パターン1ではReactが提供するコンポーネントの機能単位で整理しました。パターン2ではコンポーネントが提供したい機能単位で整理しました。

パターン2のように書くにはどうしたらいいでしょうか?それは、コンポーネントの役割を明示的に書くことで実現できます。次のようにdocとして役割を箇条書きし、それをコメント分割でコード内に示してあげます。

/**
 * 役割:
 * - hogeを表示する機能
 * - fugaを表示する機能
 */
const HogeFugaComponent = () => {
  /* ------------------------ */
  /*     hogeを表示する機能     */
  /* ------------------------ */
  const [hoge, isLoadingHoge] =  useHoge();

  /* ------------------------ */
  /*     fugaを表示する機能     */
  /* ------------------------ */
const [fuga, isLoadingFuga] = useFuga();

  // 表示
  return <div>{fuga}{hoge}</div>
}

わざわざこのように書く人はいないですよね。ここが役割をコメントとしてコードに明示するメリットです。

/**
 * 役割:
 * - 状態保持機能
 * - 副作用機能
 */
const HogeFugaComponent = () => {
  /* ------------------------ */
  /*        状態保持機能        */
  /* ------------------------ */
  const [isLoadingHoge, setIsLoadingHoge] = useState(false);
  const [hoge, setHoge] = useState(null);
  const [isLoadingFuga, setIsLoadingFuga] = useState(false);
  const [fuga, setFuga] = useState(null);

  /* ------------------------ */
  /*         副作用機能         */
  /* ------------------------ */
  useEffect(() => {
    fetchHoge()
    .then(hoge => {
      setHoge(hoge);
    })
  })

 useEffect(() => {
    fetchFuga()
    .then(resFuga => {
      setFuga(resFuga);
    })
  })

  // 表示
  return <div>{fuga}{hoge}</div>
}

実際の例

/**
 * 役割:
 * - チャンネルを検索できる。
 * - チャンネル一覧を表示する。 -> ChannelListへ委譲
 * - チャンネルを選択すると、チャット画面に移動する。 -> ChannelListへ委譲
 */
export const ChannelIndex: React.FC = () => {
  /* ------------------------ */
  /*            共通            */
  /* ------------------------ */
  const history = useHistory();
  const location = useLocation();
  const params = queryString.parse(
    location.search,
  ) as Partial<ChannelIndexPageQuery>;

  /* ------------------------ */
  /*          チャンネル一覧表示         */
  /* ------------------------ */
  const [isLoading, setIsLoading] = useState(false);
  const [channels, setChannels] = useState<Channel[]>([]);
  const [total, setTotal] = useState(0);

  // URLが変更されると(つまり検索条件が変更すると)、ページの最初までスクロール
  useEffect(() => {
    window.scrollTo(0, 0);
  }, [location]);

  /* ------------------------ */
  /*           チャンネル検索          */
  /* ------------------------ */
  // NOTE: 検索は基本的に
  // 1.urlにpush
  // 2.urlの変更を検知してurlからparamsを取得しfetch
  // という流れで行われる。
  // この方針をとった理由としては、検索条件を内部stateではなくurlに保持することによって、リンクでの共有が可能になるから。

  const PAGE_UNIT = 20;

  const currentPage = Number.parseInt(params.currentPage || '1', 10);

  const handleSearchPage = (page: number) => {
    history.push({
      search: queryString.stringify({
        ...params,
        currentPage: page.toString(),
      }),
    });
  };

  const handleSearchSubmit = (text: string) => {
    history.push({
      search: queryString.stringify({
        ...params,
        keyword: text,
      }),
    });
  };

  useEffect(() => {
    const offset = (currentPage - 1) * PAGE_UNIT;
    const limit = PAGE_UNIT;

    setIsLoading(true);

    ChannelRequest.search({
      keyword: params.keyword,
      offset: offset.toString(),
      limit: limit.toString(),
    })
      .then((res) => {
        if (!res.isSuccess) {
          notify('error', res.error);

          return;
        }

        setTotal(res.header.total);
        setChannels(res.body);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, [
    // NOTE: paramsで指定するとループするため、明示的に依存プロパティを指定する。
    currentPage,
    params.keyword,
    params.limit,
    params.offset,
  ]);

  return (
    <div className={classes.Cover}>
      <Container className={classes.Container}>
        <Search className="mb-3 h-2" onSubmit={handleSearchSubmit} />
        <ChannelList
          channels={channels}
          isLoading={isLoading}
          currentPage={currentPage}
          pageUnit={PAGE_UNIT}
          total={total}
          handlePage={handleSearchPage}
          searchKeyword={params.keyword || ''}
        />
      </Container>
    </div>
  );
};

テストでも、対応する役割をチェックすればいいと思います。

describe('<ChannelIndex />', () => {
  describe('チャンネルを検索できる。', () => {
    test.todo('チャンネルをキーワードで検索できる');
    test.todo('次のページのチャンネルを検索できる');
    test.todo('前のページのチャンネルを検索できる');
  });
});

おわり

以下のこと考えているときに、この書き方をひらめきました。
https://zenn.dev/dove/articles/3fe90b2135e4c7

結局TDDによる「意図によるプログラミング」と同じ効果になりそうです。
https://zenn.dev/dove/articles/2f0ec6786dba10

Discussion