Honoの存在は以前から知っていましたが、実際に試したことはありませんでした。
そこで今回は、Honoを使って簡単なタスク管理アプリケーションを作成してみます。
タスクの作成・取得・編集・削除を実現するアプリケーションを作ります。
前提
あくまで入門なので、できるだけ設定が少なく済む構成にしました。
-
API
- Hono
- SQLite
- Drizzle ORM
-
フロントエンド
- React
- Vite
- SWR
Githubリポジトリ
API側の作成
プロジェクト用のディレクトリを作成します。
mkdir task-app
cd task-app
API側のプロジェクトをセットアップしていきます。
pnpm create hono@latest
インストール時の対話式プロンプトでは以下のように設定しました。
Target directory: api
Which template do you want to use?: nodejs
Do you want to install project dependencies?: yes
Which package manager do you want to use?: pnpm
DBの設定周りの処理を書いていきます。
必要なファイルの作成と、ライブラリのインストールを行います。
cd ./api/src/
touch db.ts
touch tasks.db
pnpm i better-sqlite3 drizzle-orm
pnpm i -D @types/better-sqlite3
テーブル作成用のSQL、db インスタンスの定義、テーブルスキーマの定義を書きます。
// ./db.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import { sqliteTable, int, text } from "drizzle-orm/sqlite-core";
const sqlite = new Database("./tasks.db");
export const db = drizzle(sqlite);
db.run(
`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL
);
`
);
export const tasks = sqliteTable(
"tasks",
{
id: int().primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
},
() => []
);
アプリケーションのミドルウェアの設定を書いていきます。
touch middleware.ts
Honoインスタンスの定義、corsの設定、エラーの補足を書いていきます。
// ./middleware.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
export const app = new Hono();
app.use("*", async (c, next) => {
c.res.headers.append("Access-Control-Allow-Origin", "*");
c.res.headers.append(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
c.res.headers.append(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
if (c.req.method === "OPTIONS") {
return c.text("", 204);
}
await next();
});
app.onError((err, c) => {
console.error(err);
return c.json({ error: err.message || "Internal Server Error" }, 500);
});
serve(app);
次にAPIのエンドポイントを定義していきます。
エンドポイントは以下のようにします。
-
/tasks
[GET] タスクの取得 -
/tasks
[POST] タスクの登録 -
/tasks/:id
[PUT] タスクの編集 -
/tasks/:id
[DELETE] タスクの削除
エンドポイント定義のファイルを作成します。
すでに、index.tsファイルは生成されてるので上書きします。
echo -n > index.ts
先ほど作成した、Honoインスタンスとdbインスタンスを利用してエンドポイントを実装していきます。
タスクの取得
// index.ts
import { eq } from "drizzle-orm";
import { db, tasks } from "./db.js";
import { app } from "./middleware.js";
app.get("/tasks", async (c) => {
const allTasks = db.select().from(tasks).all();
return c.json(allTasks);
});
タスクの登録
// index.ts
app.post("/tasks", async (c) => {
const { title } = await c.req.json<{ title?: string }>();
if (!title || title.trim() === "") {
return c.json({ error: "Invalid input" }, 400);
}
const result = db.insert(tasks).values({ title }).run();
const newTask = db
.select()
.from(tasks)
.where(eq(tasks.id, result.lastInsertRowid as number))
.get();
return c.json(newTask, 201);
});
タスクの編集
// index.ts
app.put("/tasks/:id", async (c) => {
const { id } = c.req.param();
const taskId = Number(id);
if (Number.isNaN(taskId)) {
return c.json({ error: "Invalid ID" }, 400);
}
const { title } = await c.req.json<{ title?: string }>();
if (!title || title.trim() === "") {
return c.json({ error: "Invalid input" }, 400);
}
const result = db
.update(tasks)
.set({ title })
.where(eq(tasks.id, taskId))
.run();
if (result.changes === 0) {
return c.json({ error: "Task not found" }, 404);
}
const updatedTask = db.select().from(tasks).where(eq(tasks.id, taskId)).get();
return c.json(updatedTask);
});
タスクの削除
// index.ts
app.delete("/tasks/:id", async (c) => {
const { id } = c.req.param();
const taskId = Number(id);
if (Number.isNaN(taskId)) {
return c.json({ error: "Invalid ID" }, 400);
}
const result = db.delete(tasks).where(eq(tasks.id, taskId)).run();
if (result.changes === 0) {
return c.json({ error: "Task not found" }, 404);
}
return c.json(204);
});
以上で、API側は完了です。
フロント側の作成
Vite + Reactでプロジェクトを作成します。
cd ../../
pnpm create vite@latest
viteの対話式プロンプトでは以下のように設定しました。
Project name: front
Select a framework: React
Select a variant: TypeScript
不要なファイルを削除します。
cd front
rm -rf ./src/ ./public/
モジュールを取得し、起点となるmain.tsx
ファイルを作成します。
pnpm i
mkdir src/
cd src/
touch main.tsx
createRoot
関数を利用して、ルート要素にReactコンポーネントを描画します。
// main.tsx
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(<App />);
次に、必要なモジュールを追加し、App.tsxファイルを作成します。
pnpm i axios swr
touch App.tsx
App.tsx では、以下を定義します。
- エンドポイントの定数 (API_URL)
- タスク型 (Task)
- useSWR 用の fetcher
- 新規作成・編集用の状態 (newTask, editingTask)
- useSWR で取得するタスクデータ (tasks)
- 作成・編集・削除の各イベントハンドラ (addTask, editTask, deleteTask)
- エラーとロード中表示の UI
- タスク一覧と新規タスク追加の UI
import { useState } from "react";
import axios from "axios";
import useSWR, { mutate } from "swr";
const API_URL = "http://localhost:3000";
type Task = {
id: number;
title: string;
};
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
const App = () => {
const [newTask, setNewTask] = useState("");
const [editingTask, setEditingTask] = useState<{
id: number;
title: string;
} | null>(null);
const { data: tasks, error } = useSWR<Task[]>(`${API_URL}/tasks`, fetcher);
const addTask = async (title: string) => {
if (!title.trim()) return;
await axios.post(`${API_URL}/tasks`, { title });
mutate(`${API_URL}/tasks`);
setNewTask("");
};
const editTask = async (id: number, title: string) => {
if (!title.trim()) return;
await axios.put(`${API_URL}/tasks/${id}`, { title });
mutate(`${API_URL}/tasks`);
setEditingTask(null);
};
const deleteTask = async (id: number) => {
await axios.delete(`${API_URL}/tasks/${id}`);
mutate(`${API_URL}/tasks`);
};
if (error)
return (
<div style={{ textAlign: "center", padding: "20px", color: "#d32f2f" }}>
Error loading tasks
</div>
);
if (!tasks)
return (
<div style={{ textAlign: "center", padding: "20px" }}>Loading...</div>
);
return (
<div
style={{
maxWidth: "500px",
margin: "40px auto",
padding: "20px",
boxShadow: "0 4px 10px rgba(0,0,0,0.1)",
borderRadius: "10px",
backgroundColor: "#f9fafb",
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
}}
>
<h1
style={{
fontSize: "28px",
fontWeight: "600",
marginBottom: "30px",
textAlign: "center",
color: "#333",
}}
>
Task Manager
</h1>
<ul
style={{
listStyle: "none",
padding: 0,
margin: "0 0 20px 0",
}}
>
{tasks.map((task) => (
<li
key={task.id}
style={{
display: "flex",
alignItems: "center",
borderBottom: "1px solid #e0e0e0",
padding: "12px 0",
fontSize: "16px",
}}
>
{editingTask?.id === task.id ? (
<>
<input
type="text"
value={editingTask.title}
onChange={(e) =>
setEditingTask({ ...editingTask, title: e.target.value })
}
style={{
flex: 1,
padding: "8px",
fontSize: "16px",
border: "1px solid #ccc",
borderRadius: "4px",
marginRight: "10px",
}}
/>
<button
onClick={() => editTask(task.id, editingTask.title)}
style={{
padding: "8px 14px",
backgroundColor: "#4caf50",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
marginRight: "8px",
transition: "background-color 0.2s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "#43a047")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#4caf50")
}
>
Save
</button>
<button
onClick={() => setEditingTask(null)}
style={{
padding: "8px 14px",
backgroundColor: "#757575",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
transition: "background-color 0.2s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "#616161")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#757575")
}
>
Cancel
</button>
</>
) : (
<>
<span style={{ flex: 1, marginRight: "10px", color: "#333" }}>
{task.title}
</span>
<button
onClick={() =>
setEditingTask({ id: task.id, title: task.title })
}
style={{
padding: "8px 14px",
backgroundColor: "#2196f3",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
marginRight: "8px",
transition: "background-color 0.2s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "#1e88e5")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#2196f3")
}
>
Edit
</button>
<button
onClick={() => deleteTask(task.id)}
style={{
padding: "8px 14px",
backgroundColor: "#f44336",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
transition: "background-color 0.2s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "#e53935")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#f44336")
}
>
Delete
</button>
</>
)}
</li>
))}
</ul>
<div
style={{
display: "flex",
gap: "10px",
}}
>
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
style={{
flex: 1,
padding: "10px",
fontSize: "16px",
border: "1px solid #ccc",
borderRadius: "4px",
boxSizing: "border-box",
}}
/>
<button
onClick={() => addTask(newTask)}
style={{
padding: "10px 16px",
backgroundColor: "#4caf50",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
transition: "background-color 0.2s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "#43a047")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#4caf50")
}
>
Add
</button>
</div>
</div>
);
};
export default App;
ここまででアプリケーションのフロントエンドが完成しました。
あとはアプリケーションを以下のコマンドで実行できます。
cd ../../ && cd api/ && pnpm run dev && cd ../
cd front/ && pnpm run dev
最後に
HonoでAPIを作成してみました。
Honoはルーティングやミドルウェアの設定がわかりやすく、必要最低限の構成でAPIを作りやすいと感じました。
また、Node.jsだけでなくCloudflare Workersなどさまざまなランタイム環境でも動作するため、ユースケースに合わせて柔軟に使えそうです。
今回は小規模なアプリケーションでしたが、他のプロジェクトでも積極的に活用してみようと思います。