フロントエンド API通信戦略
はじめに
今回はフロントエンド(Next.js×TypeScript)におけるAPI通信手法について、基本的なAPI通信の手法に加えて、「Repository層, Model層, Factory, API-Client」を用いた手法を具体的なコード例とともに解説します。
この記事の対象者
- フロントエンジニア初級者から中級者
- API結合におけるディレクトリ設計が明確に定まっていない人
- API通信をする上での「Repository層」「Model層」「Factory」 「API-Client」それぞれの責務について理解したい人
全体の概要図
後の章でこの部分は詳しく解説します。
基本的なAPI通信手法
今回紹介するAPI通信手法
基本的なAPI通信手法
カスタムフックを使わない場合
カスタムフックを使わない基本的なAPI通信手法としては下記が例の1つとして挙げられるかと思います。
- src/api配下にAPI通信用の関数を作成する (CRUD)
- /pages配下のファイルにて
useEffect
やuseAsync
内で作成したAPI通信の関数を呼び出す -
state
にレスポンスデータを保持する - 取得したデータをコンポーネントに流し込みUIを描画
今回はJSONPlaceholderを用いてAPI通信の記述を具体的に書いていきます。
src/api配下にAPI通信用の関数を作成する (CRUD)
import axios from "axios";
// types
import { Post } from "../types";
export const getPosts = async (): Promise<Post[]> => {
const result = await axios.get(`https://jsonplaceholder.typicode.com/posts`);
return result.data;
};
export const getPost = async (id: number): Promise<Post> => {
const result = await axios.get(
`https://jsonplaceholder.typicode.com/posts/${id}`
);
return result.data;
};
export const createPost = async (param: Omit<Post, "id">) => {
await axios.post("https://jsonplaceholder.typicode.com/posts", param);
};
export const updatePost = async (id: number) => {
await axios.put(`https://jsonplaceholder.typicode.com/posts/${id}`);
};
export const deletePost = async (id: number) => {
await axios.delete(`https://jsonplaceholder.typicode.com/posts/${id}`);
};
pages配下のファイルにて作成したAPI通信の関数を呼び出す
useAsync
を使って今回はAPI通信用の関数を呼び出し、state
にデータを格納しています。
その上で取得したデータをUI表示用のコンポーネントに受け渡しています。
import type { NextPage } from "next";
// hooks
import { useAsync } from "react-use";
import { useState } from "react";
// types
import { PostType } from "../../src/types";
// api
import { getPosts } from "../../src/api/post";
// components
import { Post } from "../../src/features/post/components/Post";
/* 責務はAPI通信とページの表示 */
const Page: NextPage = () => {
const [posts, setPosts] = useState<PostType[]>([]);
useAsync(async () => {
const response = await getPosts();
setPosts(response);
}, []);
return <Post posts={posts} />;
};
export default Page;
Post
コンポーネントは親からprops
で渡ってきたデータのtitle
を表示するコンポーネントです。
実際に/postを確認すると、JsonPlaceholdeから取得したpostデータ一覧が表示されていることが確認できます。
カスタムフックを利用する場合
カスタムフックを利用しない場合だと/pages
の責務が「API通信の呼び出しロジック」「state
の管理」「ルーティング処理」と責務が増えてしまいます。
「API通信の呼び出しロジック」と「state
の管理」をカスタムフックに切り出すことで、/pages
をルーティング処理だけの責務に減らすことができます。
具体的なコードをみていきます。
pages/post/index.tsx
で使っていた下記のロジックをカスタムフックに切り出します。
const [posts, setPosts] = useState<PostType[]>([]);
useAsync(async () => {
const response = await getPosts();
setPosts(response);
}, []);
カスタムフックは下記のようになります。
// hooks
import { useState } from "react";
import { useAsync } from "react-use";
// api
import { getPosts } from "../../../api/post";
// types
import { PostType } from "../../../types";
/* 責務: postsのAPI通信をしデータをstateに格納しておく */
export const useFetchPosts = () => {
const [posts, setPosts] = useState<PostType[]>([]);
useAsync(async () => {
try {
const response = await getPosts();
setPosts(response);
} catch (e) {
console.log(e);
}
}, []);
return {
posts: posts,
};
};
カスタムフックに切り出すことで、API通信のロジックとstate
を/pages/post
は持つ必要がなくなり、下記のようにルーティングのみを責務にすることができます。
import type { NextPage } from "next";
// components
import { Post } from "../../src/features/post/components/Post";
// // hooks
import { useFetchPosts } from "../../src/features/post/hooks";
/* 責務はAPI通信とページの表示 */
const Page: NextPage = () => {
const { posts } = useFetchPosts();
return <Post posts={posts} />;
};
export default Page;
以上のような書き方でもAPI通信を行うことはできます。(自分も1年目はこの書き方で書いていました)
しかし場合によっては下記のような問題が起きてしまいます。
- バックエンドに論理的に密接に結合されるので、バックエンドの変更がフロントエンドに影響を与えてしまう可能性があります
- 同じAPIの呼び出しが複数部分で使用される場合に重複したコードが発生する可能性がある
- コードの統一性が取りづらくなってしまう
これらの問題点は、「Repository層」「Model層」「API-Client」にファイルを切り分け分割することで解決することができます。
詳しいメリットについては次の章で解説します。
「Repository層」「Model層」「API-Client」に分けた場合のAPI通信について
ディレクトリ構成 (今回利用するものだけ抜粋)
├─pages/ ルーティングの責務を持つ
├─src/
| ├─features/ # 機能ごとにロジックやコンポーネント型定義やstore等を管理するディレクトリ |
| ├─post/ # post関連の機能ディレクトリ
| ├─components # postで利用するコンポーネントを格納(PostList)
| ├─pages # /pages(ルーティングファイル)で読み込ませるページの実体
| ├─const # postで利用する汎用的な変数(stateの固定の初期値など)
| ├─hooks # postで利用するカスタムフック (Repositoryの呼び出し)
| |─types # postで利用する型定義
| ├─stores # postで利用するグローバルなstate
| ├─lib
├─api-clinet # (後解説)
| ├─models # モデル (後解説)
| ├─repositories # リポジトリー (後解説)
├─mock # モック (後解説)
それぞれの責務
それぞれの責務について解説をしていきます。
コードによる具体的な開発は後の章で紹介します。
Repository層
Repository層は、フロント側でのAPI通信のための処理を定義する層になっています。
次に紹介するModel層とAPI-Clinetを仲介する役割持っています。具体的な責務としては、
- API-Clinetを使ったAPI通信処理の実装
- API-Clinetから取得したデータをModel層で定義した型に変換する実装
- API通信に関する例外処理の実装
加えてRepository内のMockではAPI通信用のモックデータを作成します。
Model層
Model層は、フロント側で扱うデータ型の振る舞いを定義しAPI通信におけるデータの整合性を保つ役割を担っています。
具体的な責務としては
- データの型定義
- データのバリデーションの実装
- クライアント側でのデータの加工や整形の実装
Model層の中にあるFactory
FactoryはModelの中にあり、Model層内で利用される具体的なインスタンスを生成し、データの整合性を保つ役割を担います。
具体的な責務としては
- インスタンスの生成処理の実装
- インスタンスの生成に必要な型定義の実装
API-Client
API-Clientは実際にAPI通信を行うためのコンポーネントであり、axiosを利用してAPI通信を行う。
具体的な責務としては
- HTTPクライアントを使用したAPI通信の処理実装
- 共通で利用するAPI通信に関する例外処理の実装
この構成のメリット
「Repository層」「Model層」「API-Client」に分けた場合のメリットをまとめていきます。
- モジュール化された設計
- テストの容易化
- APIクライアントの再利用性
- 例外処理の一元化
モジュール化された設計
Model、Repository、API-Clientと分けることで、API通信に必要な機能をそれぞれ分離させ、モジュール化された設計が可能になります。
これにより、保守性や拡張性を上げることができます。
テストの容易化
Model、Repository、API-Clientにおいて単体でテストをすることができます。
これにより、API通信に関して問題が発生した場合にどの層で問題が発生しているかを素早く特定することができます。
APIクライアントの再利用性
APIクライアントを利用することで、同じようなコードを書く必要がなくなります。
具体的には下記が挙げられます。
- ヘッダーに付与するTokenの取得ロジックとTokenの付与
- BaseURLの設定
- エラー処理
例外処理の一元化
API-Clinet内で例外処理を一元化することで、API通信における処理が一貫した形になり、コード品質を向上させることができます。
この構成のデメリット
一方でこの構成にすることで下記のようなデメリットもあります。
- APIを呼び出すために必要な構造がより複雑になるまで、慣れるまでは実装に時間がかかる
- 初心者が理解するための学習コストがかかる
基本的なAPI通信と比較すると設計の複雑さが生じてしまうので、場合によっては開発者の負担が増える可能性があります。
そのため、小規模なアプリケーション開発においてはかえって開発効率を低下させてしまうこともあります。
具体的な実装例
Model、Repository、API-Clientにおいて具体的な実装例を通して解説をしていきます。
先ほどと同様に、jsonplaceholderに対してAPI通信を行う想定で実装をしていきます。
API-Client
API通信の共通処理として利用するAPI-Clientの実装は下記のようになります。
import axios from "axios";
// 環境変数よりエンドポイントを設定 (今回はhttps://jsonplaceholder.typicode.com)
const baseURL = process.env.NEXT_PUBLIC_APP_API_ENDPOINT;
// 共通ヘッダー
const headers = {
"Content-Type": "application/json",
};
// axiosの初期設定
export const ApiClient = axios.create({ baseURL, headers });
// レスポンスのエラー判定処理
ApiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.log(error);
switch (error?.response?.status) {
case 401:
break;
case 404:
break;
default:
console.log("== internal server error");
}
const errorMessage = (error.response?.data?.message || "").split(",");
throw new Error(errorMessage);
}
// token付与等のリクエスト処理の共通化
ApiClient.interceptors.request.use(async (request: any) => {
// アクセストークンを取得し共通headerに格納
const accessToken = getAccessToken();
request.headers["access-token"] = accessToken
return request;
});
API-Clinetを用意することで、API通信時に毎回設定を書くよな下記の記述を、
await axios.post(
`${process.env["NEXT_PUBLIC_API_URL"]}/posts`,
params,
{
headers: {
"Content-Type": "application/json",
},
}
);
下記のように簡略化して記述することができます。
await ApiClient.post(`/posts`, params);
Model
次にModel層にAPI通信によるデータ型の定義をしていきます。
こちらはプロジェクトのOpenAPI等のAPI仕様書を参考に実装をします。
export type Post = {
userId: number;
id: number;
title: string;
body:string;
};
この部分でフロント(Next.js)側とAPI側のデータ型の整合性を取ることができます。
Repositoryの作成後にModelの中にFactoryを作成するので再度戻ってきます。
Repository
Repository層では、フロント側でのAPI通信のための具体的なロジックを記述していきます。
基本的なAPI通信手法のところで解説をした/api
配下のロジックをこちらに記述します。
まずはAPI通信のCRUDロジックを書いてきます。
const getPosts = async (): Promise<PostType[]> => {
const response = await ApiClient.get(`/posts`);
return response.data;
};
const getPost = async (params: Pick<PostType, "id">): Promise<PostType> => {
const response = await ApiClient.get(`/posts/${params.id}`);
return response.data;
};
const createPost = async (
params: Omit<PostType, "id">
) => {
await ApiClient.post(`/posts`, params);
};
const deletePost = async (params: Pick<PostType, "id">) => {
await ApiClient.post(`/posts/${params.id}`, params);
};
こちらの各のロジックをエクスポートするために型を同ファイル内に定義します。
export type PostRepository = {
getPosts: () => Promise<PostType[]>;
getPost: (params: Pick<PostType, "id">) => Promise<PostType>;
createPost: (params: Omit<PostType, "id">) => Promise<void>;
deletePost: (params: Pick<PostType, "id">) => Promise<void>;
};
Repositoryファイルの完成形です。
import { ApiClient } from "../lib/api-client";
import { PostType } from "../models/post_model";
export type PostRepository = {
getPosts: () => Promise<PostType[]>;
getPost: (params: Pick<PostType, "id">) => Promise<PostType>;
createPost: (
params: Omit<PostType, "id">
) => Promise<void>;
deletePost: (params: Pick<PostType, "id">) => Promise<void>;
};
const getPosts : PostRepository["getPosts"] = async (): Promise<PostType[]> => {
const response = await ApiClient.get(`/posts`);
return response.data;
};
const getPost : PostRepository["getPost"] = async (params: Pick<PostType, "id">): Promise<PostType> => {
const response = await ApiClient.get(`/posts/${params.id}`);
return response.data;
};
const createPost : PostRepository["createPost"] = async (
params: Omit<PostType, "id">
) => {
await ApiClient.post(`/posts`, params);
};
const deletePost : PostRepository["deletePost"] = async (params: Pick<PostType, "id">) => {
await ApiClient.post(`/posts/${params.id}`, params);
};
export const postRepository: PostRepository = {
getPosts,
getPost,
createPost,
deletePost,
};
具体的として、サーバー側から返ってきたデータのカラム(description
)がModelで定義したカラム名(body
)と異なった場合に、Repository層の中で整形をしていきます。
サーバーから実際に返ってきたデータ
{
userId: number;
id: number;
title: string;
description:string; // サーバーから返ってくる想定のデータが異なっていた場合
};
Modelに合わせてRepository層でデータを整形
const getPosts = async (): Promise<PostType[]> => {
const response = await ApiClient.get(`/posts`);
const result = response.data.map((i: any) => {
return {
userId: i.id,
id: i.id,
title: i.userId,
body: i.description, // descriptionという名前をbodyに整形する
};
});
return result;
};
こうすることで、API側のデータに更新があった場合でも、Repositoriy層とModel層を書き換えるだけでデータの整合性を取ることができます。
モックデータの作成
OpenAPI等を利用してモックサーバーを立ち上げられる環境があればこの部分は不要ですが、ない場合にフロントがだけでモックデータを用意することができます。
モックデータはRepositoriy層の中mockディレクトリを作成し、定義します。
import { PostType } from "../../models/post_model";
import { PostRepository } from "../post_repository.ts";
const getPosts = async (): Promise<PostType[]> => {
const response = [
{
user_id: 1,
id: 1,
title: "タイトル1",
body: "本文1",
},
{
user_id: 2,
id: 2,
title: "タイトル2",
body: "本文2",
},
];
return response;
};
const getPost = async (params: Pick<PostType, "id">): Promise<PostType> => {
const response = {
user_id: 1,
id: 1,
title: "タイトル",
body: "本文",
};
return response;
};
const createPost = async (params: Omit<PostType, "id">) => {};
const deletePost = async (params: Pick<PostType, "id">) => {};
export const mockPostRepository: PostRepository = {
getPosts,
getPost,
createPost,
deletePost,
};
Modelの中にFactoryを定義する
Modelの中にFactoryを作成しインスタンスを作成します。
postFactoryに引数(先ほど作成したMock)を入れた場合はモックデータがデータとして返ってきます。
引数がなかった場合は本番用のAPI通信を実際に行います。
export const postFactory = (rep?: PostRepository) => {
// 引数があればモックデータでなければ本番用データ
const repository = rep ?? postRepository;
return {
index: async (): Promise<PostType[]> => {
const response = await repository.getPosts();
return response;
},
show: async (id: Pick<PostType, "id">): Promise<PostType> => {
const response = await repository.getPost(id);
return response;
},
post: async (params: Omit<PostType, "id">) => {
await repository.createPost(params);
},
delete: async (id: Pick<PostType, "id">) => {
await repository.deletePost(id);
},
};
};
以上で「Repository」「Model」「API-Client」の作成が完了したので、実際にカスタムフック内で呼び出していきます。
カスタムフック内でFactoryを呼び出す
先ほど作成したカスタムフック内でModel層で作成したFactoryを呼び出します。
まずは引数(モックデータ)をいれない場合で、jsonplaceholderからの一覧取得のフェッチを試してみます。
/* 責務: postsのAPI通信をしデータをstateに格納しておく */
export const useFetchPosts = () => {
const [posts, setPosts] = useState<PostType[]>([]);
const [isFetching, setIsFetching] = useState(true);
useAsync(async () => {
try {
// Factoryの呼び出し
const data = await postFactory().index();
setPosts(data);
} catch (e) {
console.log(e);
} finally {
setIsFetching(false);
}
}, []);
return {
posts: posts,
isFetching: isFetching,
};
};
先ほどと同様にデータが表示されるていることが確認できます。
次にモックデータを表示させてみます。
/* 責務: postsのAPI通信をしデータをstateに格納しておく */
export const useFetchPosts = () => {
const [posts, setPosts] = useState<PostType[]>([]);
const [isFetching, setIsFetching] = useState(true);
useAsync(async () => {
try {
// Factory経由でmockデータを呼び出す
const data = await postFactory(mockPostRepository).index();
console.log(data);
setPosts(data);
} catch (e) {
console.log(e);
} finally {
setIsFetching(false);
}
}, []);
return {
posts: posts,
isFetching: isFetching,
};
};
先ほど作成したモックデータが表示されていることを確認できます。
最後に
いかがだったでしょうか。
今回は「Repository」「Model」「Factory」を利用したAPI通信戦略について具体例をもとに解説をしました。
次回はこれをもとにしたテストの書き方等を紹介していきたいと思います。
他にも色々な記事を書いているので、ぜひ読んでいただけると嬉しいです。
Discussion
大変勉強になりました。ありがとうございます!
恐縮なのですが、文章の途中に誤字がありましたので報告させていただきます。
API-Clinet -> API-Client でしょうか?
誤字報告の連絡ありがとうございます。修正させていただきます!
有益な記事のご作成ありがとうございます!
ちょうど個人開発でNextを触り始めたばかりですので、ディレクトリ構成などに悩んでました。大変勉強になり参考にさせていただいております。
もし可能であればですが、最終的な各ファイルの内容をご掲載いただけないでしょうか。
経験も浅く理解力不足があり申し訳ないのですが、
などなど不明点が多く迷子になってしまい手が止まっております、、
もしくは参考にされたサイトなどあればご教示いただけますと幸いです。
よろしくお願いいたします!
コメントありがとうございます。
この部分省略してしまって申し訳ございません。
この部分はAPIから取得したデータを表示するだけなので、省略してしまっていました。
こちはあくまでダミーとして「アクセストークンを取得する関数」という程で出しています。
説明が抜けてわかりづらくなってしまっていました。
一応最終的なディレクトリ構成は下記です
ご丁寧にありがとうございます!大変助かりました。
上記参考にさせていただきます!!
追記:
おかげさまで無事表示できました^^