Notes に戻る
🧭

フロントエンドでAPIからデータを取得して表示するまでの現時点での自分の最適解

フロントエンドでAPIからデータを取得して表示するまでに、自分が採用しているopenapi-fetch、API error正規化、React Query、Suspense、select、テストの分け方を整理する。

#frontend#api#typescript#architecture

はじめに

フロントエンドで 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.tscreateClient と middleware だけを担当する
  • infrastructures/api/error.ts は API error の型と unwrapApiData を担当する
  • hooks/useSuspenseQueryuseQuery の中で 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-fetchfetch の薄い 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 は onErrorNetworkError / TimeoutError に正規化します。

hook 側では unwrapApiData で data を取り出すだけにします。

ここで unwrapApiData を使っているのは、selectundefined の扱いを混ぜないようにするためです。 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 側で isLoadingdata === 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 に寄せ、selectundefined や 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 は表示に集中させる。

しばらくはこの形を基本にすると思います。