View-Hook Pair パターン(草案)

English version

タイトルのような React 設計の提案を思いついたが、文脈・問題やほかの方法との比較、良し悪しを詳細にしようと書いていたところ時間がかかりすぎ、いつまで経っても手法を公開できず機を逸しそうだったため、先に手法だけ公開しておこうと思う。動くサンプルプロジェクトも何もない。

大きい処理について、サーバーやツールではレイヤーや関心単位により比較的簡単に分割できるが、フロントエンドのコンポーネントでは別の難しい問題がある。それに対して View-Hook Pair と名付けた分割統治方法を(軽く)提案する。

大きなフロントエンドコンポーネント

イメージ例:こういう画面がいくつもある SPA イメージ例:こういう画面がいくつもある SPA

それぞれの対象がお互いにロジックや UI についてそれなりに影響しあうものをご想像いただきたい。<ProjectPane /> のタブを閉じてから開きなおしても、中身は変わらずそのままであってほしい、つまり、 unmount されても <ProjectPane /> 内部の UI の状態はリセットされないでほしい。この UI 上の要求について、<ProjectPane /> の親が必要なだけの最小限なコントロールをできるように構成したい。

Redux などのデータストアを持ち出すか、もしくは、(親が関心を持つべきでないような)状態・ロジックも親がすべての制御を行うかしなければならない。コードの見た目だけでなく保守性も向上させるような分割は難しい。

View-Hook Pair パターン

この問題に対して、 View-Hook Pair パターンと名付けた分割統治方法を提案したいと思う。名前の通り React の JSX と React の Hook とで対を作るものだ。責務的には、前者は UI コードのみを持ち、後者は状態・ロジックを担当する。Pair のそれぞれはどちらも外部に export する。「この場面でこう使う」、というものではなく、こう分けると難易度が和らぐ、という多少抽象的なパターンだと考えている。調べていると似たようなコードのまとめ方をした例はいくつか見つかるため、生み出したのではなくまとめたという形になる。

Pair は主に以下から構成される。(例で使われる名前は便宜的につけたもので強い意味はない)

  • View として提供する UI コード
  • Hook として提供する状態・ロジック
  • 必要があれば Pair の統合物や Pair 間インターフェース型定義も用意する

【React】カスタムフックでstatefulなコンポーネントを整理する - yigarashi のブログ と大半は同じことを言っているのかもしれない。また、経年劣化に耐える ReactComponent の書き方 の 「Container層」ですべての Hook をまとめて Custom Hook とすること、もしくは、useEncapsulation | Kyle Shevlin のそれぞれもしくはすべての Hook を組み合わせた Custom Hook を View からは直接呼び出さないようにした形といえるかもしれない。

あるいは、 Hook を用いた漸進的な Model-View-ViewModel(MVVM) 化パターンともいえるかもしれない。

View

1
2
3
4
5
6
7
8
9
export const View = ({ enabled, enable, disable, /* ... */ }: PresentationModel) => (
<div>
<input type="text" disabled={disabled} />
<div>
<button type="button" onClick={enable}>Enable</button>
<button type="button" onClick={disable}>Disable</button>
</div>
</div>
);

Pair の View 方は、コンポーネントの UI コードすべてを含み、「(ロジック的)状態 → UI コード」というよく書かれる関数として提供する。対となる Hook を念頭に置いた引数を受け取り、戻り値として JSX を返す。

UI コードのみ切り出すため、単体テストやカタログ化・ビジュアルテストがしやすい。素朴な View には構造定義もスタイル定義も含まれるため、こちらも必要に応じて層を分けられる。

Hook

1
2
3
4
5
6
7
8
9
10
export const usePresentationModel = ({}: Args): PresentationModel => {
const [enabled, setEnabled] = useState(false);

const enable = useCallback(() => { setEnabled(true); }, []);
const disable = useCallback(() => { setEnabled(false); }, []);

// 他の定義...

return { enabled, enable, disable, /* ... */ };
};

Pair の Hook 方は、コンポーネントの状態・ロジックすべてを含み、Custom Hook として提供する。Hook の引数として依存する値・関数や初期値を受け取り、戻り値として主に View で使うことを念頭に置いた値・関数を返す。

ロジックのみ切り出すため、単体テストがしやすい。コンポーネントが充実して Hook が膨らんだ場合には、上のリンクの “useEncapsulation” のように観点事に中でさらにまとめたり、 Reducer やデータアクセス層など(少なくともインターフェース型上では) Plain な “Model” オブジェクトを奥に設けることでレイヤー分割できる。後者を実施した場合、この Hook は(ViewModel のように) React 系と非 React 系との間のコード的な緩衝地帯とできる。

統合物の基本形

1
2
3
4
export const Container = () => {
const presentationModel = usePresentationModel();
return <View {...presentationModel} />;
};

Pair を統合する基本形は、単純に Hook の結果をそのまま View に渡す上のコードになる。これを使って統合テストをしてもよいかもしれない。

統合物は、可能な限り Pair それぞれがその関心に注力できるような書き方をすること。

「大きなコンポーネント」のためのパターンということで、簡単なコードの例では利点を説明するのは難しい。ここではコンポーネントの内側もしくは外側で活用する例を示したい。

1 つのコンポーネントのテスタブルな分け方として

まず、大きいコンポーネントで内部的に使うような場合に使える。ページコンポーネントに限らない。

1
2
3
4
5
6
7
export const Page = ({ userName, changeTheme }: Props) => {
const { initialize, ...presentationModel } = usePageLogics({ changeTheme });

useEffect(() => { initialize(); }, []);

return <PageView {...presentationModel} userName={userName} />;
};

Hook の結果の一部を useEffect で使い、Page の mount 時に特定の処理を呼ぶ、という書き方もできる。また、Prop など Hook 以外からの値を織り交ぜてもよい(意味なくすべきでは無いが…)。

usePageLogics 内でさらに層を作る場合は、Page で DI のようなことをしつつ usePageLogics が Context や Redux、Router 等に直接依存することを避ける。

上述の通り Pair のそれぞれ・統合物を簡単にテストできる。

業務でもこんな感じで実装されたコードがあり、サーバー開発でレイヤードアーキテクチャに慣れた人はわかりやすいらしい。

あるコンポーネントに含まれる分割統治として

もう一つ挙げる例は、大きいコンポーネント内で分割統治したいとき、あるコンポーネントが外部的に使われる場合だ。こちらもページコンポーネントに限らない。(Context を使ったのは例であり、「バケツリレー」してよい。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Page = () => {
const [isPaneOpen] = useState(false);
const projectListProps = useProjectList();

return (
<ProjectListContext.Provider value={projectListProps}>
<div>/* deep */
{isPaneOpen && <div><PaneContent /></div>}
/* deep */</div>
<ProjectListContext.Provider>
);
};

const PaneContent = () => {
const projectListProps = useContext(ProjectListContext);

return <div><ProjectList {...projectListProps} /></div>;
};

UI コードのあるべき記述場所と状態のあるべきライフサイクルから定まる記述場所が違うという問題を、View と Hook という Pair に分割してそれぞれを違う場所に置く、という方法で解決できる(上の例では、isPaneOpen === false へとトグルしても ProjectList の状態は維持される)。状態維持&分割統治という要求のためにグローバルストアを導入・モデル変換する必要もない。

もちろん、PageLeftPane のどこでもそれぞれ Hook 以外の値を織り交ぜて良いので、必要ならば Hook から View までの間の階層において状態・ロジックの調整が可能だ。(単に置き場だけが問題だったのであれば Pair の Hook を Unstated Next でストア化するのも良いかもしれない。)

置かれる場所は分離してはいるものの、Pair のそれぞれの単体テストと、テスト用に統合物を作っての統合テストを行える。

残念ながら、まだ潰しきれない心配があり、この書き方は本格的には実戦投入(業務利用)できていない。

今のところの疑問

  • (MVVM の ViewModel と同じく)Pair 間インターフェース型が外に公開されている。調整が効く形ではあるものの、これは分割統治として適切か?
  • コンポーネントが小さければ、ある程度は従来の結合していた書き方のほうが実装を進めやすい。どのくらいの大きさが損益分岐点か?
  • Pair を子の Pair に分割する実例がまだない。Pair 分割は従来の結合したコンポーネントの分割以下の難易度で済むか?
  • React Server Component との相性が未知数。お互いの利点を消さず両用できるか?