「フロントエンドとバックエンドを分離して開発する」と言っても、バックエンドAPIが完成するまでフロントエンド側が待っていては、実際の開発は並行化できません。

そこで重要になるのが、画面からHTTP通信の詳細を切り離すAPI層と、実際のバックエンドの代わりにレスポンスを返すMock APIです。

この記事では、小さなTodoアプリを題材に、React側を次のように分けます。

ファイル主な責務
main.jsxReactアプリを起動する
App.jsxルーティングとページの読み込みを管理する
Nav.jsxページ間のナビゲーションを提供する
Home.jsx / Todos.jsxページの表示と状態管理を担当する
api/config.jsHTTPクライアントの共通設定を持つ
api/todos.jsTodo APIを関数として公開する
mock/todos.js開発用のMockレスポンスを定義する

中心になる考え方は単純です。

ページコンポーネントから見るデータ取得方法を固定し、その向こう側にあるデータソースだけを差し替えます。

まず全体構成を見る

今回の例では、次のような構成を想定します。

src/
├── main.jsx
├── App.jsx
├── components/
│   └── Nav.jsx
├── pages/
│   ├── Home.jsx
│   └── Todos.jsx
└── api/
    ├── config.js
    └── todos.js
mock/
└── todos.js
vite.config.js

pagesから直接URLを書いてHTTPリクエストを送るのではなく、必ずapiディレクトリを経由します。

これにより、画面側は「どのURLへアクセスするか」「Mockなのか本番APIなのか」といった事情を意識せずに済みます。

1. main.jsx:エントリーポイントは薄くする

main.jsxは、ReactアプリをDOMへマウントするためのエントリーポイントです。

// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";

const rootElement = document.getElementById("root");

if (!rootElement) {
  throw new Error("#root element was not found");
}

createRoot(rootElement).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

ここでは、アプリケーション固有のデータ取得や画面ロジックを持たせません。

StrictModeは、開発時に追加のチェックを有効にします。開発環境では、問題のある副作用を見つけやすくするためにコンポーネントのレンダーやEffectのセットアップ・クリーンアップが追加で実行されることがあります。

本番環境で同じ回数だけ実行されるという意味ではありません。

2. App.jsx:ルーティングと遅延読み込みをまとめる

次に、React Routerでページを切り替えます。

// src/App.jsx
import { lazy, Suspense } from "react";
import {
  BrowserRouter,
  Route,
  Routes,
} from "react-router-dom";
import Nav from "./components/Nav.jsx";

const Home = lazy(() => import("./pages/Home.jsx"));
const Todos = lazy(() => import("./pages/Todos.jsx"));

export default function App() {
  return (
    <BrowserRouter>
      <Nav />

      <Suspense fallback={<p>読み込み中...</p>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/todos" element={<Todos />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

BrowserRouterがURLとページを対応させる

BrowserRouterはHistory APIを使ってブラウザーのURLを管理します。

RoutesRouteでは、現在のURLに応じて表示するコンポーネントを決めます。

この例では次の対応です。

URLページ
/Home
/todosTodos

NavRoutesの外側に置いているため、ページを切り替えてもナビゲーション自体は残ります。

lazyとSuspenseでページコードを必要になったときに読み込む

const Todos = lazy(() => import("./pages/Todos.jsx"));

lazyを使うと、そのコンポーネントが初めて必要になったタイミングまでコードの読み込みを遅らせられます。

たとえば利用者が/todosを開かなければ、Todoページのコードを最初から読み込む必要はありません。

遅延読み込み中はSuspensefallbackが表示されます。

<Suspense fallback={<p>読み込み中...</p>}>
  <Routes>{/* 各ページ */}</Routes>
</Suspense>

lazyを使う場合は、読み込み待ちを処理するSuspense境界も用意しておく必要があります。

3. Nav.jsx:SPA内の移動にはLinkを使う

ナビゲーションはReact RouterのLinkで実装します。

// src/components/Nav.jsx
import { Link } from "react-router-dom";

export default function Nav() {
  return (
    <nav>
      <Link to="/">Home</Link>
      {" | "}
      <Link to="/todos">Todos</Link>
    </nav>
  );
}

通常の<a href="/todos">でもページ移動はできますが、ブラウザーは新しいドキュメントとしてページを読み直します。

一方、React RouterのLinkはルーターに遷移を任せるため、SPA内でページ全体を再取得せずに表示を切り替えられます。

これは単なる見た目の違いではなく、ルーティングをクライアント側で管理するSPAの基本的な構成です。

4. Home.jsxとTodos.jsx:ページはAPI関数だけを見る

Home.jsxは単純なページです。

// src/pages/Home.jsx
export default function Home() {
  return <h1>Home</h1>;
}

重要なのはTodos.jsxです。

// src/pages/Todos.jsx
import { useEffect, useState } from "react";
import { getTodos } from "../api/todos.js";

export default function Todos() {
  const [todos, setTodos] = useState([]);
  const [errorMessage, setErrorMessage] = useState("");

  useEffect(() => {
    const controller = new AbortController();

    const loadTodos = async () => {
      try {
        const data = await getTodos({ signal: controller.signal });
        setTodos(data);
      } catch (error) {
        // アンマウント時の中断は画面上のエラーとして扱わない。
        if (!controller.signal.aborted) {
          setErrorMessage("Todoの取得に失敗しました。");
        }
      }
    };

    loadTodos();

    return () => {
      controller.abort();
    };
  }, []);

  if (errorMessage) {
    return <p>{errorMessage}</p>;
  }

  return (
    <main>
      <h1>Todos</h1>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.completed ? "完了" : "未完了"}: {todo.title}
          </li>
        ))}
      </ul>
    </main>
  );
}

このページが知っているデータ取得方法は、次の1行だけです。

const data = await getTodos({ signal: controller.signal });

Todos.jsxは次のことを知りません。

  • 実際のURLが/api/todosなのか
  • HTTPクライアントとしてaxiosを使っているのか
  • 開発中はMockレスポンスなのか
  • 本番では別ドメインのAPIへアクセスするのか

この境界が、フロントエンドとバックエンドの結合を弱くします。

useEffectの空の依存配列は「本番でマウント時に実行する」と考える

useEffect(..., [])は、依存値の変更による再実行を行わないEffectです。

ただし、「どんな環境でも必ず1回しか実行されない」と覚えるのは正確ではありません。

ReactのStrictModeをルートで有効にしている場合、開発時にはEffectの問題を検出するため、追加のセットアップとクリーンアップが実行されます。

そのため、HTTPリクエストのような外部システムとの同期処理では、中断やクリーンアップを考えておく方が安全です。

5. api/config.js:HTTPクライアントの設定を集約する

次に、axiosのインスタンスを1か所で作ります。

// src/api/config.js
import axios from "axios";

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api",
  timeout: 5_000,
});

export default api;

ポイントは、ページごとにaxios.get(...)を書くのではなく、共通設定済みのインスタンスを作ることです。

axios.create()では、baseURLやタイムアウト、ヘッダーなどをまとめて設定できます。

この例では、baseURLをViteの環境変数から受け取ります。

開発環境では、たとえば次のように設定できます。

# .env.development
VITE_API_BASE_URL=/api

実APIへ切り替える環境では、APIの公開URLへ変更します。

# .env.production
VITE_API_BASE_URL=https://api.example.com

こうしておけば、APIの接続先を変えるためにソースコードそのものを書き換える必要はありません。

なお、VITE_で始まる環境変数はクライアント側のバンドルから参照できます。したがって、APIキーやパスワードなどの秘密情報を入れてはいけません。

Axiosのインスタンスについては公式ドキュメントにもbaseURLtimeoutをまとめる例があります。

6. api/todos.js:URLとレスポンス形式をページから隠す

Todo関連のAPIはapi/todos.jsへ集約します。

// src/api/todos.js
import api from "./config.js";

export async function getTodos({ signal } = {}) {
  const response = await api.get("/todos", { signal });
  return response.data.todos;
}

api/config.jsbaseURL/apiなら、実際のリクエスト先は次のURLになります。

/api/todos

ページ側はresponse.data.todosというレスポンス構造も知る必要がありません。

たとえばバックエンド側の仕様変更でレスポンスが次のようになったとします。

{
  "data": {
    "items": []
  }
}

その場合でも、getTodos()の戻り値を同じTodo配列にそろえれば、ページ側のsetTodos(data)は変更せずに済みます。

export async function getTodos({ signal } = {}) {
  const response = await api.get("/todos", { signal });
  return response.data.data.items;
}

つまりAPI層は、単にURLをまとめる場所ではありません。

バックエンドのHTTP仕様を、フロントエンドが使いやすい関数へ変換する境界として使えます。

fetchではなくaxiosでなければならないのか

必ずしもaxiosである必要はありません。

現在のFetch APIでも、AbortControllerAbortSignalを使ってリクエストを中断できます。対応環境ではAbortSignal.timeout()によるタイムアウトも利用できます。

一方、axiosにはaxios.create()による設定共有、インターセプター、共通ヘッダー、レスポンス処理などをまとめやすい利点があります。

そのため、「fetchからaxiosへアップグレードする」と考えるより、プロジェクトで必要な共通処理に応じてHTTPクライアントを選ぶ方が実態に合っています。

API層を用意しておけば、将来HTTPクライアントを変更するときも、その変更をページコンポーネントまで広げずに済みます。

7. mock/todos.js:バックエンドの代わりになるレスポンスを用意する

バックエンドがまだ実装されていない段階では、/api/todosへMockレスポンスを返します。

元の構成と同じくvite-plugin-mockを使う場合、Mock定義は次のように書けます。

// mock/todos.js
export default [
  {
    url: "/api/todos",
    method: "get",
    timeout: 800,
    response: () => ({
      code: 0,
      todos: [
        {
          id: 1,
          title: "API層を実装する",
          completed: true,
        },
        {
          id: 2,
          title: "Todo画面を仕上げる",
          completed: false,
        },
      ],
    }),
  },
];

timeoutを少し入れておくと、読み込み状態や連打時の挙動も確認しやすくなります。

Vite側ではMockプラグインを有効にします。

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteMockServe } from "vite-plugin-mock";

export default defineConfig({
  plugins: [
    react(),
    viteMockServe({
      mockPath: "mock",
      enable: true,
    }),
  ],
});

vite-plugin-mockの現在の設定では、Mock機能の有効化にenableを使えます。

これで開発サーバー上の/api/todosへアクセスすると、バックエンドへ到達する代わりにMock定義からレスポンスが返ります。

重要なのは、Todos.jsx側のコードが変わらないことです。

Todos.jsx

getTodos()

axios instance

GET /api/todos

開発時: Mock API
本番時: 実バックエンド

ページから見ると、どちらも同じgetTodos()です。

Mockと実APIでレスポンス契約をそろえる

Mockを用意しても、実際のバックエンドとレスポンス形式が違えば、結合時に大きな修正が必要になります。

今回の例では、次のレスポンスを契約として扱っています。

{
  "code": 0,
  "todos": [
    {
      "id": 1,
      "title": "API層を実装する",
      "completed": true
    }
  ]
}

フロントエンドとバックエンドで先に決めておきたいのは、少なくとも次の項目です。

  • HTTPメソッド
  • URL
  • リクエストパラメーター
  • 成功時のレスポンス構造
  • エラー時のレスポンス構造
  • 各フィールドの型
  • nullや省略可能フィールドの扱い

Mockは「適当なダミーデータ」ではなく、未完成のAPIを先に契約として表現するものとして使うと効果的です。

1回のデータ取得を順番に追う

ここまでの処理を/todosへのアクセスから順番に並べます。

  1. 利用者が/todosを開く
  2. App.jsxのルートがTodosを選ぶ
  3. Todos.jsxがマウントされる
  4. useEffectからgetTodos()を呼ぶ
  5. api/todos.jsが共通のaxiosインスタンスを使う
  6. baseURL + /todos/api/todosへリクエストする
  7. 開発時はMockがリクエストを受けてTodo一覧を返す
  8. getTodos()がレスポンスからTodo配列だけを取り出す
  9. Todos.jsxsetTodos()でstateを更新する
  10. Todo一覧が再レンダーされる

この流れでは、ページコンポーネントがMockの存在を確認する処理はありません。

それが重要です。

ページ側に次のような条件分岐が増えると、Mockと本番APIの切り替えが画面ロジックへ漏れ始めます。

// 避けたい例
if (isMock) {
  return mockTodos;
}

return fetchRealTodos();

Mockと実APIの切り替えは、通信層や開発環境の設定側で吸収する方が、ページの責務を保ちやすくなります。

Mockを使えば完全にバックエンドから独立できるわけではない

ここでいう「独立開発」は、バックエンドと相談せず好きな仕様で作ってよいという意味ではありません。

APIの契約が曖昧なまま双方が別々に実装すると、最終的な結合時に次のような差が見つかります。

  • フィールド名が違う
  • 型が違う
  • エラー形式が違う
  • ページネーション方式が違う
  • 認証方法が違う
  • 日付やタイムゾーンの扱いが違う

したがって、並行開発で先に合意すべきなのは実装そのものではなく、APIの境界です。

その境界が決まっていれば、フロントエンドはMockでUIとデータフローを作り、バックエンドは同じ契約に合わせて実APIを作れます。

まとめ

Reactフロントエンドがバックエンドの完成を待たずに進めるために重要なのは、Mockそのものよりも、Mockへ差し替えられる構造を先に作ることです。

今回の構成では、役割を次のように分けました。

考え方実装
ページからHTTPの詳細を隠すapi/todos.js
共通HTTP設定を集約するapi/config.js
接続先を環境ごとに切り替えるVITE_API_BASE_URL
未完成のAPIを再現するmock/todos.js
ページはAPI関数だけを呼ぶgetTodos()

最終的に目指したい状態は、次の一文にまとめられます。

フロントエンドはバックエンドの実装完了を待たず、合意したAPI契約に対して開発する。

API層とMockを用意しておけば、画面開発、状態管理、エラー表示、読み込み中のUIなどを先に進められます。実APIが完成した後は、同じ契約を保ったまま接続先を切り替えて結合確認へ進めます。