View-Hook Pair によるバリエーションのあるコンポーネントの実装

English version

以前の記事で View-Hook Pair というものを提案した。今回はこれを用いでバリエーションのあるコンポーネントをどう実装できるかについて述べる。この方法を使うと、仕様変更やリファクタリングのしやすさを大きく損なわない形でコードの共通化を実現できる。

サンプルコードはこちら

コンポーネントのバリエーション

アプリケーションを実装していると、ほぼ同じユースケースで同じ場所・混ぜて使われるような(デザイン上の括りでの)コンポーネントが発生する。これらが生まれる背景としては、対象の属性によって一部の UI や振る舞いが違ったり、フィーチャーフラグで出し分けされていたり、ユーザーによるカスタマイズが可能だったりした場合が挙げられる。

今回はそうした内容を含むサンプルプロジェクトとして、簡易的なファイル閲覧アプリケーションを取り上げる。このアプリケーションは、左右 2 ペインで左側にファイル一覧、右側に選択中のファイルの詳細情報を掲載するもので、画像ファイルや動画ファイルについては詳細情報をサムネイル(実際には適当に取得した画像)でのプレビュー付きで表示する。

初期状態 初期状態
通常の詳細情報 UI 通常の詳細情報 UI
メディア系ファイルの詳細情報 UI メディア系ファイルの詳細情報 UI

今回は、詳細情報の違いをバリエーションの違いと見なす。

課題

こうしたコンポーネントの共通部分は、仕様変更があった場合でも作成箇所や修正箇所がバリエーションの数に単に比例したり、各バリエーション間で挙動のずれ・修正漏れが発生したりして修正コストと認知コストが必要以上に増えるのは避けたい。しかし、素朴な方法としてはいくつか考えられるが、コンポーネントが複雑になってきた場合に問題が生じる。

  • バリエーションを(実装上の括りでの)コンポーネントの違いとして表現すると、重複するコードが増えて修正箇所が増えたり修正漏れが出たりする。
  • バリエーションをパラメタの違いとして表現すると、コンポーネントの中が分岐ばかりで修正が難しくなる。
  • バリエーションによって不要な情報も要求したり必要な情報が型として表現できない。
  • 最初はきれいに共通化または分岐したつもりの実装であっても、機能が増えるにしたがってそうでは無くなり改変が厳しくなる。

View-Hook Pair を使うと、こうした課題は緩和されるはずだ。

View-Hook Pair

View-Hook Pair とは View-Hook Pair パターン(草案)で提案したような、形式として View と Hook の対を作成する分割統治方法だ。前者が UI コード、後者が状態・ロジックを担当する。

今回は「標準形」として以下のように書くことにした。

1
2
3
4
5
6
7
8
9
function useObject({}: Props, {}: Dependencies): [ViewProps, Exports] {
return [{}, {}] as const;
}

function View({}: ViewProps): ReactElement {
return <div />;
}

export const Component = { useObject, View };

今回、 DependenciesExports という型が新たに出てきた。これらの利用は任意で、無かったとしても今回の例では同等の内容を実現できる。前者は Dependency Injection の受け口を意識しており、外部依存の副作用やリソース取得部分というような「起動」後は固定だが技術的には詳細だという依存先をコンポーネントから分離できるようにする。後者はオブジェクト指向のクラスが公開するようなメソッド・プロパティを意識しており、この「公開」インターフェースを使って useObject 内とメッセージをやり取りするイメージだ。

もし対を統合する場合は、それを利用する場所で統合する。例えば、 Component を View-Hook Pair ではない形式で使うコンポーネントや、単体テストが該当する。

1
2
3
4
5
function Component_(props: Props) {
const [viewProps, componentExports] = Component.useObject(props, dependencies);

return <Component.View {...viewProps} />;
}

戦術

ここからは、対の組み合わせでどのように上位の対を実現するかを述べる。

まず、共通部分でコンポーネント化できる場所を通常のコンポーネントとして実装する。Presentational コンポーネントや、ある程度の大きさのロジック・外部に影響を与える状態を含まないようなものは、特に View-Hook Pair 化しなくて良い。もしそうでなければ、上の「標準形」のようにコンポーネントを対のそれぞれの部分に分ける。分けるといっても、通常は機械的に、コンポーネントの状態を ViewProps に、useEffect 等で動かすものを関数化して Exports に、(アニメーションなど UI 用の状態は含む)宣言的 UI を View に、それぞれ収まるよう分割すればよい。

View-Hook Pair 形式のコンポーネントを使う View-Hook Pair 形式のコンポーネントでは、以下のように View と Hook を独立に組み合わせる。

1
2
3
4
5
6
7
8
9
10
function useObject({}: Props, {}: Dependencies): [ViewProps, Exports] {
const [childProps, childExports] = Child.useObject({}, {});
return [{ childProps }, {}] as const;
}

function View({ childProps }: ViewProps) {
return <Child.View {...childProps} />;
}

export const Parent = { useObject, View };

親コンポーネントの useObject では、基本的には自身の PropsDependencies、子の useObject からの Exports を組み合わせ、自身の責務を実装する。View では、HTML 要素や他のコンポーネントを駆使して子の View を適切な場所に配置し全体をマークアップする。おそらくは、独立にと言っても、子の Props がフラットになっていると混ざりあって大変なので ViewProps はある程度は構造を持っておくのが良いと思われる。

最終的に統合する最上位の場所では、上記の “Component_” のように useObject を呼び出し必要な処理を記述して View を呼び出す。

処理の流れを図として表すと以下のようになる。

処理の流れ 処理の流れ
※ 実際には同じ深さの View のそれぞれの順番は任意

Hook 側のツリーでは、親が子から Exports を受け取った時点で子の(カプセル化された)状態と組み合わせて、(帰りがけ順で)自身の ViewPropsExports を作ることができる。これは従来の React コンポーネントでは状態の二重管理以外でこれを実現するのは難しかった。また、 View 側のツリーも、Hook 側のツリーと同じ形になっており、(こちらは順不同だが)対応するように render されていく。

サンプルプロジェクトを通した実装例

冒頭に掲載したサンプルコードから、この記事の主題であるバリエーションの実現を主眼にしたコードを掲載する。他のコードについては、 ‘src/Panel’ を参考にしてほしい。このフォルダ以外のコンポーネントは View-Hook Pair を使っておらず、また、題意から外れるため触れない。

すでに掲載した通り、このサンプルでは詳細情報の部分にメディア系ファイル用とそうでないファイル用というバリエーションがある。今回はそれぞれを MediaPanelTextPanel として別々なコンポーネントとして実装している( ‘src/Panel’ の中にある)。これらはどちらとも上の図の Parent にあたるもので、その中身は ‘src/Panel/parts’ にあるものを、スペーシングや片方に独自の挙動・装飾を除き共有している。

まず簡単な TextPanel のコードを示す(論点ではないためスタイルは除いた)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Props = { name: string };

type Dependencies = {};

function useObject({ name }: Props, {}: Dependencies) {
const [attributesProps] = Attributes.useObject({ name }, {});

return [{ name, attributesProps }];
}

function View({ name, attributesProps }: ReturnType<typeof useObject>[0]) {
return (
<div>
<div>
<Header name={name} />
</div>
<div>
<Attributes.View {...attributesProps} />
</div>
</div>
);
}

export const TextPanel = { useObject, View };

普通の Header 共通コンポーネントと View-Hook Pair 形式の Attributes 共通コンポーネントを利用している。<div /> はスペーシング(これはこのコンポーネントの責務)のために設置している。

次に、MediaPanel の Hook のコードを示す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Props = { id: string; name: string };

type Dependencies = { getPreviewUrl: (id: string) => Promise<string> };

function useObject({ id, name }: Props, { getPreviewUrl }: Dependencies) {
const [previewUrl, setPreviewUrl] = useState<string>();

const [previewProps] = Preview.useObject({ previewUrl }, {});
const [attributesProps, { editing: attributesEditing }] = Attributes.useObject({ name }, {});

const load = useCallback(async () => {
setPreviewUrl(undefined);
setPreviewUrl(await getPreviewUrl(id));
}, [id, getPreviewUrl]);

return [{ name, attributesEditing, previewProps, attributesProps }, { load }] as const;
}

TextPanel には無いプレビュー部分があるため、Preview 共通コンポーネントも利用している。また、 Attributes が編集中の時はアニメーションを止めたいという要求を満たすよう、 Attributes.useObjectExports も利用している。さらに、読み込み処理の実行タイミングを制御できるようにするため load を自身の Exports としている。

最後に、MediaPanel の View のコードを示す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function View({
name,
attributesEditing,
previewProps,
attributesProps,
}: ReturnType<typeof useObject>[0]) {
const [previewHovered, setPreviewHovered] = useState(false);

return (
<div>
<div className={previewHovered && !attributesEditing && style}>
<Header name={name} />
</div>
<div onMouseEnter={() => setPreviewHovered(true)} onMouseLeave={() => setPreviewHovered(false)}>
<Preview.View {...previewProps} />
</div>
<div>
<Attributes.View {...attributesProps} />
</div>
</div>
);
}

単に Hook から受け取った子コンポーネント用 Props をそのまま渡し、さらに、表示専用の状態である previewHovered を利用している。

※ View-Hook Pair は View に情報の状態・ロジックを持たせないパターンであって、View は情報が無い表示や装飾にのみ影響する状態・ロジックは持てる。

欠点

  • 仕様に対する実装として本質的な部分のコードは共通化されるが、ボイラープレートが多い。
  • 条件実行や繰り返しとの折り合い(View と Hook が別の階層になる場合に問題となりうる)。
  • 対の型をどうつけるか。すべてを明示的に型定義して対を従わせるか、対から動的に生成するか(この例では後者)。

まとめ

View-Hook Pair の活用例として、バリエーションのあるコンポーネントの保守性の問題に対抗するための実装方法を紹介した。