フロントエンドでAPIからデータを取得して表示するまでの現時点での自分の最適解
フロントエンドでAPIからデータを取得して表示するまでに、自分が採用しているopenapi-fetch、API error正規化、React Query、Suspense、select、テストの分け方を整理する。
はじめに
フロントエンドで API からデータを取得して表示する処理について、現時点ではだいたいこの形にしています。
この記事は一般的なベストプラクティスというより、私はこうしているという構成メモです。
前提として、自前の requestJson や axios のような汎用 HTTP クライアント層は基本的に作りません。
OpenAPI があるなら openapi-fetch を使います。
ただし、server state の管理は React Query にかなり寄せています。
つまり、こういう分け方です。
- HTTP 通信は
openapi-fetchの client 経由にする - HTTP / network / timeout error は
infrastructures/apiで正規化する - 共通 header や 401 処理も middleware に閉じ込める
- API 呼び出しは hook の
queryFn/mutationFnに閉じ込める - retry などの共通制御は
QueryClientに閉じ込める - cache / refetch / invalidate / retry / loading は React Query に任せる
- 画面用の変換は hook の
selectに寄せる - component には API レスポンスをそのまま渡さない
- 初期表示で必須のデータは Suspense を使ってシンプルにする
「fetch を直接あちこちで呼ぶ」という話ではありません。
openapi-fetch は native fetch の薄い wrapper として使い、型と共通処理をそこに集めます。
結論
現時点では、次のように分けるのが一番扱いやすいです。
src/
infrastructures/
api/
client.ts # openapi-fetch client と middleware
error.ts # HttpError / NetworkError / TimeoutError
features/
articles/
model/
article-view-model.ts # 表示用の型
hooks/
use-articles.ts # client.GET、React Query、select
components/
article-list.tsx # 表示だけを担当
infrastructures/api/client.tsはcreateClientと middleware だけを担当するinfrastructures/api/error.tsは API error の型とunwrapApiDataを担当するhooks/はuseSuspenseQueryやuseQueryの中でclient.GET/client.POSTを呼び、unwrapApiDataで data を取り出すhooks/はselectで API レスポンスを表示用に変換するcomponents/は取得方法を知らず、渡された値を表示する- テストは MSW で API レスポンスを差し替え、変換結果にフォーカスする
大事なのは、API のレスポンス型をそのまま UI の props にしないことです。
API は外部境界です。 React Query は server state の管理です。 component は表示です。
ここを混ぜないようにしています。
なぜ axios ではなく openapi-fetch なのか
理由は単純で、自分が欲しいのは高機能な HTTP クライアントではなく、OpenAPI と一致した型付きの呼び出しだからです。
openapi-fetch は fetch の薄い wrapper として使えます。
そのうえで、OpenAPI から生成した型により、URL、path params、query、request body、response の型を合わせられます。
共通処理は client の middleware に閉じ込めます。
import createClient from "openapi-fetch"
import { HttpError, NetworkError, TimeoutError } from "@/infrastructures/api/error"
import type { paths } from "./types"
export const client = createClient<paths>({
baseUrl: API_BASE_URL,
fetch: (...args) => globalThis.fetch(...args),
})
client.use({
async onResponse({ response }) {
if (response.ok) {
return
}
throw new HttpError(response.status, await parseErrorBody(response))
},
async onError({ error }) {
if (isTimeoutLikeError(error)) {
return new TimeoutError()
}
if (error instanceof HttpError) {
return error
}
return new NetworkError(error instanceof Error ? error.message : "Network error", error)
},
})
client.use({
async onRequest({ request }) {
request.headers.set("Content-Type", "application/json")
request.headers.set("X-App-Version", APP_VERSION)
const token = getAccessToken()
if (token) {
request.headers.set("Authorization", `Bearer ${token}`)
}
},
async onResponse({ response }) {
if (response.status === 401) {
clearSession()
}
},
})
middleware には、どの API でも共通の処理だけを置きます。
- 認証ヘッダ
- アプリバージョン
- ログ
- 401 のセッション破棄
- non-2xx response の
HttpError化 - network / timeout error の正規化
逆に、画面固有のエラー表示や業務エラー分類はここに置きません。 そこは caller 側に残します。
openapi-fetch の middleware は、request や response を差し替えるときだけ返すようにしています。
単に header を追加したり、401 を見て session を消したりするだけなら、何も返しません。
API 呼び出しは hook に閉じ込める
API 呼び出し自体は、基本的に hook に閉じ込めます。
理由は、React Query と API 呼び出しを別々の層に分けても、結局その関数を使う場所が hook だけになることが多いからです。
たとえば、こう書きます。
import { unwrapApiData } from "@/infrastructures/api/error"
export function useArticles() {
return useSuspenseQuery<ApiArticle[], Error, ArticleListItem[]>({
queryKey: ["articles"],
queryFn: async () => {
return unwrapApiData(await client.GET("/api/v1/articles"))
},
select: toArticleListItems,
})
}
この形にすると、hook を見れば次のことが一箇所で分かります。
- query key
- どの API を呼ぶか
- API レスポンスをどう表示用に変換するか
API 呼び出しが複数の hook から再利用されるなら切り出します。
ただ、最初から api/ に全部分けるより、使う場所に置いた方が読みやすいことが多いです。
大事なのは、component に client.GET を出さないことです。
API 呼び出しは hook に閉じ込めます。
API error は infrastructure で正規化する
hook に { error } 分岐を散らしません。
HTTP error、network error、timeout error は、infrastructures/api で共通の Error に正規化します。
export class HttpError extends Error {
readonly code: string | null
constructor(
readonly status: number,
readonly body: unknown,
) {
super(getApiErrorMessage(body, `HTTP request failed with status ${status}`))
this.name = "HttpError"
this.code = getApiErrorCode(body)
}
}
export class NetworkError extends Error {
constructor(message = "Network error", readonly cause?: unknown) {
super(message)
this.name = "NetworkError"
}
}
export class TimeoutError extends Error {
constructor(message = "Request timed out") {
super(message)
this.name = "TimeoutError"
}
}
openapi-fetch の middleware では、non-2xx response を HttpError として throw します。
fetch reject は onError で NetworkError / TimeoutError に正規化します。
hook 側では unwrapApiData で data を取り出すだけにします。
ここで unwrapApiData を使っているのは、select に undefined の扱いを混ぜないようにするためです。
queryFn の時点で成功時の data を定義済みの値として返せる形にしておけば、select は返ってきたデータを表示用に変換することだけに集中できます。
select の中で result.data ?? [] のような補完や error 判定を始めると、取得境界の処理と表示モデルへの変換が混ざってしまいます。
export function unwrapApiData<T>(result: ApiResult<T>): Exclude<T, undefined> {
if ("error" in result && result.error !== undefined) {
throw new HttpError(result.response.status, result.error)
}
return result.data as Exclude<T, undefined>
}
この形にしておくと、hook はこう書けます。
export function useArticles() {
return useSuspenseQuery({
queryKey: ["articles"],
queryFn: async () => unwrapApiData(await client.GET("/api/v1/articles")),
select: toArticleListItems,
})
}
retry / 非 retry の判断は QueryClient に閉じ込めます。
認証切れのようなリトライしても回復しないエラーは、共通設定で retry しないようにします。
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof HttpError && error.status === 401) {
return false
}
return failureCount < 3
},
},
},
})
caller 側に残すのは、明示的に扱う業務エラー分類だけです。
- OTP の rate limit / unauthorized
- 解析の already in progress / no new resources / insufficient credits
- Google Docs export の integration required
- material delete の analyzed / processing 制約
それ以外の transport / HTTP / timeout の正規化は infrastructures/api に寄せます。
React Query にかなり依存する
一方で、React Query にはかなり依存しています。
API から取ってきたデータを画面に出すとき、実際に面倒なのは HTTP 通信そのものではありません。 面倒なのは、その周辺です。
- cache
- refetch
- stale 判定
- retry
- invalidate
- loading
- error
- mutation 後の同期
このあたりは React Query がかなり完成しています。 なので、自分で薄い状態管理を作るより、React Query の流儀に寄せる方が楽です。
自分の中では、openapi-fetch の client はただの通信関数です。
React Query は server state の置き場所です。
この2つを分けて考えています。
hook の select で表示用に変換する
API レスポンスはバックエンドの都合で決まります。 UI に必要な値は画面の都合で決まります。
なので、API レスポンスをそのまま component に渡さず、hook の select で表示用に変換します。
export interface ArticleListItem {
id: string
title: string
publishedDateLabel: string
href: string
}
type ApiArticle = {
id: string
title: string
publishedAt: string
}
function toArticleListItems(apiArticles: ApiArticle[]): ArticleListItem[] {
return apiArticles.map((item) => ({
id: item.id,
title: item.title,
publishedDateLabel: formatDateLabel(item.publishedAt),
href: `/articles/${item.id}`,
}))
}
export function useArticles() {
return useSuspenseQuery({
queryKey: ["articles"],
queryFn: async (): Promise<ApiArticle[]> => {
return unwrapApiData(await client.GET("/api/v1/articles"))
},
select: toArticleListItems,
})
}
component は、変換済みの値だけを受け取ります。
export function ArticleList() {
const { data: articles } = useArticles()
return articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))
}
この形にしておくと、component から API の都合が消えます。
- 日付のフォーマット
- 詳細ページの URL
- 表示名の組み立て
- nullable な値の扱い
- 並び順やグルーピング
こういうものを component に散らさないようにしています。
select が大きくなりすぎたら、変換関数だけ model/ に逃がします。
ただ、最初から重い抽象化は作りません。
hook の中で読める範囲なら、そのまま select に置くことが多いです。
Suspense を使えるところは使う
初期表示に必須のデータは、できるだけ useSuspenseQuery に寄せています。
理由は、component がかなりシンプルになるからです。
通常の useQuery だと、component 側で isLoading や data === undefined を扱う必要があります。
でも、初期表示に必須のデータであれば、そこを component の中で毎回分岐するより、Suspense boundary に寄せた方が読みやすいです。
export function ArticlesPage() {
return (
<Suspense fallback={<ArticleListSkeleton />}>
<ArticleList />
</Suspense>
)
}
useSuspenseQuery を使うと、成功時の data は定義済みとして扱えます。
そのため、表示 component は success の世界だけを考えられます。
もちろん、全部を Suspense にするわけではありません。
次のような場合は、普通に useQuery を使います。
- ユーザー操作後に初めて取得する
- 検索条件が頻繁に変わる
- 一部分だけ loading を出したい
- 前回のデータを残しながら再取得したい
- enabled の制御が必要
逆に、画面を表示する時点で必ず必要なデータなら、Suspense を使った方がシンプルに書けることが多いです。
loading が必要な画面と、そうでない画面を分ける
loading は、全部の component が知る必要はありません。
自分の中では、次のように分けています。
- 画面全体で待てばよいものは Suspense boundary に寄せる
- 部分的に待ちたいものは
useQueryの状態を使う - ボタン操作や mutation は、その操作単位で pending を見せる
- component の内部に独自の loading state を増やしすぎない
これを決めておくと、isLoading の分岐があちこちに散らばりにくくなります。
loading 表示が必要な画面はあります。
ただ、必要ではない場所まで毎回 loading / error / success の union を component に持ち込むと、表示のコードが重くなります。
React Query と Suspense に寄せられるところは寄せて、component はできるだけ表示に集中させます。
差し替えやすい構成にしておく
React Query に依存すると言っても、component が React Query を直接意識しすぎる形にはしません。
component が知っていいのは、基本的に表示用の値だけです。
openapi-fetch client
-> React Query
-> select
-> view model
-> component
この流れにしておくと、差し替えがしやすいです。
- base URL や認証ヘッダが変わっても
clientと middleware を直せばよい - API のパスが変わっても該当 hook の
client.GETで気づける - OpenAPI schema が変われば
client.GETの型で気づける - レスポンス形式が変わっても
selectや変換関数で吸収できる - 表示項目が変わっても component の props を見ればよい
- React Query の使い方を変えても component への影響を抑えやすい
「React Query に依存しているのに差し替えやすい」というと矛盾して聞こえるかもしれません。 ただ、自分が避けたいのは、component の中に API レスポンス、loading 分岐、再取得条件、日付整形が全部混ざることです。
React Query への依存は hook に閉じ込めます。
API client への依存も hook に閉じ込めます。
共通 API 処理は middleware と error.ts に閉じ込めます。
表示の都合は select に寄せます。
この分け方なら、あとで変えたい場所がかなり見つけやすくなります。
テストは MSW で変換にフォーカスする
この構成では、まず infrastructures/api の error 正規化をテストします。
- non-2xx response が
HttpErrorになる - API error body から
code/messageを取り出せる - network error が
NetworkErrorになる - timeout が
TimeoutErrorになる unwrapApiDataが data を返すか、HttpErrorを throw する
そのうえで、hook のテストは MSW を使います。
見たいのは、低レベルの fetch が実際に通信できることではありません。
見たいのは、API レスポンスを受け取ったときに、hook が component に渡す値が期待どおりになることです。
たとえば、次のような観点です。
- API の日付が表示用ラベルに変換される
- API の ID から詳細ページの URL が組み立てられる
- 空配列のときに空の配列が返る
- 明示的に扱う業務エラーだけ、caller 側の Error class に分類される
MSW で API レスポンスだけ差し替えれば、openapi-fetch、React Query の hook、select の変換を自然にテストできます。
server.use(
http.get("/api/v1/articles", () =>
HttpResponse.json([
{
id: "article-1",
title: "React Query と openapi-fetch",
publishedAt: "2026-06-27",
},
]),
),
)
このレスポンスに対して、hook から返る値が次のようになっていればよいです。
expect(result.current.data).toEqual([
{
id: "article-1",
title: "React Query と openapi-fetch",
publishedDateLabel: "2026年6月27日",
href: "/articles/article-1",
},
])
テストを細かく分けすぎるより、MSW で実際の境界に近い形を作り、変換結果を見る方がシンプルだと思っています。
もちろん、変換が複雑になったら toArticleListItems() だけを unit test してもよいですが、本来exportする必要のない関数をテストするために export するのはなるべく避けたいです。
まとめ
現時点での自分の最適解は、こうです。
- 自前の
requestJsonや axios は基本的に持たない - OpenAPI がある場合は
openapi-fetchを使う - HTTP / network / timeout error は
infrastructures/apiで正規化する - 共通 header や 401 処理は middleware に閉じ込める
- data extraction は
unwrapApiDataに寄せ、selectにundefinedや error の扱いを混ぜない - retry / 非 retry などの共通制御は
QueryClientに閉じ込める - API 呼び出しは hook の
queryFn/mutationFnに閉じ込める - server state は React Query に任せる
- 初期表示に必須のデータは Suspense を使う
- loading が必要な場所だけ
useQueryの状態を使う - API レスポンスから表示モデルへの変換は hook の
selectに寄せる - テストは API error 正規化と hook の戻り値を見る
ポイントは、axios か fetch かではありません。
大事なのは、API の都合、server state の都合、表示の都合を混ぜないことです。
自分の用途では、openapi-fetch と React Query の組み合わせがちょうどよいです。
通信は型付きの薄い client に寄せ、error 正規化は infrastructures/api に寄せ、共通制御は QueryClient に寄せ、状態管理は React Query に任せ、component は表示に集中させる。
しばらくはこの形を基本にすると思います。