Rust(axum, sqlx)とReactでWebアプリ開発 - 備忘録3 Dioxus編

## はじめに
[前々回の記事](https://qiita.com/KMMT_h_m/private/1d7a9dccafa973812ed5)
[前回の記事](https://qiita.com/KMMT_h_m/private/fe7817ed5409c951699e)

こちらの続き。
今回はDioxusでの実装の備忘録。

:::note warn
本記事は筆者の備忘録的な側面が多いため、読みにくい部分があります。
また、必ずしも正しい情報ではなかったり、実装における最適解ではない可能性があります。
:::
※もしより正確な情報だったり詳細な内容をご存じの方はコメントで教えていただけると幸いです。

## Dioxusについて

まずDioxusとは何か、から軽く紹介する。
公式サイトは以下なので、詳しい説明はそちらで。

https://dioxuslabs.com/

紹介すると書いたが、他の方がすでに日本語の解説記事を書かれているのでそちらを参照されることをおすすめする。

https://qiita.com/taisuke101700/items/4d262e46c2165e01f477

かなり雑に要約すると、Rustでフロントエンドアプリケーションを書くためのクレートで、Reactっぽい書き方ができるのが特徴。

### 参考記事

こちらの記事がDioxusとAxumで実装されていており、私が作ったアプリケーションと同じ構成であったので参考になった。

https://blog.romira.dev/articles/65a38cfdcc42e4d4373f7ef0

Todo,Labelの表示など外部APIにリクエストする際には非同期で実装する必要があり、以下の記事が参考になった。

https://naokirin.hatenablog.com/entry/2022/12/31/034838

テンプレート作成に関してはこちらの記事も参考にした。

https://synamon.hatenablog.com/entry/entry_rust_web_frontend

## コードサンプル
### Cargo.toml
<details><summary>Cargo.toml</summary>

```rust
[package]
name = "front-rust"
version = "0.1.0"
authors = ["user email"]
edition = "2021"

[dependencies]
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0"
dioxus = { version = "0.5", features = ["fullstack", "router"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"

# Debug
dioxus-logger = "0.5.1"
reqwest = { version = "0.12.7", features = ["json"] }

[features]
default = []
server = ["dioxus/axum"]
web = ["dioxus/web"]
```
</details>

### main.rs

モジュール化はしていないのでmain.rsのみ

<details><summary>main.rs</summary>

```rust
#![allow(non_snake_case)]

use dioxus::prelude::*;
use dioxus_logger::tracing;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;

#[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
enum Route {
    #[route("/")]
    Home {},
}

fn main() {
    // Init logger
    dioxus_logger::init(tracing::Level::INFO).expect("failed to init logger");
    tracing::info!("starting app");
    launch(App);
}

fn App() -> Element {
    rsx! {
        Router::<Route> {}
    }
}

#[component]
fn Home() -> Element {
    let todo_data = use_server_future(get_todo_data)?.value().unwrap(); // SSR
    let label_data = use_server_future(get_label_data)?.value().unwrap(); // SSR
    tracing::info!("label_data: {:?}", type_of(&label_data));

    let mut selected_labels = use_signal(Vec::<i32>::new);

    rsx! {
        h2 { "Label Set" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let input_name = event.values().get("name").unwrap().as_value();
                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = post_label_data(input_name.clone()).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "name" }
            input { r#type: "submit", value: "ADD LABEL" }
        }
        // h2 { "Label Delete" }
        // p { "↓input label id" }
        // form { onsubmit: move |event| {
        //         tracing::info!("Submitted! {event:?}");
        //         let delete_id = event.values().get("id").unwrap().as_value();
        //         wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
        //             if let Err(err) = delete_data("labels".to_string(), delete_id.parse().unwrap()).await {
        //                 tracing::error!("Failed to post data: {:?}", err);
        //             }
        //         });
        //     },
        //     input { name: "id" }
        //     input { r#type: "submit", value: "DELETE LABEL" }
        // }
        h2 { "Todo Set" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let input_text = event.values().get("text").unwrap().as_value();

                // Labelの初期選択状態を反映する
                // 画面上のインデックスをVec型に変換
                let selected_ids: Vec<i32> = selected_labels.read().clone();
                // DBから取得したデータのインデックスと画面上のインデックスを対応させる
                let selected_labels_data: Vec<Label> = selected_ids.iter()
                    .filter_map(|&index| label_data.as_ref().ok()?.get(index as usize).cloned())
                    .collect();
                tracing::info!("selected_labels_data: {:?}", selected_labels_data);

                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = post_todo_data(input_text.clone(), selected_labels_data).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "text" }
            // Generate checkboxes for labels
            if let Ok(ref data) = label_data {
                for (i, label) in data.iter().enumerate() {
                    div {
                        input {
                            r#type: "checkbox",
                            value: "{label.id}",
                            onchange: move |event| {
                                let bool = bool::from_str(&event.value()).unwrap();
                                if bool {
                                    // trueの場合、selected_labelsにlabel.idを追加
                                    selected_labels.write().push(i as i32); // 本当は画面表示上のインデックスではなくてlabel.idを入れたい
                                } else {
                                    // falseの場合、selected_labelsからlabel.idを削除
                                    selected_labels.write().retain(|&id| id != i as i32); // 本当は画面表示上のインデックスではなくてlabel.idを入れたい
                                }
                                tracing::info!("checked: {:?}", event.value());
                                tracing::info!("selected_labels: {:?}", selected_labels);
                            }
                        }
                        label { "{label.name}" }  // Display label name
                    }
                }
            }
            input { r#type: "submit", value: "ADD TODO" }
        }
        h2 { "Todo Delete" }
        p { "↓input todo id" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let delete_id = event.values().get("id").unwrap().as_value();
                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = delete_data("todos".to_string(), delete_id.parse().unwrap()).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "id" }
            input { r#type: "submit", value: "DELETE TODO" }
        }
        div {
            div {
                h2 { "Label" }
                if let Ok(ref data) = label_data {
                    for label in data.iter() {
                        div { "===============================" }
                        div {
                            p { "Label ID: {label.id}" }
                            p { "Label Name: {label.name}" }
                        }
                    }
                    div { "===============================" }
                } else {
                    div { "Labels Data get error" }
                }
            }
            div {
                h2 { "Todo" }
                if let Ok(ref data) = todo_data {
                    for todo in data.iter() {
                        div { "===============================" }
                        div {
                            p { "Todo ID: {todo.id}" }
                            p { "Todo Text: {todo.text}" }
                            p { "Todo Completed: {todo.completed}" }
                            for todo_label in todo.labels.iter() {
                                p { "Todo Labels ID: {todo_label.id}" }
                                p { "Todo Labels Name: {todo_label.name}" }
                            }
                        }
                    }
                    div { "===============================" }
                } else {
                    div { "Todo Data get error" }
                }
            }
        }
    }
}

// --------------
// struct
// --------------
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Label {
    pub id: i32,
    pub name: String,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TodoEntity {
    pub id: i32,
    pub text: String,
    pub completed: bool,
    pub labels: Vec<Label>,
}

// --------------
// todo function
// --------------
#[server(GetTodoData)]
async fn get_todo_data() -> Result<Vec<TodoEntity>, ServerFnError> {
    let todo = reqwest::get("リクエストURL/todos")
        .await
        .unwrap()
        .json::<Vec<TodoEntity>>()
        .await?;
    tracing::info!("todo: {:?}", todo);

    Ok(todo)
}

async fn post_todo_data(text: String, labels: Vec<Label>) -> Result<(), ServerFnError> {
    tracing::info!("post: {:?}, labels: {:?}", text, labels);

    let body = json!({
        "text": text,
        "labels": labels.iter().map(|label| {
            json!(label.id)
        }).collect::<Vec<_>>(), // 各ラベルをJSON形式に変換してPOST
    });

    let client = reqwest::Client::new();
    let res = client
        .post("リクエストURL/todos")
        .json(&body)
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}

async fn delete_data(kind: String, id: i32) -> Result<(), ServerFnError> {
    tracing::info!("delete_todo_data: {:?}", id);

    let client = reqwest::Client::new();
    let res = client
        .delete(format!("リクエストURL/{}/{}", kind, id))
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}

// --------------
// label function
// --------------
#[server(GetLabelData)]
async fn get_label_data() -> Result<Vec<Label>, ServerFnError> {
    let label = reqwest::get("リクエストURL/labels")
        .await
        .unwrap()
        .json::<Vec<Label>>()
        .await?;
    tracing::info!("label: {:?}", label);

    Ok(label)
}

async fn post_label_data(name: String) -> Result<(), ServerFnError> {
    tracing::info!("post: {:?}", name);

    let body = json!({
        "name": name,
    });

    let client = reqwest::Client::new();
    let res = client
        .post("リクエストURL/labels")
        .json(&body)
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}
```
</details>

## 実装メモ

[前回の記事](https://qiita.com/KMMT_h_m/private/fe7817ed5409c951699e)でReactで実装した画面をもとに、Dioxusで再構築していく。

が、最終的にこの記事執筆時点ではおよそ4割ぐらいの機能を実装したところでまだ途中。
Edit機能やLabelの削除機能、Todo追加時の画面更新処理、デザイン面などは今後実装して更新していくかもしれない。

一旦現時点までのアプリケーションのデモ画面を掲載する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487953/303d523d-9c42-9429-13b7-949b9620bb37.png)

...見ての通り、cssは一切入れていないし、未デプロイ。
そもそもDioxusで作ったアプリケーションのデプロイ方法がわからない。WebAssemblyのデプロイ記事を読んでみたがまだ実践には至っていない。

しかし、一応Todo、Labelの表示、登録、Todoの削除はできるところまでは頑張って実装した。

### get処理

```rust
// 一部抜粋

#[component]
fn Home() -> Element {
    let todo_data = use_server_future(get_todo_data)?.value().unwrap(); // SSR

    rsx! {
        h2 { "Todo" }
        if let Ok(ref data) = todo_data {
            for todo in data.iter() {
                div {
                    p { "Todo ID: {todo.id}" }
                    p { "Todo Text: {todo.text}" }
                    p { "Todo Completed: {todo.completed}" }
                    for todo_label in todo.labels.iter() {
                        p { "Todo Labels ID: {todo_label.id}" }
                        p { "Todo Labels Name: {todo_label.name}" }
                    }
                }
            }
        } else {
            div { "Todo Data get error" }
        }
    }
}

// Label、TodoEntityはバックエンド側の実装をそのまま持ってきた
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Label {
    pub id: i32,
    pub name: String,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TodoEntity {
    pub id: i32,
    pub text: String,
    pub completed: bool,
    pub labels: Vec<Label>,
}

// Todo get関数
#[server(GetTodoData)]
async fn get_todo_data() -> Result<Vec<TodoEntity>, ServerFnError> {
    let todo = reqwest::get("リクエストURL")
        .await
        .unwrap()
        .json::<Vec<TodoEntity>>()
        .await?;
    tracing::info!("todo: {:?}", todo);

    Ok(todo)
}
```

`Vec`型で`TodoEntity`の配列を受け取り、for分の中でイテレータのループで一つずつ取り出している。

### post処理

postの関数は以下のような実装となった。

```rust
// Todo post関数
async fn post_todo_data(text: String, labels: Vec<Label>) -> Result<(), ServerFnError> {
    tracing::info!("post: {:?}, labels: {:?}", text, labels);

    let body = json!({
        "text": text,
        "labels": labels.iter().map(|label| {
            json!(label.id)
        }).collect::<Vec<_>>(), // 各ラベルをJSON形式に変換してPOST
    });

    let client = reqwest::Client::new();
    let res = client
        .post("リクエストURL")
        .json(&body)
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}
```

getとちょっと違い、引数に入力した文字列と選択したLabelのVec型を渡す実装になる。
以下の記事が参考になった。

https://qiita.com/gosshys/items/43ed92f7aaf4516d11eb

### post処理のコンポーネント

次はUIを実装するのだが、登録済みのLabelを紐づけるコンポーネント部分で苦労した。
React側の画面では、`SELECT LABELS`を押すとモーダルが開き、登録済みのLabel一覧が表示され、チェックを入れるとTodo投稿時に最初からLabelが紐づく仕組み。

**React画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487953/b57fffe6-573a-1ced-2f55-0c26ffe8c9bb.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487953/d0eb37ae-7f55-eb24-861f-243ddb1adb6a.png)

これをDioxus側で再現したいのだが、いったんモーダル実装は置いておき、単純に最初からチェックボックスを置く実装とした。

見た目はこんな感じ。

**Dioxus画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487953/a091ad6f-b0b8-4bcc-7a62-6faced564935.png)

ここの
**チェックボックスを選択したLabelの内部IDをTodo投稿時のリクエスト先に渡す**
という処理に苦労した。

最終的にこんな感じのコンポーネントになった。

```rust
#[component]
fn Home() -> Element {
    let label_data = use_server_future(get_label_data)?.value().unwrap(); // SSR

    // 選択したLabelの画面表示上のインデックスを格納する変数（ReactのuseStateのようなもの）
    let mut selected_labels = use_signal(Vec::<i32>::new);

    rsx! {
        h2 { "Todo Set" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let input_text = event.values().get("text").unwrap().as_value();

                // Labelの初期選択状態を反映する
                // 画面上のインデックスをVec型に変換
                let selected_ids: Vec<i32> = selected_labels.read().clone();
                // DBから取得したデータのインデックスと画面上のインデックスを対応させる
                let selected_labels_data: Vec<Label> = selected_ids.iter()
                    .filter_map(|&index| label_data.as_ref().ok()?.get(index as usize).cloned())
                    .collect();
                tracing::info!("selected_labels_data: {:?}", selected_labels_data);

                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = post_todo_data(input_text.clone(), selected_labels_data).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "text" }
            // Generate checkboxes for labels
            if let Ok(ref data) = label_data {
                for (i, label) in data.iter().enumerate() {
                    div {
                        input {
                            r#type: "checkbox",
                            value: "{label.id}",
                            // 画面表示上のLabelのインデックスを格納
                            onchange: move |event| {
                                let bool = bool::from_str(&event.value()).unwrap();
                                if bool {
                                    // trueの場合、selected_labelsにlabel.idを追加
                                    selected_labels.write().push(i as i32); // 本当はlabel.idを入れたい
                                } else {
                                    // falseの場合、selected_labelsからlabel.idを削除
                                    selected_labels.write().retain(|&id| id != i as i32); // 本当はlabel.idを入れたい
                                }
                                tracing::info!("checked: {:?}", event.value());
                                tracing::info!("selected_labels: {:?}", selected_labels);
                            }
                        }
                        label { "{label.name}" }  // Display label name
                    }
                }
            }
            input { r#type: "submit", value: "ADD TODO" }
        }
    }
}
```

チェックボックスを作る部分で、上記のように実装しているとどうしても`event.value()`が`true`か`false`のどちらかの値しか取れない。
理想は`label.id`を`selected_labels`に入れたいのだが、どうやら所有権周りでとれなさそう。
おそらくクロージャを使っている関係だと思うが、勉強不足で解決できなかった。

代替案として、チェックボックスを押下した際は**画面表示上のインデックス**を`selected_labels`に格納し、postする前処理で画面表示上のインデックスと実際のLabal IDと紐づけてpost関数に渡すようにした。

### delete処理

これもReact側のように、Todoの横にDELETEボタンを置き、対応するTodoを削除するようにしたかったのだが、postと同じく`event.value()`が`true`か`false`のどちらかの値しか取れないことに苦戦した結果、
削除したいTodo IDを直接打ち込む実装に変更した。

**React画面**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487953/01585e96-70a4-4f4a-b415-341b81ec01e8.png)

**Dioxus画面**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487953/3431975b-24d4-c68f-1816-a49c1c24e5f4.png)

```rust
#[component]
fn Home() -> Element {
    rsx! {
        h2 { "Todo Delete" }
        p { "↓input todo id" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let delete_id = event.values().get("id").unwrap().as_value();
                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = delete_data("todos".to_string(), delete_id.parse().unwrap()).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "id" }
            input { r#type: "submit", value: "DELETE TODO" }
        }
    }
}

// delete関数
async fn delete_data(kind: String, id: i32) -> Result<(), ServerFnError> {
    tracing::info!("delete_todo_data: {:?}", id);

    let client = reqwest::Client::new();
    let res = client
        .delete(format!("リクエストURL/{}/{}", kind, id))
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}
```

ここでひとつ、kindという引数にStringを渡していることにお気づきだろうか。
そう、TodoとLabelの削除処理はリクエスト先が違うだけなので、共通化しようとした形跡である。

しかし、Labelのほうは`label_data`を更新しないと削除したラベルがTodo投稿時に選択できるままになってしまう。ここは未実装のため、今後挑戦するかもしれない。

### Labelのget,post処理
Todoとほとんど同じなので省略。

## まとめ

ということで、かなり中途半端だがRustでDioxusを用いてフロントエンドを実装してみた。
まだまだ勉強不足、研究不足なところはあるが、バックエンド、フロントエンドの両方をすべてRustで書くことは可能であることが分かった。

しかし、他の方も書かれているようにRust × Web フロントエンドはまだまだ未成熟な分野であり、現状だとReactやNext.jsなどの代替になるには少しハードルが高そうなため、今後の発展に期待したい。

## 派生記事リンク
https://qiita.com/KMMT_h_m/private/1d7a9dccafa973812ed5

https://qiita.com/KMMT_h_m/private/fe7817ed5409c951699e

https://qiita.com/KMMT_h_m/private/10616daa60ac98bf2740