46
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

GoodpatchAdvent Calendar 2020

Day 10

Reactのパターン(Composition, Compound Components)を使ってリファクタリング

Last updated at Posted at 2020-12-10

この記事はGoodpatch Advent Calendar 2020の10日目となります。

話したいこと(結論)

Reactのコンポーネントで、propsとか分岐が増えてきたぞ、と思ったら、CompositionとCompound Componentを使うのがおすすめ:

  • propsをバケツリレーして、ネストの下層の振る舞いが分かりづらいなら、Compositionを使ってネストの中身を親から直接参照にする
  • Compositionを使うとなんでもchildrenに渡してしまうことになるが、渡すデータを制御したければCompound Componentsを使う

※ 今回は、リファクタリングとはなんぞや、とかリファクタリングのタイミングなどの部分には触れていません。また、React Hooksは使いませんのでご注意ください!

前段

なぜリファクタリングについて書くのか?

Strapの開発チームでは、日々の開発リソースの中で割合を決めて、技術的な返済に充てることにしています。チーム全体で、技術的負債の返済の価値を共有しあって、このような方針で進められることは、開発者として嬉しい環境です。

ちゃんと時間を使うんだから、意味のあるリファクタリングにしたいと思い、色々と調べたり社内外の師匠たちから学び、いくつか良い方法と出逢ったので紹介したいと思い、書きました。

なぜリファクタリング目線のパターン紹介なのか?

Reactのみならず、デザインパターンを紹介している素晴らしい本や記事はたくさん見かけますが、リファクタリングと言う切り口ではあまり紹介されていないのかなと思っていました。

「この実装、あまり良くないかもしれない、、、」と言うざわつきを感じながら、特定のパターンに置き換えるという発想までなかなか到達出来ずにいた過去の自分の例も考えると、現状複雑になってしまった構造をどんなパターンで解消出来るかどうか、と言う視点での記事も有用かなと思いました。

なので、一つ例を紹介して、その全体をどうやって改善出来るかを包括的に紹介出来ればと思います。

メンバー管理UIがどんどんカオスになっていく過程を考える

※ 以下の例は、私が勝手に考えた架空の事例です。また、Typeの記述に一部抜粋があったり、命名などはあまり正確には設計していないのでご了承ください 🙏

「メンバーのリストを表示する」というUIを例としてあげます。まずはどのようにカオスが作り上げられるかを想像してみます。

1. まずはシンプルなUIから始まる

元々はシンプルに名前とアバターだけを表示させるコンポーネントでした。
export (4).png

//使うtype
type Member = {
	id: number,
	name: string,
	avatar?: string
}

//MemberList.tsx
const MemberList = ({ members }) => (
	<ul>
		{
			members.map(member => (
				<MemberListItem
					key={member.id}
					avatar={member.avatar}
					name={member.name} />
			)
		}
	</ul>
)

//MemberListItem.jsx
const MemberListItem = ({ avatar, name }) => (
	<li><img src={avatar ? avatar : placeholder } />{name}</li>
)

2. 「権限やメニューを増やしたいぞ」

権限の設定やメニューも増えて、以下のような仕様になりました。
export (2).png

前までの MemberListItem の中に、メニューの内容を表示させる Menu が入りました。そして、自分自身かどうかによる判定と、権限による判定で見せる値と呼び出すアクションを変更しました。

メンバーの情報(アバター、名前、メールアドレス)はとりあえず全部 member として渡すことにしました。

//🤮よくない例だよ 🤮

//使うtype
type Member = {
	id: string,
	name: string,
	avatar?: string,
	email: string,
	role: 'admin' | 'member' | 'guest'
}

//MemberList.jsx
const MemberList = ({ members }) => (
	<ul>
		{
			members.map(member => (
				<MemberListItem
					key={member.id}
					member={member} />
			)
		}
	</ul>
)

//MemberListItem.jsx
const MemberListItem = ({ member }) => (
	<li>
		<img src={member.avatar ? member.avatar : placeholder} />
		<div>
			<div>{member.name}</div>
			<div>{member.email}</div>
		</div>
		{member.role === "guest" && <div>ゲスト</div>}
		<Menu member={member} />
	</li>
)

//Menu.jsx
class Menu extends React.Component = {
	leave = () => { 退出処理 }
	makeOtherLeave = () => { メンバー削除処理 }

	render = () => {
		if(member.role === "admin") return null;
		return (
			//...メニューのトグルボタンをここで実装すると仮定
			//メニューの中身↓
			<div>
				<button onClick={member.id === currentUser.id ? leave : makeOtherLeave }>
					{member.id === currentUser.id ? "退出する" : "退出させる"}
				</button>
			</div>
		)
	}
}

この辺りから胸のざわめきが聞こえてきますが、一旦これで落ち着かせたとしましょう。

3. 「このコンポーネント、他のところにも使おうか」

月日が経ち、招待しているメンバーのリストも、このUIと同じ様に表示したらいいのでは、という話が上がりました。そして図の様な仕様となりました。

export (3).png

今までは、メンバーリストの中身は Member というタイプのみが許容されていましたが、招待メンバーの場合は、アバターも名前も未定のため、新しい Invitation タイプを使うことになります。

Invitation には今のところ権限などもないので、 Member タイプのプロパティを全てoptionalにした新しいタイプに適応させています 😱

//🤮よくない例だよ 🤮

//使うtype
type Member = {
	id: string,
	name: string,
	avatar?: string,
	email: string,
	role: 'admin' | 'member' | 'guest'
}
type Invitation = {
	id: string,
	email: string
}
type BadMemberType = Partial<Member> //以下のコンポーネントのmemberではこれを使う

//MemberList.jsx
const MemberList = ({ members }) => (
	<ul>
		{
			members.map(member => (
				<MemberListItem
					key={member.id}
					member={member} />
			)
		}
	</ul>
)

//MemberListItem.jsx
const MemberListItem = ({ member }) => (
	<li>
		<img src={member.avatar ? member.avatar : placeholder} />
			{member.name ? (
					<div>
						<div>{member.name}</div>
						<div>{member.email}</div>	
					</div>
					{member.role === "guest" && <div>ゲスト</div>}
				): (<div>{member.email}</div>)
			}
		<Menu member={member} />
	</li>
)

//Menu.jsx
class Menu extends React.Component {
	leave = () => { 退出処理 }
	makeOtherLeave = () => { メンバー削除処理 }
	deleteInvitation = () => { 招待削除処理 }

	render = () => {
		const { member } = this.props;
		if(member.role && member.role === "admin") return null;
		return (
			//...メニューのトグルボタンをここで実装すると仮定
			//メニューの中身↓
			<div>
				{member.role ?
					(
						<button onClick={member.id === currentUser.id ? this.leave : this.makeOtherLeave }>
							{member.id === currentUser.id ? "退出する" : "退出させる"}
						</button>
					):(
						<button onClick={this.deleteInvitation}>削除する</button>
					)
				}
			</div>
		)
	}
}

一応動くのですが、Typeも崩壊していて、このコードは後の改修の際に憎しみを生んでしまう可能性があります。リファクタをしてみましょう。

この事例をリファクタリーング 💪

1. 上から下へと渡され続けるpropsをなんとかしたい

bucket_relay_nimotsu.png

Kent C. Doddsさんが言うProp Drilling なるものですが、日本人的に言うと「propsのバケツリレー」です。

ここでは、 MemberListItem とその中にある Menu どちらにも member と言う物がバケツリレーの様に上から下へ渡されています。

この様にバケツリレーをしていくと、「どの階層で何が起こっているのか」を、一番親の階層 MemberList 、もしくは MemberListを使っているページなどのコンポーネントで把握することはほぼ不可能になってしまいます 😭

解決方法: Composition

このバケツリレー問題の解決方法には、Context API を使う方法など色々ありますが、Compositionという方法を紹介します。

Compositionとは、コンポーネントをネストするのではなく、 props.children として渡してしまう、と言う方法です。例えば、今回の例で言うと、 Menu のボタンの中身は MemberListItemchildren として制御することが出来るはずです。

//MemberList.tsx
const MemberList = ({children}) => <ul>{children}</ul>

//MemberListItem.tsx
const MemberListItem = ({ children, member }) => (
	<li>
      <img
        src={member.avatar ? member.avatar : PLACEHOLDER}
        style={{width: 40}}
        alt="avatar" 
      />
			{member.name ? (
          <>
            <div>
              <div>{member.name}</div>
              <div>{member.email}</div>	
            </div>
            {member.role === "guest" && <div>ゲスト</div>}
          </>
				): (<div>{member.email}</div>)
			}
		  <Menu>{children}</Menu>
    </li>
)

//Menu.tsx
const Menu = ({ children }) => {
	if(!children) return null;
	return (
		//...メニューのトグルボタンをここで実装すると仮定
		//メニューの中身↓
		<div>{children}</div>
	)
};

実際にこのコンポーネントを使うときは、こんな感じです。

//参加しているメンバーの一覧画面 : JoinedMembersPage.tsx
class JoinedMembersPage extends React.Component {
  leave = () => { 退出処理 }
	makeOtherLeave = () => { メンバー削除処理 }

  render = () => (
      <MemberList>
        {
          JOINED_MEMBERS.map(member => (
            <MemberListItem key={member.id} member={member}>
              {member.role !== "admin" && (
                <button onClick={ member.id === currentUser.id ? this.leave : this.makeOtherLeave} >
                  {member.id === currentUser.id ? "退出する" : "退出させる"}
                </button>
              )}
            </MemberListItem>
          ))
        }
      </MemberList>
  )
}
//招待しているメンバーの一覧画面: InvitedMembersPage.tsx
class InvitedMembersPage extends React.Component {
  deleteInvitation = () => { 招待削除処理) }
  render = () => (
      <MemberList>
        {
          INVITATIONS.map(member => (
            <MemberListItem key={member.id} member={member}>
              <button onClick={this.deleteInvitation}>削除する</button>
            </MemberListItem>
          ))
        }
      </MemberList>
  )
}
  • 一番上の階層( JoinedMembersPage, InvitedMembersPage)から、 MemberList の中のメニューが何をやっているかが把握しやすい
  • Menu コンポーネントの関心が完全にUIとトグルのロジックのみに切り分けられるため、拡張がしやすくなる(今後他のパターンのメニューが増えた時に、良くない例の Menu のようにどんどんif文やswitch文が膨れ上がったりしない)
  • Menu とロジックが切り離されたため、単体でのテストがしやすいコンポーネントになる

などなどいいことがあります。

解決方法' : Compositionの応用

あともう少しかゆい部分があります。それは、 MemberListItem が微妙に整理できていません。

MemberListItem には、「アバターなどのメンバーの情報を表示する部分」と「メニューを表示する部分」がありますが、今は「メニューを表示する部分」だけを children として渡しており、情報の部分はなんとも言えない謎ロジックが残ってしまっている状態です。

ただ、コンポーネントに持たせられる children は一つなので、どうすべきか、、、

これについては、「 childrenに変わるプロパティを作りレンダーする」と言う方法も解決策になると思っています。

//MemberListItem.tsx
const MemberListItem = ({ children, menuChildren }) => (
	<li>
		{children}
		<Menu>{menuChildren}</Menu>
	</li>
)
//参加しているメンバーの一覧画面 : JoinedMembersPage.tsx
class JoinedMembersPage extends React.Component {

	//①menuChildrenでレンダーする内容を関数化
	renderMenuChildren = (member: Member): React.ReactNode | null => {
        if(member.role === "admin") return null;
        return (
          <button onClick={ member.id === currentUser.id ? this.leave : this.makeOtherLeave} >
            {member.id === currentUser.id ? "退出する" : "退出させる"}
          </button>
        )
    }

	render = () => (
		<MemberList>
        {JOINED_MEMBERS.map(member => (
            <MemberListItem
              key={member.id}
              menuChildren={this.renderMenuChildren(member)}>//②menuChildrenのpropとしてMemberListItemに渡す
                <img
                  src={member.avatar ? member.avatar : PLACEHOLDER}
                  style={{width: 40}}
                  alt="avatar" 
                />
                <div>
                  <div>{member.name}</div>
                  <div>{member.email}</div>	
                </div>
                {member.role === "guest" && <div>ゲスト</div>}
            </MemberListItem>
          ))
        }
      </MemberList>
		)
	}
}

2. 構造のルールを担保したい

上記のCompositionで、ある程度コードの拡張性が改善しました。

ただ、ある意味拡張出来すぎて、秩序を保つのが難しくなってしまう危険が増えました。

先ほどの例で言うと、例えば MemberListMemberListItem の中は children として渡している、つまりなんでもアリ状態です。

<MemberList>このchildrenにはなんでも入れられるよ</MemberList>

でも、私たちはメンバーリストの中にはメンバーが入って欲しいのです。

カンガルーのお母さんの袋の中にはカンガルーの子供だけが入っていて欲しいのです。逆に、カンガルーの子供は常にお母さんの袋の中にいて欲しいのです 🦘

animal_kangaroo.png

解決方法:Compound Components

Compound Componentsを使うと、この問題を解決することが出来ます。

Compound Componentsとは、ざっくりと言うと「HTMLの selectoption のような、中身の構造を定義するパターン」です。

//こうじゃなくて
const options = [
	{ value: "January", label="1月" },
	{ value: "February", label="2月" },
	{ value: "March", label="3月" },
]
<select options={options} />

//こんなイメージじゃ
<select>
	<option value="January">1月</option>
	<option value="February">2月</option>
	<option value="March">3月</option>
</select>

propsとしてoptionsを渡すやり方が良くないという訳ではないのですが、このように、Compound Componentを使うことで、より構造が分かりやすく、制御しやすくなるメリットがあります。

Compound Componentsとはなんぞや、と言うことについては以下の記事で詳しく書かれているので、そちらを参照してください。
Reactのデザインパターン Compound Components

では、実際にコードを書き換えてみます。

//MemberList.tsx
import { MemberListItem } from 'MemberListItem';

class MemberList extends React.Component {
	static ListItem = MemberListItem;//①

	render = () => (
		{ React.Children.map(children, child => {
	    if (!React.isValidElement(child) || child.type !== MemberList.ListItem) return null; //②
			return React.cloneElement(child);
	    })
		}
	)
}

① ListItemのコンポーネントは MemberListItemを使いますよ
child のタイプとしては ListItem 以外は何も見せません(これはerrorでも良いかもですが)
と言うことがここで指定されました。

そして、実際にこのコンポーネントを使うページ側はどうなるかと言うと:

//参加しているメンバーの一覧画面 : JoinedMembersPage.tsx
class JoinedMembersPage extends React.Component {

	...色々

	render = () => (
			<MemberList>
				{members.map(member => (
					<MemberList.ListItem> //<- ここが変わりました 
						{リストの中身}
					</MemberList.ListItem>
				))}
			</MemberList>
	)
	
}

MemberListMemberListItem を直接入れ込むのではなく、 MemberList.ListItem とすることで、二つのコンポーネントの関係性は MemberList で管理することが出来ます。

この要領で MemberList 以外にも、 MemberListItemMenu なども整理出来ますが、長くなるのでやめます。

まとめ

かなり荒いと思いますが、2つのパターンを使ってリファクタリングを試みてみました。
フルバージョンのコードは一応Githubに置いておきました(それぞれのステップごとにブランチを切っています)。

ここまで読んでいただきありがとうございます。

最後に、パターンについて色々と勉強させていただいた内外の師匠に感謝いたします!!🐠
色々と深く学びたい方は、Epic Reactというオンラインコースもおすすめです。

みなさま良いクリスマスをお過ごしください!
スクリーンショット 2020-12-10 11.45.04.png
(Strapではクリスマスカードも作ることが出来ます)

参考

One React mistake that's slowing you down | Epic React by Kent C. Dodds
Prop Drilling
Advanced React Component Patterns
Reactのデザインパターン Compound Components

46
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
46
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?