この記事は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から始まる
元々はシンプルに名前とアバターだけを表示させるコンポーネントでした。
//使う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. 「権限やメニューを増やしたいぞ」
権限の設定やメニューも増えて、以下のような仕様になりました。
前までの 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と同じ様に表示したらいいのでは、という話が上がりました。そして図の様な仕様となりました。
今までは、メンバーリストの中身は 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をなんとかしたい
Kent C. Doddsさんが言うProp Drilling なるものですが、日本人的に言うと「propsのバケツリレー」です。
ここでは、 MemberListItem
とその中にある Menu
どちらにも member
と言う物がバケツリレーの様に上から下へ渡されています。
この様にバケツリレーをしていくと、「どの階層で何が起こっているのか」を、一番親の階層 MemberList
、もしくは MemberList
を使っているページなどのコンポーネントで把握することはほぼ不可能になってしまいます 😭
解決方法: Composition
このバケツリレー問題の解決方法には、Context API を使う方法など色々ありますが、Compositionという方法を紹介します。
Compositionとは、コンポーネントをネストするのではなく、 props.children
として渡してしまう、と言う方法です。例えば、今回の例で言うと、 Menu
のボタンの中身は MemberListItem
の children
として制御することが出来るはずです。
//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で、ある程度コードの拡張性が改善しました。
ただ、ある意味拡張出来すぎて、秩序を保つのが難しくなってしまう危険が増えました。
先ほどの例で言うと、例えば MemberList
や MemberListItem
の中は children
として渡している、つまりなんでもアリ状態です。
<MemberList>このchildrenにはなんでも入れられるよ</MemberList>
でも、私たちはメンバーリストの中にはメンバーが入って欲しいのです。
カンガルーのお母さんの袋の中にはカンガルーの子供だけが入っていて欲しいのです。逆に、カンガルーの子供は常にお母さんの袋の中にいて欲しいのです 🦘
解決方法:Compound Components
Compound Componentsを使うと、この問題を解決することが出来ます。
Compound Componentsとは、ざっくりと言うと「HTMLの select
と option
のような、中身の構造を定義するパターン」です。
//こうじゃなくて
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>
)
}
MemberList
で MemberListItem
を直接入れ込むのではなく、 MemberList.ListItem
とすることで、二つのコンポーネントの関係性は MemberList
で管理することが出来ます。
この要領で MemberList
以外にも、 MemberListItem
や Menu
なども整理出来ますが、長くなるのでやめます。
まとめ
かなり荒いと思いますが、2つのパターンを使ってリファクタリングを試みてみました。
フルバージョンのコードは一応Githubに置いておきました(それぞれのステップごとにブランチを切っています)。
ここまで読んでいただきありがとうございます。
最後に、パターンについて色々と勉強させていただいた内外の師匠に感謝いたします!!🐠
色々と深く学びたい方は、Epic Reactというオンラインコースもおすすめです。
みなさま良いクリスマスをお過ごしください!
(Strapではクリスマスカードも作ることが出来ます)
参考
One React mistake that's slowing you down | Epic React by Kent C. Dodds
Prop Drilling
Advanced React Component Patterns
Reactのデザインパターン Compound Components