Raw JavaScript と HTMX で同じ機能を作って比較してみた

# はじめに
Raw JavaScript を書くのはツラいけど、Reactなどを必要とするほどのリッチなUIを作りたいわけでないため、モノリシックなアーキテクチャで楽にJSを使いたい。そのような場合の選択肢として、HTMXというものがあることを最近知りました。

少し前ですが、DjangoCon Europe 2022 でそのような事例紹介の発表がありました。

https://www.youtube.com/watch?v=3GObi93tjZI

実際に、DjangoでHTMXを使ってみたいと思います。

# 1. ボタンを押したら、メッセージを表示する
まずは、次の画像のように、ボタンがクリックされたら、Ajaxを利用してメッセージを取得し、画面に表示される機能を作ってみます。

![スクリーンショット 2024-01-29 19.06.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/8a4b7c68-5c55-983e-0efc-49fdf3f8d27e.png)

![スクリーンショット 2024-01-29 19.07.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/a22eb5db-49da-7b5c-cc25-813886e9a352.png)

## Raw JavaScript の場合
比較対象として、Raw JavaScript の場合から作ります。

```views.py
from django.http import JsonResponse
from django.views import View
from django.shortcuts import render

class JavaScriptView(View):
    def get(self, request, *args, **kwargs):
        questions = Question.objects.all()
        context = {"questions": questions}
        return render(request, "sample/javascript.html", context)

class JsGetMessages(View):
    def get(self, request, *args, **kwargs):
        message = "Hello, World!"
        return JsonResponse({'message': message})
```

```javascript.html
<html lang="ja">
    <head>
        <title>JS-Sample</title>
        <script src="https://cdn.tailwindcss.com"></script>
    </head>

    <body class="p-6">
        <h1 class="text-3xl font-bold">
            JavaScript Sample
        </h1>

        <!-- Message -->
        <div class="mt-6 flex">
            <button type="button" id="messageButton"
                class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
            >
                Click me
            </button>
            <div class="ml-6 text-xl" id="messageText">
                Let's display a Message by clicking a button!
            </div>
        </div>

    </body>

    <script>
        // Buttonが押されたら、Messageを表示
        document.getElementById('messageButton').addEventListener('click', function() {
            // サーバーからメッセージを取得
            fetch('/question/js/get-messages/')
                .then(response => response.json())
                .then(data => {
                    const messageText = document.getElementById('messageText');
                    messageText.innerText = data.message
                });
        });
    </script>
</html>
```

![スクリーンショット 2024-01-29 19.12.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/2f384fee-d834-098c-4011-83a791a28bc6.png)

## HTMX の場合
続いて、HTMXを用いて同様の機能を実装してみます。

```views.py
from django.http import JsonResponse
from django.views import View
from django.shortcuts import render

class HtmxView(View):
    def get(self, request, *args, **kwargs):
        questions = Question.objects.all()
        context = {"questions": questions}
        return render(request, "sample/htmx.html", context)

class HtmxGetMessages(View):
    def get(self, request, *args, **kwargs):
        message = "Hello, World!"
        return render(request, 'sample/parts/message.html', {'message': message})
```

Raw JS のように JSON形式で返すと、JSONの内容がそのまま表示されてしまうので、置き換えたい要素をpartsとして作成します。

```parts/message.html
<div class="ml-6 text-xl" id="messageText">
    {{ message }}
</div>
```

```htmx.html
<html lang="ja">
    <head>
        <title>HTMX-Sample</title>
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://unpkg.com/htmx.org"></script>
    </head>

    <body class="p-6">
        <h1 class="text-3xl font-bold">
            HTMX Sample
        </h1>

        <!-- Message -->
        <div class="mt-6 flex">
            <button type="button" id="messageButton" hx-get="/question/htmx/get-messages/" hx-trigger="click" hx-target="#messageText" hx-swap="outerHTML"
                class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
            >
                Click me
            </button>
            <div class="ml-6 text-xl" id="messageText">
                Let's display a Message by clicking a button!
            </div>
        </div>
    </body>
</html>
```
![スクリーンショット 2024-01-29 19.17.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/999eed55-a04a-5f23-3d03-109b2191e7e2.png)

HTMXを用いて、JavaScript を全く書かずに同様の機能を実装できました。htmlのタグに「hx」から始まる記述をすることで、 JavaScriptの記述と同様の処理を実現できるようです。

# 2. 選択された質問に合わせて、回答の選択肢を更新する
次はもう少し複雑な機能を実装してみましょう。選択された質問に合わせて、回答の選択肢を更新する機能を作っていきます。

## Raw JavaScript の場合
```models.py
from django.db import models

class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField("date published")

class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    is_correct = models.BooleanField(default=False)
```

```views.py
from .models import Question, Choice

class JsGetChoices(View):
    def get(self, request, *args, **kwargs):
        question_id = request.GET.get('question_id')
        choices = Choice.objects.filter(question_id=question_id)
        return JsonResponse({'choices': [{'id': choice.id, 'choice_text': choice.choice_text} for choice in choices]})
```

```javascript.html
<html lang="ja">
    <head>
        <title>JS-Sample</title>
        <script src="https://cdn.tailwindcss.com"></script>
    </head>

    <body class="p-6">
        <h1 class="text-3xl font-bold">
            JavaScript Sample
        </h1>

        <!-- 問題のセレクトボックス -->
        <div class="mt-6">
            <label for="questions" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">問題を選択してください。</label>
            <select id="questionSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
                {% for question in questions %}
                    <option value="{{ question.id }}">{{ question.question_text }}</option>
                {% endfor %}
            </select>
        </div>

        <!-- 回答のセレクトボックス -->
        <div class="mt-6">
            <label for="choices" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">回答を選択してください。</label>
            <select id="choiceSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
            </select>
        </div>
    </body>

    <script>
        // 質問のセレクトボックスでの選択変更を監視
        document.getElementById('questionSelect').addEventListener('change', function() {
            const questionId = this.value;

            // サーバーから選択肢データを取得
            fetch(`/question/js/get-choices/?question_id=${questionId}`)
                .then(response => response.json())
                .then(data => {
                    const choiceSelect = document.getElementById('choiceSelect');

                    // 既存の選択肢をすべて削除
                    while (choiceSelect.firstChild) {
                        choiceSelect.removeChild(choiceSelect.firstChild);
                    }

                    // 取得した選択肢データをセレクトボックスに追加
                    data.choices.forEach(function(choice) {
                        const option = new Option(choice.choice_text, choice.id);
                        choiceSelect.add(option);
                    });
                });
        });
    </script>
</html>
```
![スクリーンショット 2024-01-29 19.25.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/e5f8066d-3c76-9dcc-3de1-924f179f9802.png)

![スクリーンショット 2024-01-29 19.25.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/be8e9fd1-0ae3-29d2-ae8d-595e05bd3484.png)

このように、問題を選択すると、質問の選択肢が更新されます。


## HTMX の場合
```vews.py
from .models import Question, Choice

class HtmxGetChoices(View):
    def get(self, request, *args, **kwargs):
        question_id = request.GET.get('question_id')
        choices = Choice.objects.filter(question_id=question_id)
        return render(request, 'sample/parts/choice_list.html', {'choices': choices})
```

``` parts/choice_list.html
<select id="choiceSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
    {% for choice in choices %}
        <option value="{{ choice.id }}">{{ choice.choice_text }}</option>
    {% endfor %}
</select>
```

```　htmx.html
<html lang="ja">
    <head>
        <title>HTMX-Sample</title>
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://unpkg.com/htmx.org"></script>
    </head>

    <body class="p-6">
        <h1 class="text-3xl font-bold">
            HTMX Sample
        </h1>

        <!-- 問題のセレクトボックス -->
        <div class="mt-6">
            <label for="questions" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">問題を選択してください。</label>
            <!-- HTMXを使用して change event を処理する -->
            <select id="questionSelect" hx-get="/question/htmx/get-choices/" hx-vals='js:{"question_id": calculateValue()}' hx-trigger="change" hx-target="#choiceSelect" hx-swap="outerHTML"
                class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
            >
                {% for question in questions %}
                    <option name="question" value="{{ question.id }}">{{ question.question_text }}</option>
                {% endfor %}
            </select>
        </div>

        <!-- 回答のセレクトボックス -->
        <div class="mt-6">
            <label for="choices" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">回答を選択してください。</label>
            <!-- HTMXを使用して選択肢を更新する -->
            <select id="choiceSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
            </select>
        </div>
    </body>

    <script>
        function calculateValue() {
            return document.getElementById('questionSelect').value;
        }
    </script>
</html>
```
![スクリーンショット 2024-01-29 19.28.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/13b2111c-a25d-18d3-66d3-a296fa44d103.png)

![スクリーンショット 2024-01-29 19.28.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2065133/67f832c1-95ac-981a-7105-586b5338dc6e.png)

選択された option の value を親要素である select に渡すのに苦労しました。結局、JSで選択された option の value を返す関数を作って、select に渡すようにしましたが、よりスマートな方法があれば教えていただきたいです。

それでも、HTMXを使用することで確かにJSの記述量は（コード全体の記述量も）格段に減りました。大規模あるいは複雑なWebアプリケーションの場合はReactなどを導入してフロントエンドを分離させるべきだと思いますが、小規模かつ単純なWebアプリケーションにおいてHTMXは有用な選択肢かもしれません。今後もう少し本格的に使用してみたいと思います。


# 参考資料
公式ドキュメント

https://htmx.org/