Todoアプリ - 連携編
いよいよ最終ステップ!フロントエンドとバックエンドを連携させて、完全なTodoアプリを完成させます。
連携の流れ
何が変わる? 前ページではモックデータ(仮のデータ)を使っていたため、ページをリロードすると元に戻っていました。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", }); }
コード解説
API通信は時間がかかる処理なので、async関数の中でawaitを使って「完了を待ってから次に進む」ようにしています。
ブラウザ標準のHTTP通信関数。第1引数にURL、第2引数にオプション(HTTPメソッド、ヘッダー、ボディ)を渡します。
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 でバックエンドと通信する
- フロントエンドとバックエンドを連携させる