関心の分離についてフロントエンド開発を例に考える
はじめに
バックエンド開発でよく見かけるレイヤードアーキテクチャーなどにおいては、おおよそ以下のようにして関心事を分離するケースが多いのではないかと思います インフラストラクチャー (永続化, キャッシュ, 通知など)
ドメインロジック
プレゼンテーション (コントローラー)
アプリケーション
それに対してフロントエンド開発においては、主に以下の2つの関心事の比率がアプリケーションの大部分を占めるケースが多いのではないかと思います
インフラストラクチャー (HTTP, ストア, ストレージ (localStorageなど), 各種サードパーティSDK, など) そのため、これらの2つのレイヤーに基づいて関心の分離を行う際の例などについていくつか紹介します 題材
以下のようにfetch()によってHTTPサーバーからユーザーの一覧を取得し、それを画面に表示する<UserList>コンポーネントがあったとします。このコンポーネントを例に関心の分離について見ていきます。 code:jsx
export function UserList() {
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
const data = await res.json();
if (res.ok) {
setUsers(data);
} else {
setError(createError(data));
}
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, []);
if (isLoading) {
return <Spinner />;
}
if (error) {
return <ErrorPage error={error} />;
}
return (
<List>
{
...users.map((x) => <ListItem key={x.id}>{x.name}</li>)
}
</List>
);
}
まず、この<UserList>はプレゼンテーションに関する役割に加えて、fetch()を呼んでHTTPサーバーからデータを取得しています。このfetch()を使用したHTTPサーバーからのデータの読み込みは、関心事としてはプレゼンテーションではなくインフラストラクチャーの関心事であると考えられます。
それに対して、fetch()によって取得されたユーザー一覧を表示するコードについてはプレゼンテーションの関心事であると考えられます。そのため、この<UserList>コンポーネントはプレゼンテーションだけでなく、インフラストラクチャーの関心事に関しても知識を持つことになります。
先程の<UserList>コンポーネントにあったfetch()によるHTTPサーバーからのデータの取得をReact Hooksとして抽出してみます。 code:javascript
export function useUsers() {
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
const res = await fetch("/api/users");
const data = await res.json();
if (res.ok) {
setUsers(data);
} else {
setError(createHTTPError(data));
}
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, []);
return { users, error, isLoading };
}
次に、<UserList>コンポーネントもuseUsers()を使うように変更します。
code:javascript
export function UserList() {
const { users, isLoading, error } = useUsers();
if (isLoading) {
return <Spinner />;
}
if (error) {
return <ErrorPage error={error} />;
}
return (
<List>
{
...users.map((x) => <ListItem key={x.id}>{x.name}</li>)
}
</List>
);
}
これで見た目はだいぶスッキリしたのではないかと思います。しかし、これにより関心の分離は実現できているでしょうか?先程のプレゼンテーションのレイヤーからインフラストラクチャーのレイヤーへの関心事を分離するという観点から考えた場合、これはまだ関心の分離は実現できていない状態だと個人的には思います。(もちろん、このような形でフックを分割しておくことで、例えばHTTPサーバーとのやり取りをfetch()から別の手段へ移行したとしても、<UserList>を変更する必要はなくなる、などのメリットはあります) たしかに、<UserList>コンポーネントのファイルからはfetch()を使用しているコードはなくなりました。しかし、<UserList>コンポーネントの直接の依存であるuseUsers()内ではfetch()が使用されています。そのため、<UserList>コンポーネントは依存しているuseUsers()を介して、HTTP(fetch())というインフラストラクチャーに関する実装の詳細への依存を持ってしまっていることになります。
なぜこれで関心の分離が実現できていないのかを考えるに当たっては、テストコードを記述することを考えるとイメージしやすいのではないかと思います。例えば<UserList>コンポーネントのテストコードを記述しようと考えた場合、mswなどによりスタブのHTTPサーバーを用意しないとテストが難しいです。もし<UserList>コンポーネントからHTTPサーバー(インフラストラクチャーレイヤーの関心事)への依存が切り離されていれば、スタブを用意せずとも独立して<UserList>コンポーネントはテストが可能であるはずです。 まず、<UserList>コンポーネントからインフラストラクチャーに関連した関心事(userUsers())を削除してみます。useUsers()の削除に合わせて、<Spinner>や<ErrorPage>のレンダリングなどもこのコンポーネントの役割としてはやや大きいため、これらの表示に関する制御も削除します。合わせて、<UserList>が表示するためのユーザーの一覧もprops経由で受け取るように変更します。
code:javascript
export function UserList({ users = [] }) {
return (
<List>
{
...users.map((x) => <ListItem key={x.id}>{x.name}</li>)
}
</List>
);
}
実際にContainerコンポーネントを用意してみます。ContainerコンポーネントではPresentationalコンポーネントとは異なり、インフラストラクチャーの関心事へのアクセスなどを許容します。
code:pages/users.js
import { UserList } from '@/components/UserList';
export function UserListContainer() {
const { users, error, isLoading } = useUsers();
if (isLoading) {
return <Spinner />;
}
if (error) {
return <ErrorPage error={error} />;
}
return <UserList users={users} />;
}
これにより、<UserList>コンポーネントからはインフラストラクチャーに関する知識を削除することができました。
先程、例にも示しましたが、これはフロントエンドにおいて関心の分離を実現するためのデザインパターンで、コンポーネントを以下の2種類に分類します。 Presentationalコンポーネント
主に、物事がどのように見えるかという部分について責任を持つ (=> ReactにおいてはJSXによるDOMの構築やスタイリングなど) そのUI自身に関する状態は管理してもよい (フォームの入力状態など)
UIに表示したいデータがどのようにして読み込まれるか(HTTP, WebSocket, localStorageなど) または どのように管理されるか (ストア, 状態管理ライブラリ)といった部分には責任を持たない Presentationalコンポーネントは複数のPresentationalコンポーネントまたはContainerコンポーネントを子孫として持つ
ただし、関心の分離の観点からは、PresentationalコンポーネントからContainerコンポーネントへの直接の依存を持たせることは避けたほうが良いでしょう (この場合、Render PropsやCompositionなどの方法を用いることで解決できるはずです) Containerコンポーネント
Presentationalコンポーネントや他のContainerコンポーネントにデータや振る舞いなどを提供する
Presentatoinalコンポーネントと異なり、データの読み込みや管理方法には制限はない
具体的には、Containerコンポーネントにおいては以下のような操作を実行してもよい
fetchなどを使って、HTTPサーバーと通信しても良い
以下のページなどでも詳しく説明されているため、そちらも参照ください
code:javascript
import { UserList } from '@/components/UserList';
export function UserListContainer() {
const { users, error, isLoading } = useQuery({
queryFn: () => fetch('/api/users').then(async (res) => {
const data = await res.json();
if (res.ok) return data;
else throw createHTTPError(data);
})
});
if (isLoading) {
return <Spinner />;
}
if (error) {
return <ErrorPage error={error} />;
}
return <UserList users={users} />;
}
HOC(Higher-Order Components)を利用したパターン
先程のContainer/Presentationalコンポーネントパターンの導入により、Presentationalコンポーネントからは完全にインフラストラクチャーに関する関心事を切り離すことができました。しかし、Containerコンポーネントである<UserListContainer>はuseUsers()に直接依存をしており、依然としてインフラストラクチャーに関する関心事が残っています。 HOC (Higher-Order Components)のテクニックなどを使うことで、これを分離することもできます。
code:javascript
export const withUsers = (Component) => (props) => {
const data = useUsers();
return <Component {...props} {...data} />;
};
code:javascript
export const withErrorPage = (Component) => ({ error, ...props }) => {
return error? <ErrorPage error={error} /> : <Component {...props} />;
};
code:javascript
export const withSpinner = (Component) => ({ isLoading, ...props }) => {
return isLoading ? <Spinner /> : <Component {...props} />;
};
そして、これらのHOCを利用してContainerコンポーネントを作成します。
code:pages/users.js
import { withUsers } from '...';
import { withErrorPage } from '...';
import { withSpinner } from '...';
export const UserListContainer = withUsers(withErrorPage(withSpinner(UserList)));
現実にはここまで分離をする必要性やメリットはそこまで高くはないと思うので、ここまでやるかどうかは好みなどによるのかと思います。あくまで一例として留めていただけるとよいと思います
Contextを利用したパターン
そのためには、まずHTTPサーバーとのやり取りをする役割をinterfaceとして抽象化します。
code:types/api-client.ts
export interface APIClient {
getUsers(): Promise<Array<User>>;
// ...
}
次にこのinterfaceの実装を用意します。
code:infrastructure/api-client.ts
import type { APIClient } from '@/types/api-client';
export function createAPIClient(): APIClient {
async function getUsers(): Promise<Array<User>> {
const res = await fetch('/api/users');
const users = await res.json();
return users;
}
// ...
const client = { getUsers, /*...*/ };
return client;
}
次にこのAPIClientを提供するフックを用意します。
code:typescript
import type { APIClient } from '@/types/api-client';
const APIClientContext = createContext();
function useAPIClient(): APIClient {
return useContext(APIClientContext);
}
注意点としてこのフックの戻り値はinterface型で定義します。例えば、以下のようにAPIClientを実装したFetchAPIClientというクラスがあったとします。
code:infrastructure/fetch-api-client.ts
import type { APIClient } from '@/types/api-client';
export class FetchAPIClient implements APIClient {
async getUsers(): Promise<Array<User>> {
const res = await fetch('/api/users');
const users = await res.json();
return users;
}
}
この場合、以下のようにuseAPIClientを定義してしまうと、実装の詳細※がuseAPIClient()に依存したコードに漏れてしまいます。(※FetchAPIClientはfetch()へ直接的に依存しており、FetchAPIClientを直接参照するuseAPIClient()もfetch()への依存を持つことになってしまう)
code:typescript
import type { FetchAPIClient } from '@/infrastructure/fetch-api-client';
export function useAPIClient(): FetchAPIClient {
// ...
}
これを避けるには、以下のようにinterfaceであるAPIClientを使って型を定義する必要があります。
code:typescript
import type { APIClient } from '@/types/api-client';
export function useAPIClient(): APIClient {
// ...
}
そして、<UserList>コンポーネントではuseAPIClient()から取得できるAPIClientを利用してデータを取得します。
code:javascript
export function UserList() {
const apiClient = useAPIClient();
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
const users = await apiClient.getUsers();
setUsers(users);
} catch (error) {
setError(error);
} finally {
setIsLoaindg(false);
}
};
// 省略...
}
Contextの利用に当たっての注意点
上記で解説した方法以外にも、Contextを利用することで複数のコンポーネント間でpropsを介さずに状態の共有を行ったりすることもできます
しかし、個人的にはこの使い方はできる限り避けた方がよいのではないかと思います
大抵、Contextなどを利用して状態を共有したいというケースではpropsのバケツリレーを回避したいというのが目的である場合が多いのではないかと思います。その場合はContextではなくCompositionなどの手法を利用して回避するとよいのではないかと思います propsのバケツリレーを回避するためだけにContextを利用して状態を共有してしまうと、結合度の増加などを招き、却ってコードの品質が低下してしまうことも考えられます その他の手法について
関心の分離を実現するための手法としては、このページで紹介したもの以外にも様々な手法があります こういったフレームワークを利用する際は、このページで紹介した手法を使う必要性は高くないと思います