Next.js,Goを使ってモダンTodoアプリを作っていく
初学者が一度は作るであろうTodoアプリ。最近Go言語を勉強しているので、学習用に作ってみました。
なぜNext.jsとGoなのか
Next.jsについて
近年のWeb開発のフロントエンドではNext.js×TypeScriptが主流になっている気がするので、改めてNext.jsの利点を整理してみました。
・SSR(サーバーサイドレンダリング)やSSG(静的サイト生成)によって検索エンジン最適化(SEO)とページロードの高速化を同時に実現できる。
・Next.jsにはファイルベースのルーティングや簡単にAPIルートを追加できる仕組みがあり、開発効率を大幅に向上させることができる。
Goについて
バックエンドの言語はPython,PHP,Ruby,Node.js,Rust,Goなど様々な選択肢がありますが、なぜそのプログラミング言語を使うのか、について言及した記事が少ないと感じたのでこちらもGoの利点を整理します。
・Goはコンパイル言語であるため、他のインタプリタ型言語に比べて高速に動作し、実行時エラーも少なくなる。
・並行処理(ゴルーチン)を標準でサポートしているため、大量のリクエストを効率よく処理でき処理速度や同時接続数の点で他の言語よりGoが優れている。
ディレクトリ構成
以下のようなディレクトリ構成になっています。
フロントエンド
/frontend
├── app
│ ├── page.tsx # メインページ
├── lib
│ ├── api.ts # APIと通信するための関数
└── tsconfig.json # TypeScript設定ファイル
バックエンド
/backend
├── main.go
コード詳細
api.ts
// Todoリストを取得するリクエスト
export const getTodos = async () => {
const response = await fetch('http://localhost:8080/todos');
return response.json();
};
// Todoリストに追加するリクエスト
export const addTodo = async (text: string) => {
const response = await fetch('http://localhost:8080/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
return response.json();
};
まずバックエンドのAPIと通信するための関数を/lib/api.tsにまとめます。
・getTodos によって、GET リクエストを http://localhost:8080/todos に送信し、TODOリストのデータをjson形式で取得します。
・同様にaddTodoによって、POSTリクエストをバックエンドのAPIに送信し、TODOリストにデータを追加します。
page.tsx
'use client'
import { useState, useEffect } from 'react';
import { getTodos, addTodo } from '../lib/api';
interface Todo {
id: string;
text: string;
}
export default function Home() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodo, setNewTodo] = useState<string>('');
// 初回レンダリングのデータ取得
useEffect(() => {
const fetchTodos = async () => {
const data = await getTodos();
setTodos(data);
};
fetchTodos();
}, []);
//Todoを追加する処理
const handleAddTodo = async () => {
if (newTodo.trim() === '') return;
const addedTodo = await addTodo(newTodo);
setTodos([...todos, addedTodo]);
setNewTodo('');
};
return (
<div>
<h1>TODO List</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
/>
<button onClick={handleAddTodo}>Add TODO</button>
</div>
);
}
・Todo interfaceは、TODOアイテムの型を定義しています。id(識別子)とtext(内容)をここで宣言します。
・useEffectを使って、コンポーネントが初めてレンダリングされたときに fetchTodos 関数を実行し、getTodos から取得したデータを todos 状態にセットします。
※今回はデータベースを用いてないのであまり効果を実感できないかもしれませんが、初回レンダリング時にデータべースからデータを取得するときに用います。
・handleAddTodo 関数によって、newTodo の内容が空でない場合に新しいTODOアイテムを追加します。
正確には、addTodo を呼び出してサーバーに新しいTODOを追加し、サーバーから返ってきた追加されたTODOアイテム(addedTodo)をtodosに追加します。
setTodos([...todos, addedTodo]); は、既存のTODOリストに新しいTODOを追加した配列でtodosを更新しています。
最後に setNewTodo('') を呼び出して、newTodo 状態をリセットします。
main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/google/uuid"
)
type Todo struct {
ID string `json:"id"`
Text string `json:"text"`
}
var todos = []Todo{}
// GETリクエストに対応する関数
func getTodos(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
// POSTリクエストに対応する関数
func addTodo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
var newTodo Todo
if err := json.NewDecoder(r.Body).Decode(&newTodo); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newTodo.ID = uuid.New().String()
todos = append(todos, newTodo)
json.NewEncoder(w).Encode(newTodo)
}
// CORS対応のためのエントリポイント関数
func handleTodos(w http.ResponseWriter, r *http.Request) {
// CORS設定
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// OPTIONSメソッドの処理(プリフライトリクエストに対応)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// メソッドによって異なる処理を実行
switch r.Method {
case "GET":
getTodos(w, r)
case "POST":
addTodo(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func main() {
http.HandleFunc("/todos", handleTodos)
fmt.Println("Server is running on port 8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
・グローバル変数todosとして空のスライス []Todo{} で初期化し、TODOリストを保持します。
・getTodos 関数は、フロントエンドからの GET リクエストを処理し、現在のTODOリストをJSON形式で返します。
※w.Header().Set("Access-Control-Allow-Origin", "*")は CORS (Cross-Origin Resource Sharing) の設定で、任意のオリジンからのアクセスを許可しています。これにより、異なるドメインのフロントエンドアプリケーションからAPIにアクセス可能になります。
・同様にaddTodo 関数は、クライアントからの POST リクエストを処理し、新しいTODOアイテムを追加します。
var newTodo Todo: リクエストボディからデータを受け取るための一時的な変数です。
json.NewDecoder(r.Body).Decode(&newTodo): リクエストボディをデコードして newTodo に格納します。デコードエラーが発生した場合、400 Bad Request エラーを返して終了します。
newTodo.ID = uuid.New().String(): 新しいTODOアイテムに一意なIDを割り当てます。
todos = append(todos, newTodo): 新しいTODOを todos スライスに追加します。
json.NewEncoder(w).Encode(newTodo): 新しく追加されたTODOをJSON形式でエンコードしてレスポンスとして返します。
・handleTodosによってCORSの設定を行い、それぞれのメソッドによってgetTodos,addTodosを呼び出します。
・main関数でサーバの立ち上げ、handleTodosの呼び出しを行います。
デモ映像
このような動作をしていればここまでは終了です!
しかし、これじゃあまりにも簡素すぎますね。次は削除、編集機能を追加していきたいと思います。
編集、削除機能追加
上で作成したTodoアプリに編集機能と削除機能を追加していきます。
コードを見たほうが理解できると思うので、さっそくフロントエンドのコードから見ていきます。
api.ts
// Todoを取得するリクエスト
export const getTodos = async () => {
const response = await fetch('http://localhost:8080/todos');
return response.json();
};
// Todoに追加するリクエスト
export const addTodo = async (text: string) => {
const response = await fetch('http://localhost:8080/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
return response.json();
};
// Todoを編集するリクエスト
export const updateTodo = async (id: string, text: string) => {
const response = await fetch(`http://localhost:8080/todos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
return response.json();
};
// Todoを削除するリクエスト
export const deleteTodo = async (id: string) => {
await fetch(`http://localhost:8080/todos/${id}`, {
method: 'DELETE',
});
};
Todoを編集するリクエストとTodoを削除するリクエストを追加しました。
http://localhost:8080/todos/${id}
で個々のTodoにアクセスしてPUT,DELETEを行えるようにします。
page.tsx
'use client'
import { useState, useEffect } from 'react';
import { getTodos, addTodo, updateTodo, deleteTodo } from '../lib/api';
import './globals.css';
interface Todo {
id: string;
text: string;
}
export default function Home() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodo, setNewTodo] = useState<string>('');
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
// 初回レンダリングのデータ取得
useEffect(() => {
const fetchTodos = async () => {
const data = await getTodos();
setTodos(data);
};
fetchTodos();
}, []);
//Todoを追加する処理
const handleAddTodo = async () => {
if (newTodo.trim() === '') return;
const addedTodo = await addTodo(newTodo);
setTodos([...todos, addedTodo]);
setNewTodo('');
};
// Todoを編集する処理
const handleUpdateTodo = async (id: string, text: string) => {
const updatedTodo = await updateTodo(id, text);
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
setEditingTodo(null);
};
// Todoを削除する処理
const handleDeleteTodo = async (id: string) => {
await deleteTodo(id);
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div className="container">
<h1>TODO List</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{editingTodo?.id === todo.id ? (
<input
type="text"
placeholder="Add new todo"
value={editingTodo.text}
onChange={(e) =>
setEditingTodo({ ...editingTodo, text: e.target.value })
}
/>
) : (
<span>{todo.text}</span>
)}
{editingTodo?.id === todo.id ? (
<button onClick={() => handleUpdateTodo(todo.id, editingTodo.text)}>Save</button>
) : (
<button onClick={() => setEditingTodo(todo)}>Edit</button>
)}
<button onClick={() => handleDeleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
/>
<button onClick={handleAddTodo}>Add TODO</button>
</div>
);
}
追加箇所を説明します。
・editingTodoで現在編集中のTODOアイテムを保持する状態を宣言
・updateTodo関数でAPI経由でTODOを更新し、返された更新済みTODOを使用してtodos状態を更新します。
・delete関数を呼び出してAPI経由でTODOを削除します。
・JSXでのレンダリングについて
各todoアイテムには3つのボタンが表示されます。
Edit: editingTodoにtodoを設定して編集モードにします。
Save: handleUpdateTodoを呼び出してTODOを更新します。
Delete: handleDeleteTodoを呼び出してTODOを削除します。
main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/google/uuid"
)
type Todo struct {
ID string `json:"id"`
Text string `json:"text"`
}
var todos = []Todo{}
// GETリクエストに対応する関数
func getTodos(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
// POSTリクエストに対応する関数
func addTodo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
var newTodo Todo
if err := json.NewDecoder(r.Body).Decode(&newTodo); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newTodo.ID = uuid.New().String()
todos = append(todos, newTodo)
json.NewEncoder(w).Encode(newTodo)
}
// 更新リクエストに対応する関数
func updateTodo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
// パスからIDを取得
id := r.URL.Path[len("/todos/"):]
// 新しいデータを取得
var updatedTodo Todo
if err := json.NewDecoder(r.Body).Decode(&updatedTodo); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Todoを更新
for i, todo := range todos {
if todo.ID == id {
todos[i].Text = updatedTodo.Text
json.NewEncoder(w).Encode(todos[i])
return
}
}
http.Error(w, "todo not found", http.StatusNotFound)
}
// 削除リクエストに対応する関数
func deleteTodo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
// パスからIDを取得
id := r.URL.Path[len("/todos/"):]
// Todoを削除
for i, todo := range todos {
if todo.ID == id {
todos = append(todos[:i], todos[i+1:]...)
w.WriteHeader(http.StatusNoContent)
return
}
}
http.Error(w, "Todo not found", http.StatusNotFound)
}
// CORS対応のためのエントリポイント関数
func handleTodos(w http.ResponseWriter, r *http.Request) {
// CORS設定
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// OPTIONSメソッドの処理(プリフライトリクエストに対応)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// メソッドによって異なる処理を実行
switch r.Method {
case "GET":
getTodos(w, r)
case "POST":
addTodo(w, r)
case "PUT":
updateTodo(w, r)
case "DELETE":
deleteTodo(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func main() {
http.HandleFunc("/todos", handleTodos)
http.HandleFunc("/todos/", handleTodos) //特定のIDを扱うためのエンドポイント
fmt.Println("Server is running on port 8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
updateTodo
・id := r.URL.Path[len("/todos/"):]でリクエストURLからIDを抽出します。
・todosスライスをループし、IDが一致するTODOのテキストを更新します。見つかったTODOは更新後、レスポンスとして返します。
deleteTodo
・指定したIDのTODOを検索し、見つかった場合はtodosから削除します。削除が成功した場合、204 No Contentステータスを返します。
handleTodos
・PUTとDELETEメソッドが呼び出されたケースを追加しています。
main関数
・http.HandleFunc("/todos/", handleTodos)で特定のIDを受け付けたときのエンドポイントを設定します。
デモ映像
出来ました!
CSSも少しつけてみました。これで最低限の機能を持ったTODOアプリをNext.jsとGoで作ることが出来ました。
これにログイン機能やデータベースを追加したりしてより実用的なものにしていけばさらにプログラミングの知識を深めることが出来ると思います。
次回は強化版のTODOアプリを作っていきたいと思います!