Reactコンポーネントの抽象化とインターフェースのリファクタリング

12 min読了の目安(約7600字TECH技術記事
Discuss1Likes61

記事の概要と動機

Takepepeさんの「AtomicDesign 境界線のひき方」という記事を読んでいて、はたと気づいた。「限定的コンポーネントを横断的なものに移行する」という箇所は、SOLID原則のISPとそのリファクタリングの話だ。ISP(Interface Segregation Principle)とはインターフェース分離原則である。

コンポーネントは、はじめは限定的コンテキストで実装するべきでしょう。共通利用される頃合いに、リファクタリングすれば十分です。その際に忘れてはならないことが「抽象化」です。

この記事は、Takepepeさんの記事中の以下の一文に対して、インターフェースという観点から解説を加えた返歌、つまりアンサーソングである。

コンポーネントのインターフェース

フロントエンドのコンポーネントのインターフェースとは、単純化するとPropsの型である。

type ArticlesListCardProps = {
  title: string;
  author: string;
  description: string;
}

export const ArticlesListCard: React.FC<ArticlesListCardProps> = (props) => (
  // ...
)

Reactコンポーネントとは、Propsを引数にとり、ReactElementまたはnullを返す関数である。ReactコンポーネントにおいてPropsは可変である一方、返り値の型は不変である。そこで、関心の対象を可変であるPropsに絞ることにする。これが、「コンポーネントのインターフェースとは、単純化するとPropsの型」と述べた理由だ。

具象コンポーネント

Propsの型が定義されていることで、親コンポーネント(依存する側)が子コンポーネント(依存される側)の使い方を知らなくて済む。つまり、ParentComponentArticlesListCardという具象コンポーネントではなく、type ArticlesListCardPropsという抽象に依存する。

(オブジェクト指向言語では抽象クラスと具象クラスの区別が存在する一方、Reactでは抽象コンポーネントは存在しない。以下ではPropsの型という抽象との対比を強調するため、一般的なReactコンポーネントを具象コンポーネントと呼ぶことにする)

import { ArticlesListCard } from './ArticlesListCard'

const ParentComponent = React.FC = () => (
  <div>
    <ArticlesListCard title={'タイトル'} author={'著者名'} description={'説明'} />
    <SomeComponent />
  </div>
)

ここでは、要件が加わり、ArticlesListCardOtherArticlesListCardというコンポーネントに変更すると仮定する。DOMの構造は変更されているが、利用するPropsの型は同じだ。

type ArticlesListCardProps = {
  title: string;
  author: string;
  description: string;
}

export const ArticlesListCard: React.FC<ArticlesListCardProps> = (props) => (
  <div>
    <h1>{props.title}</h1>
    <p>{props.author}</p>
    <p>{props.description}</p>
  </div>
)

export const OtherArticlesListCard: React.FC<ArticlesListCardProps> = (props) => (
  <div>
    <h1>{props.title}</h1>
    <p>{props.description}{props.author}</p>
  </div>
)

すると、ParentコンポーネントではArticlesListCardOtherArticlesListCardに変更する修正だけで済む。親コンポーネントが渡すPropsに変更がないことに注目頂きたい。

import { OtherArticlesListCard } from './ArticlesListCard'

const ParentComponent = React.FC = () => (
  <div>
    <OtherArticlesListCard title={'タイトル'} author={'著者名'} description={'説明'} />
  </div>
)

Propsの変更が不要である理由は、ParentComponentは具象コンポーネントArticlesListCardに依存しており、ArticlesListCardArticlesListCardPropsという抽象に依存しているるためだ。

想定通り動作していることを確認したら、OtherArticlesListCardの名前をArticlesListCardに変更し、ArticlesListCardを削除する。これで安全なリファクタリングが可能だ。

ただ、DOMの変更のみであれば直接JSXを書き換えるだけで十分だ。実務でこの手順を踏んでいる方はいないだろう。ここでは説明のためにあえて上記の手順を踏んだ。重要なのは「インターフェースを固定したまま、具体的な実装を差し替える」という意識である。

似て非なるコンポーネントの登場

次に、似て非なるコンポーネントが登場したとする。Propsの型は以下の通りだ。

Takepepeさんの記事を引用すると、ArticlesListCardとほとんど同じである。

「author・publisher」「description・body」は同じ場所にレンダリングされる値です。

type PublishersListCardProps = {
  title: string;
  publisher: string;
  body: string;
}

export const PublishersListCard: React.FC<PublishersListCardProps> = (props) => (
  <div>
    <p>{props.body}{props.publisher}</p>
    <h2>{props.title}</h2>
  </div>
)

説明を簡単にするために、PublishersListCardを先ほどのParentComponentで利用する。

import { OtherArticlesListCard } from './ArticlesListCard'
import { PublishersListCard } from './PublishersListCard'

const ParentComponent = React.FC = () => (
  <div>
    <ArticlesListCard title={'タイトル'} author={'著者名'} description={'説明'} />
    <PublishersListCard title={'タイトル'} publisher={'出版社名'} body={'本文'} />
  </div>
)

コンポーネントを対比すると共通点を可視化できる。ParentComponentArticlesListCardPublishersListCardという具象に依存している。

依存関係を図示する

ParentComponentと2つのコンポーネントに対する依存を図示する。

UML図

この図を見て、オブジェクト指向言語でのプログラミングに慣れた方であれば、依存の方向を逆転させることを考えるだろう。つまり、ParentComponentは具体的な2つのコンポーネントに依存するのではなく、Propsの型に依存するべきだと思うはずだ(DIP)。

しかし、筆者の知る限りReactにはコンポーネントのDI機構はないため、(一つずつコンポーネントをPropsとして渡せばDI自体は可能だが)具象コンポーネントを利用する方が一般的である。

ただし、TypeScriptやFlowで記述している場合、親コンポーネント側に明示的に子コンポーネントのPropsの型を記述しなくても、親コンポーネント側から渡すPropsの値との整合性をチェックしてくれる。このため、厳密には正確ではないと自覚しつつ、筆者はコンポーネントの依存関係を以下のように理解、解釈している。

UML図

コンポーネント同士の依存関係を明示できたため、インターフェースをリファクタリングする必要性を容易に理解できるだろう。

インターフェースをリファクタリングする

もう一度Propsの型定義を見てみよう。プロパティ名以外の違いはないことに気付くはずだ。つまり、2つのPropsの型には共通点をまとめるというリファクタリングの余地があるのだ。

type ArticlesListCardProps = {
  title: string;
  author: string;
  description: string;
}

type PublishersListCardProps = {
  title: string;
  publisher: string;
  body: string;
}

そこで、Takepepeさんは以下のように型を抽象化している。コンポーネントでPropsの使われ方を参照しつつ、プロパティ名が一段階抽象化されていることが読み取れる。

type ListCardProps = {
  title: string;
  subTitle: string;
  main: string;
}

このListCardProps型を利用して、コンポーネントのリファクタリングを実施する。

コンポーネントをリファクタリングする

ArticlesListCardPublishersListCardListCardPropsに依存させ、具象コンポーネントをリファクタリングする。まずはコンポーネントが受け取るPropsを変更する。

type ListCardProps = {
  title: string;
  subTitle: string;
  main: string;
}

export const ArticlesListCard: React.FC<ListCardProps> = (props) => (
  <div>
    <h1>{props.title}</h1>
    <p>{props.main}{props.subTitle}</p>
  </div>
)
type ListCardProps = {
  title: string;
  subTitle: string;
  main: string;
}

export const PublishersListCard: React.FC<ListCardProps> = (props) => (
  <div>
    <p>{props.main}{props.subTitle}</p>
    <h2>{props.title}</h2>
  </div>
)

ParentComponentは以下のようになる。この段階でParentComponentは具象(ArticlesListCardPublishersListCard)に依存している。抽象(ListCardProps)には、両コンポーネントを通して間接的に依存している。

import { ArticlesListCard } from './ArticlesListCard'
import { PublishersListCard } from './PublishersListCard'

const ParentComponent = React.FC = () => (
  <div>
    <ArticlesListCard title={'タイトル'} subTitle={'著者名'} main={'説明'} />
    <PublishersListCard title={'タイトル'} subTitle={'出版社名'} main={'本文'} />
  </div>
)

次に、ListCardPropsという抽象に依存したコンポーネントを同一ファイルListCardにまとめる。

type ListCardProps = {
  title: string;
  subTitle: string;
  main: string;
}

export const ArticlesListCard: React.FC<ListCardProps> = (props) => (
  <div>
    <h1>{props.title}</h1>
    <p>{props.main}{props.subTitle}</p>
  </div>
)

export const PublishersListCard: React.FC<ListCardProps> = (props) => (
  <div>
    <p>{props.main}{props.subTitle}</p>
    <h2>{props.title}</h2>
  </div>
)

これによりParentComponentは以下のように書き換えられる。

import { ArticlesListCard, PublishersListCard } from './ListCard'

const ParentComponent = React.FC = () => (
  <div>
    <ArticlesListCard title={'タイトル'} subTitle={'著者名'} main={'説明'} />
    <PublishersListCard title={'タイトル'} subTitle={'出版社名'} main={'本文'} />
  </div>
)

これでリファクタリングが完了し、依存関係を整理することができた。

改めて依存関係を図示する

リファクタリングの結果、コンポーネントの依存関係は以下のように変化した。

UML図

しかし、リファクタリング前に図示した時と同様に、ParentComponentを実装している間、私には依存関係が以下のように見えるのである。

UML図

最初の図と比べてみて欲しい。

具象ではなく抽象に依存する

この記事では、Takepepeさんの記事を元に、インターフェースという観点からコンポーネントとPropsのリファクタリングの過程を示した。

実はTypeScriptでPropsの型を記述すると、コンポーネントからインターフェースを分離できているのである。しかし、その分離、つまり抽象化の仕方がまずいとコンポーネントは複雑性を抱えたままになる。

早すぎる抽象化は害悪である。似て非なるコンポーネントが登場した時がリファクタリングのチャンスだ。そして具象は変わりやすい。変わりにくい抽象に依存しよう。

この記事ではISPを取り上げた。しかし、SRP、OCP、DIPなど他の重要な原則はフロントエンドのプログラミングにも通じる箇所がある。

フロントエンドエンジニアの皆さんも、SOLID原則を意識してプログラミングに取り組んでみて欲しい。自分の見てきた世界が一変する感覚を味わった時には、既にあなたのスキルはレベルアップしている。

(ちなみにLSPは探究中、というかReactでは「継承よりコンポジションを好む」ため、特に意識しなくていいのではないかとも思っている。ReactにもLSPが役立つよ、という知見をお持ちの方はぜひコメント欄で教えてください)