3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

プロジェクトの土台が出来ました。
image.png

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

これでタスク管理アプリケーションの完成です!
image.png

最後に

HonoでAPIを作成してみました。
Honoはルーティングやミドルウェアの設定がわかりやすく、必要最低限の構成でAPIを作りやすいと感じました。
また、Node.jsだけでなくCloudflare Workersなどさまざまなランタイム環境でも動作するため、ユースケースに合わせて柔軟に使えそうです。
今回は小規模なアプリケーションでしたが、他のプロジェクトでも積極的に活用してみようと思います。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?