第3部:実践チュートリアル Step 16 / 20

Todoアプリ - 連携編

いよいよ最終ステップ!フロントエンドとバックエンドを連携させて、完全なTodoアプリを完成させます。

連携の流れ

Next.js
localhost:3000
⇄ fetch API ⇄
FastAPI
localhost:8000

何が変わる? 前ページではモックデータ(仮のデータ)を使っていたため、ページをリロードすると元に戻っていました。API連携により、データがバックエンド側に保存され、リロードしても残るようになります。

Step 1: API通信関数を作成

まず、バックエンドと通信するための関数をまとめたファイルを作成します。API通信の処理を1つのファイルにまとめておくと、コンポーネントがすっきりします。

AIへの指示

frontend/src/lib/api.ts を作成して、
TodoのAPI通信関数を作成してください。

関数:
- fetchTodos(): 全Todo取得
- createTodo(title: string): Todo作成
- updateTodo(id: number, data: { completed?: boolean }): Todo更新
- deleteTodo(id: number): Todo削除

APIのベースURL: http://localhost:8000

完成コード: src/lib/api.ts

import { Todo } from "@/types/todo";

const API_URL = "http://localhost:8000";

export async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch(`${API_URL}/todos`);
  return res.json();
}

export async function createTodo(title: string): Promise<Todo> {
  const res = await fetch(`${API_URL}/todos`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title }),
  });
  return res.json();
}

export async function updateTodo(
  id: number,
  data: { title?: string; completed?: boolean }
): Promise<Todo> {
  const res = await fetch(`${API_URL}/todos/${id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  return res.json();
}

export async function deleteTodo(id: number): Promise<void> {
  await fetch(`${API_URL}/todos/${id}`, {
    method: "DELETE",
  });
}

コード解説

async/await

API通信は時間がかかる処理なので、async関数の中でawaitを使って「完了を待ってから次に進む」ようにしています。

fetch

ブラウザ標準のHTTP通信関数。第1引数にURL、第2引数にオプション(HTTPメソッド、ヘッダー、ボディ)を渡します。

JSON.stringify

JavaScriptのオブジェクトをJSON文字列に変換。APIにデータを送るときはJSON形式にする必要があります。

Step 2: ページをAPI連携に変更

AIへの指示

frontend/src/app/page.tsx を編集して、
モックデータの代わりにAPIを使うように変更してください。

変更内容:
1. useEffectで初回読み込み時にfetchTodosを呼ぶ
2. addTodo関数をcreateToを使うように変更
3. toggleTodo関数をupdateTodoを使うように変更
4. deleteTodo関数をdeleteTodoAPIを使うように変更
5. ローディング状態を追加(読み込み中は「Loading...」を表示)

完成コード: src/app/page.tsx(API連携版)

"use client";

import { useState, useEffect } from "react";
import { Todo } from "@/types/todo";
import * as api from "@/lib/api";

export default function Home() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTitle, setNewTitle] = useState("");
  const [loading, setLoading] = useState(true);

  // 初回読み込み:ページ表示時にAPIからTodoを取得
  useEffect(() => {
    api.fetchTodos().then((data) => {
      setTodos(data);
      setLoading(false);
    });
  }, []);

  // Todo追加:APIに送信してから画面を更新
  const addTodo = async () => {
    if (!newTitle.trim()) return;
    const newTodo = await api.createTodo(newTitle);
    setTodos([...todos, newTodo]);
    setNewTitle("");
  };

  // 完了切り替え:APIに更新を送信
  const toggleTodo = async (id: number, completed: boolean) => {
    const updated = await api.updateTodo(id, {
      completed: !completed,
    });
    setTodos(todos.map(t => t.id === id ? updated : t));
  };

  // 削除:APIから削除してから画面を更新
  const handleDelete = async (id: number) => {
    await api.deleteTodo(id);
    setTodos(todos.filter(t => t.id !== id));
  };

  // ローディング中の表示
  if (loading) {
    return (
      <main className="min-h-screen p-8 max-w-md mx-auto
        text-center text-gray-500">
        Loading...
      </main>
    );
  }

  return (
    <main className="min-h-screen p-8 max-w-md mx-auto">
      <h1 className="text-2xl font-bold mb-6 text-center">
        Todoリスト
      </h1>

      {/* 追加フォーム */}
      <div className="flex mb-6">
        <input
          type="text"
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && addTodo()}
          placeholder="新しいTodoを入力..."
          className="flex-1 border rounded-l-lg px-4 py-2"
        />
        <button
          onClick={addTodo}
          className="bg-blue-500 text-white px-4 py-2
            rounded-r-lg hover:bg-blue-600"
        >
          追加
        </button>
      </div>

      {/* Todoリスト */}
      <ul className="space-y-2">
        {todos.map((todo) => (
          <li
            key={todo.id}
            className="flex items-center justify-between
              p-3 bg-gray-50 rounded"
          >
            <div className="flex items-center">
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id, todo.completed)}
                className="mr-3"
              />
              <span
                className={todo.completed ?
                  "line-through text-gray-400" : ""}
              >
                {todo.title}
              </span>
            </div>
            <button
              onClick={() => handleDelete(todo.id)}
              className="text-red-500 hover:text-red-700"
            >
              削除
            </button>
          </li>
        ))}
      </ul>
    </main>
  );
}

モック版からの変更点

1. useEffect の追加

useEffect(() => { ... }, []) はページが表示されたときに1回だけ実行される処理です。ここでAPIからTodoデータを取得しています。

末尾の [](空の依存配列)は「初回表示のときだけ実行」という意味。これを忘れると無限ループになるので注意。

2. 関数を async に変更

各関数に async を付け、API呼び出しに await を使っています。

Before(モック版)

const addTodo = () => { ... }

After(API版)

const addTodo = async () => { ... }

3. ローディング状態の追加

loading 状態を追加して、データ取得中は「Loading...」を表示。API通信が完了したら setLoading(false) でローディングを終了し、Todoリストを表示します。

4. toggleTodoの引数追加

モック版では toggleTodo(id) だけでしたが、API版では toggleTodo(id, completed) と現在のcompletedの値も渡すようにしました。APIに「反転後の値」を送るためです。

Step 3: 動作確認

両方のサーバーを起動

ターミナル1(バックエンド)

cd backend
uvicorn main:app --reload

ターミナル2(フロントエンド)

cd frontend
npm run dev

重要:バックエンドを先に起動してください。フロントエンドが起動時にバックエンドに接続しようとするためです。

確認項目チェックリスト

  • ページを開くとバックエンドのデータが表示される
  • 新しいTodoを追加すると、バックエンドにも保存される
  • 完了状態を切り替えると、バックエンドも更新される
  • 削除すると、バックエンドからも削除される
  • ページをリロードしてもデータが残っている!

Todoアプリ完成!

おめでとうございます!Next.js + FastAPIのフルスタックアプリが完成しました!

よくあるエラーと解決法

CORSエラー

ブラウザのコンソール(F12 → Console)に「Access to fetch ... has been blocked by CORS policy」と表示される

原因:バックエンドが、フロントエンドからのアクセスを許可していない。

解決法:FastAPIの main.py でCORS設定の allow_origins"http://localhost:3000" が含まれているか確認。

Loading... のまま表示されない

「Loading...」がずっと表示されたまま

原因:バックエンドサーバーが起動していない、またはURLが間違っている。

解決法:(1) バックエンドが起動しているか確認 → http://localhost:8000/docs にアクセス (2) api.ts の API_URL が正しいか確認

追加/更新/削除が反映されない

操作してもリストが変わらない

原因:APIの戻り値がうまく処理されていない。

解決法:(1) 関数に async/await が付いているか確認 (2) ブラウザのConsoleでエラーを確認 (3) バックエンドの /docs で直接APIをテスト

422 Unprocessable Entity

Consoleに「422」エラーが表示される

原因:フロントエンドが送るデータの形式とバックエンドが期待する形式が一致していない。

解決法:(1) Content-Type: application/json ヘッダーがあるか確認 (2) JSON.stringifyで送るデータの構造がPydanticモデルと一致しているか確認

デバッグのコツ

ブラウザのDevTools(F12キー)を活用しましょう:

  • Console タブ:JavaScriptのエラーメッセージを確認
  • Network タブ:API通信の詳細(リクエスト内容、レスポンス、ステータスコード)を確認

第3部完了!

チュートリアルお疲れさまでした!Next.js + FastAPIでフルスタックアプリを作る流れを体験できました。

習得したスキル:

  • TypeScriptで型定義を作成する
  • Reactのコンポーネントを組み立てる
  • useState / useEffect で状態管理をする
  • fetch API でバックエンドと通信する
  • フロントエンドとバックエンドを連携させる
前へ:フロントエンド編 次へ:よくあるエラー集