👋

React Behaviourパターン

に公開

これはアンチパターンです。

https://ja.react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks

はじめに

Behaviourパターンなんて言っていますが、実際にはReactのコンポーネントに依存性注入の考えを持ってきたに過ぎません。
Container/Presentationalパターンの派生とも言えそうです。
このパターンがすでに考案されていて、名前が付けられているとして、それを知っている有識者の方がいたらご教授ください。

このコーディングパターンに名前はある?

はい、このコーディングパターンは、React におけるHooks を使った関心事の分離 (Separation of Concerns with Hooks)、あるいはより具体的にコンポーネントとロジックの分離 (Component-Logic Separation) と呼ばれるアプローチの一種と考えることができます。

解説

このパターンでは、以下の2つの主要な要素が分離されています。

  1. UI (プレゼンテーション) ロジック: Counter コンポーネントが担当しています。このコンポーネントは countincrease というプロパティを受け取り、それらをどのように表示し、ユーザーインタラクションにどう反応するかを定義しています。useCounter という関数を受け取ることで、内部で必要なプロパティを「注入」しています。

  2. ビジネスロジック/状態管理: useCounter カスタムフックが担当しています。このフックは、カウンターの count の状態とその increase ロジックをカプセル化しています。UI から独立して再利用可能なロジックを提供します。

このパターンの利点

  • 関心事の分離: UI とロジックが明確に分離されるため、それぞれの変更が他方に与える影響を最小限に抑えられます。
  • 再利用性: useCounter のようなカスタムフックは、他のコンポーネントでも簡単に再利用できます。また、Counter コンポーネントも、異なるロジックを持つフックを渡すことで再利用可能です。
  • テスト容易性: ロジック (useCounter) と UI (Counter) を個別にテストしやすくなります。
  • 可読性と保守性: コードベースが大きくなっても、それぞれの役割が明確なため、コードの理解と保守が容易になります。

補足

Counter コンポーネントが useProps という関数をプロップとして受け取る形式は、Dependency Injection (依存性の注入) の一種と見なすこともできます。これにより、Counter コンポーネントは特定のフックに強く依存することなく、柔軟に振る舞いを変更できるようになっています。

コード例

// counter.tsx
export type UseCounter = () => {
  count: number;
  increase: () => void;
};

export const Counter = (props: { useCounter: UseCounter }) => {
  const args = props.useCounter();
  return <button onClick={args.increase}>Count: {args.count}</button>;
};
// useCounter.ts
export const useCounter: UseCounter = () => {
  const [count, setCount] = useState(0);
  const increase = () => setCount(count + 1);
  return { count, increase };
};
// app.tsx
export const App = () => {
  return <Counter useCounter={useCounter} />;
};

ここでuseCounterはコンポーネントCounterふるまいを表す専用フックです。
対象コンポーネントであるCounterは専用フックのみを受け取り、それを使ってレンダリングに使うデータやイベントハンドラなどを取得します。
対象コンポーネントは専用フックの返り値からレンダリングを行うことのみに、専用フックは対象コンポーネントのふるまいを定義することのみに関心を持ちます。

なにがうれしい?

関心事の分離テスト容易性といった利点は語るまでもないでしょう(そのような読者を想定して)。
ここではReact、というかUIに関するコードだからこその利点を語ります。

あなたは!
Reactアプリのコンポーネントの層を見て、UIとロジックのミルクレープみたいだなぁと思ったことはありませんか?
確かにReactは素晴らしい!関数コンポーネントとフックを編み出した人は天才だ!

しかし!
あと一歩何か足りない気がする。。。そんな風に思ったことはありませんか?


Laitr Keiows, CC BY-SA 3.0 https://creativecommons.org/licenses/by-sa/3.0, via Wikimedia Commons

このパターンを使えば、そのもどかしさがなくなるかもしれません。
なぜなら、本当に見た目とふるまいのそれぞれに集中できるからです。

このパターンを使用したTODOアプリの例をご覧ください。

一通り動作を確認できたら、npm run storybookを実行してみてください。

todo-itemが最もこのパターンの形が表れている部分だと思います。
以下のようなファイルに分けられています。

TodoItemtodoIdごとに独立したふるまいを持つため、専用フックを作成する専用フックファクトリを定義しています。

ファイル名 内容
todo-item.stories.tsx TodoItemのStorybook(createUseTodoItemMockを利用)
todo-item.test.tsx TodoItemのみのテスト
todo-item.tsx 専用フックの型UseTodoItemと対象コンポーネントTodoItemの定義
use-todo-item.mock.ts モック実装の専用フックファクトリcreateUseTodoItemMockの定義
use-todo-item.test.tsx ふるまいのテスト
use-todo-item.ts 専用フックファクトリcreateUseTodoItemの定義

Storybookではモック実装の専用フックと対象コンポーネントを使って見た目を確認し、実際にアプリに組み込む際には専用フックと対象コンポーネントを使うということが可能になっています。

質問疑問ドラえもん

Jestのモックでいいのでは?

Unitテストではそれでいいかもしれません。
Storybookにもいくつか、Mock Service Workerに代表されるようなモック系のAddonがあります。
しかし、JestやMSWでモックを作るにはコンポーネントの見た目に関する部分だけでなく、ロジックの部分ももれなく把握する必要があると思います。
専用フックとして完全に分離してしまえば、コンポーネントの見た目の部分に集中でき、ロジックの把握不足によるモックもれをなくせると思います。

どのコンポーネントにこのパターンを使うべき?

APIコールなど外部との連携が必要なコンポーネントだと思います。
コンポーネント内で閉じている、StateやRefしか持たないようなコンポーネントはそのままでいいと思います(Counterの例は良くない)。

todo-formではTodoFormの中でRHFのuseFormを使っています。

専用フックファクトリに渡してそのまま返す値は通常のPropsとして渡すべきでは?

専用フックは引数を取らない関数にした方が一貫性があって良いかなと思いました。
専用フックとは別にPropsとして渡す値があってもいいかもしれません。

処理が追いづらい

要改善点です。
このパターンは理解を難しくするだけだったかもしれません。

まとめ

メリット

  • テスト容易性: UIとロジックを別々にテストできるため、単体テストが簡単になります。
  • 集中: UIコンポーネントは見た目に、専用フックは振る舞いに集中して開発できます。
  • Storybookとの親和性: モックの専用フックを使用することで、Storybookで見た目を確認する際に、ロジックを気にすることなくUIに集中できます。

デメリット

  • 複雑性の増加: パターンを適用することで、コードの構造が複雑になり、処理を追いにくくなる可能性があります。
  • 学習コスト: このパターンに慣れていない開発者にとっては、理解に時間がかかるかもしれません。

おわりに

当初思っていたよりも複雑なパターンになってしまった気がします。
このままでは使えないかもしれません。

専用フックのテストファイルは、対象のコンポーネントのテストファイルと分けなくてもよかったかもしれません。

Discussion