htmx & Hono & Bun & Pico CSSで爆速TODOアプリを作ってみた

# はじめに

htmx以外は全て初めて触る技術だったので、入門ということでTODOアプリを作ってみました。

# Demo

![todo (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/206b5a84-66e5-4401-f84e-6ebb501872eb.gif)

# 使用した技術スタック

## htmx

https://htmx.org/

htmxに関しては、以前書いた記事に詳しく書いております。

[もうjsなんていらない！世界で流行っているHTMXについてまとめてみた #JavaScript - Qiita](https://qiita.com/twrcd1227/items/7bce18167fb02ec22729)

## Hono

https://hono.dev/

あらゆるランタイム上で動作する軽くて速いWebフレームワーク。

## Bun

https://bun.sh/

Zigで書かれた高速なJavaScriptランタイム。

## Pico CSS

https://picocss.com/

最小限のものだけが入ったシンプルなCSSフレームワーク

# 動作方法

以下からクローンしてください。([成果物のリポジトリ](#成果物))

```sh
git clone https://github.com/tomo1227/todo-bun-hono-htmx-pico.git
```

Dev-Containerが使える方はコンテナを立ち上げて、`bun run dev`すれ

bunをインストールしていない人は https://bun.sh/docs/installation からインストールして、以下のコマンドを叩いてください。

```shell-session
bun i
bun run dev
````

すると,`http://localhost:3000/`にアクセスできるようになります。

# 実装の紹介

今回は、サンプルアプリなので、Pico CSSやhtmxはCDNで導入しています。本番環境などでは、非推奨なので気をつけてください。

`index.tsx`は以下のように、割とシンプルに書くことがで来ました。
パッとみてFastAPIと似てる気がしました。

```jsx:index.tsx
import { Hono } from "hono";
import "typed-htmx";
import TodoApp from "./components/TodoApp";
import Task from "./components/Task";
import Todo from "./components/Todo";

const app = new Hono();

let todos: Todo[] = [];

app.get("/", (c) => {
  return c.html(<TodoApp />);
});

app.post("/todo", async (c) => {
  const formData = await c.req.parseBody();
  const newId =
    todos.length > 0 ? Math.max(...todos.map((todo) => todo.id)) + 1 : 1;
  const newTodo = {
    id: newId,
    text: formData.todo as string,
  };
  todos.push(newTodo);

  return c.html(<Task todos={todos} />);
});

app.delete("/delete/:id", (c) => {
  const id: number = parseInt(c.req.param().id, 10);
  todos = todos.filter((todo) => todo.id !== id);
  return c.html(<Task todos={todos} />);
});

export default app;
```

htmxはformのところで多く使用した。できるだけjavascriptを書かずに、HTMLの部分だけで行うことを目指しました。

```jsx:components/TaskTodo.jsx
  <main class="container">
    <h1 class="pico-color-pink-500">Welcome!</h1>
    <h1>Hono & Bun & htmx & Pico CSS Todo App</h1>
    <form
      hx-post="/todo"
      hx-target="#todos"
      {...{ "hx-on::after-request": "this.reset()" }}
    >
      <input
        id="todo"
        name="todo"
        type="text"
        required="true"
        placeholder="ここにタスクを入力"
      />
      <button type="submit">ADD TASK</button>
    </form>
    <section id="todos" hx-get="/todo" hx-trigger="load"></section>
  </main>
```

また、タスクを追加すると、以下のようなコンポーネントが作成される。
deleteボタンを押すと、hx-deleteが発火する。対象をsectionで、swap方法を指定すると、削除が簡単にようになった。

```jsx:components/Task.tsx
import Todo from "./Todo";

interface TaskProps {
  todos: Todo[];
}

const Task = ({ todos }: TaskProps) => {
  return (
    <article class="container">
      {todos.map((todo) => (
        <section class="grid">
          <div id={todo.id.toString()} class="card">
            <strong>{todo.text}</strong>
          </div>
          <div></div>
          <button
            type="reset"
            class="pico-background-red-450"
            hx-delete={`/delete/${todo.id}`}
            hx-target="closest article"
            hx-swap="outerHTML"
          >
            DELETE
          </button>
        </section>
      ))}
    </article>
  );
};
export default Task;
```

# ハマった箇所

## htmxのhx-onでシンタックスエラー

https://github.com/bigskysoftware/htmx/issues/1710

フォーム送信後に入力をリセットするのだが、これをhtmxで書くと以下のようになる。

```html
<form hx-on::after-request="this.reset()" />
```

しかし、HonoのJSXでは、上記の書き方はシンタックスエラーとなる。
ちなみに、コロンが1つだとエラーにならないが、動作しない。

```jsx
<form hx-on="htmx:after-request: this.reset()" />
```

最終的に以下のようにすると動作することがわかった。

```jsx
<form {...{ 'hx-on::after-request': 'this.reset()' }} />
```

# 成果物

今回作ったTodoアプリのリポジトリです。

https://github.com/tomo1227/todo-bun-hono-htmx-pico


# 参考文献

1. https://github.com/Desdaemon/typed-htmx
1. https://hono.dev/docs/getting-started/basic
1. https://picocss.com/docs
1. https://github.com/Pratap22/bun-todo-htmx
1. [Hono + htmx + Cloudflareは新しいスタック](https://zenn.dev/yusukebe/articles/e8ff26c8507799#php%E3%81%AE%E8%A9%B1%E3%82%92%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%EF%BC%9F)