1 つのタスクを実行するために連携する複数のコンポーネントを作成する
複合パターン
多くのアプリケーションは、互いに関連し合うコンポーネントをもちます。それらは共有された状態を通じて互いに依存し合い、ロジックを共有します。たとえば、select
、ドロップダウン、メニューなどのコンポーネントがこれにあたります。複合コンポーネントパターン (compound component pattern) を使うと、あるタスクを実行するために連携するコンポーネントを作成することができます。
コンテクスト API
例として、リスの画像のリストを見てみましょう。ここでは、リスの画像を表示するだけでなく、ユーザーが画像を編集したり削除したりできるボタンを追加したいと思います。FlyOut
[1] コンポーネントを実装して、ユーザーがコンポーネントをトグルすると、メニュー項目のリストが表示されるようにします。
FlyOut
コンポーネントの内部には、本質的には以下の 3 つのものがあります:
- トグルボタンとリストを含む、
FlyOut
ラッパー -
List
をトグルするToggle
ボタン - メニュー項目のリストを格納する
List
この例には、複合コンポーネントパターンと React の コンテクスト API の組み合わせが適していそうです!
まず、FlyOut
コンポーネントを作成しましょう。このコンポーネントはステートを保持し、すべての子コンポーネントに対してトグルの値を提供する FlyOutProvider
を返します。
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
const providerValue = { open, toggle };
return (
<FlyOutContext.Provider value={providerValue}>
{props.children}
</FlyOutContext.Provider>
);
}
これで、ステートフルな FlyOut
コンポーネントができあがり、open
と toggle
の値を子コンポーネントに渡すことができるようになりました!
Toggle
コンポーネントを作成しましょう。このコンポーネントは、ユーザーがクリックするとメニューの表示状態を切り替えるコンポーネントをレンダリングします。
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
Toggle
が FlyOutContext
プロバイダに実際にアクセスできるようにするには、Toggle
を FlyOut
の子コンポーネントとしてレンダリングする必要があります!ここで、そのように単純に子コンポーネントとしてレンダリングすることも可能ではあります。しかし、Toggle
コンポーネントを FlyOut
コンポーネントのプロパティとするという方法もあるのです。
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
FlyOut.Toggle = Toggle;
こうすれば、FlyOut
コンポーネントを他のファイルで使用したい場合に、FlyOut
をインポートするだけで済みます。
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
</FlyOut>
);
}
トグルだけでは十分ではありません。リストアイテムをもつリストも必要で、これは open
の値に基づいてメニューを開閉します。
function List({ children }) {
const { open } = React.useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
List
コンポーネントは、open
の値が true
か false
かに応じて子コンポーネントをレンダリングします。Toggle
コンポーネントと同様に、List
と Item
を FlyOut
コンポーネントのプロパティとしましょう。
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
function List({ children }) {
const { open } = useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
これで、FlyOut
コンポーネントのプロパティとして使用できるようになりました!ここでは、ユーザーに Edit と Delete という 2 つのオプションを表示したいと思います。2 つの FlyOut.Item
コンポーネントをレンダリングする FlyOut.List
を作成し、1 つは Edit 用、もう 1 つは Delete 用とします。
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
完璧です!FlyOutMenu
自体に状態を追加することなく、FlyOut
コンポーネント全体を作成することができました。
複合パターンは、コンポーネントライブラリを作成するときに便利です。Semantic UI のような UI ライブラリを使うときに、このパターンをよく見かけるはずです。
React.Children.map
コンポーネントの子要素をマッピングして複合コンポーネントパターンを実装することもできます。 open
と toggle
プロパティをクローンし、子コンポーネントに open
と toggle
プロパティを追加することができます。
export function FlyOut(props) {
const [open, toggle] = React.useState(false);
return (
<div>
{React.Children.map(props.children, child =>
React.cloneElement(child, { open, toggle })
)}
</div>
);
}
すべての子コンポーネントはクローンされ、open
と toggle
の値が渡されます。前の例のようにコンテクスト API を使用する代わりに、props
を通じてこれら 2 つの値にアクセスできるようになりました。
import React from "react";
import Icon from "./Icon";
export function FlyOut(props) {
const [open, toggle] = React.useState(false);
return (
<div className={`flyout`}>
{React.Children.map(props.children, child =>
React.cloneElement(child, { open, toggle })
)}
</div>
);
}
function Toggle({ open, toggle }) {
return (
<div className="flyout-btn" onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
function List({ children, open }) {
return open && <ul className="flyout-list">{children}</ul>;
}
function Item({ children }) {
return <li className="flyout-item">{children}</li>;
}
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
Pros
複合コンポーネントは、自身の内部でステートを管理し、それを複数の子コンポーネント間で共有します。複合コンポーネントを使用する場合、私たちが内部のステートについて気にする必要はありません。
また、複合コンポーネントをインポートする場合、そのコンポーネントで利用可能な子コンポーネントを明示的にインポートする必要はありません。
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
Cons
React.Children.map
を使用して値を提供する場合、コンポーネントのネストに関して制限があります。親コンポーネントの直接の子だけが open
と toggle
props にアクセスできるのです。つまり、それらのコンポーネントを他のコンポーネントでラップすることはできません。
export default function FlyoutMenu() {
return (
<FlyOut>
{/* This breaks */}
<div>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</div>
</FlyOut>
);
}
React.cloneElement
により要素をクローンすると、シャローマージ (shallow merge) が実行されます。すでに存在する props は、渡された新しい props と一緒にマージされます。これは、React.cloneElement
メソッドに渡した props と既存の props が同じ名前をもっていた場合、名前の衝突が発生するということです。props がシャローマージされるため、名前が衝突した prop の値は、渡された最新の値で上書きされます。
参考文献
-
訳注: ポップアップを意味します。 ↩︎