RustとDioxusで投稿アプリのSPAに編集機能と削除機能を実装しようWebアプリ実装で学ぶ、現場で役立つRust入門(11)

第11回では、第10回に引き続き投稿アプリに更新機能を実装します。編集、削除機能の実装を通じて、DioxusによるSPA開発の、より実践的な手順を学びます。

» 2024年08月15日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「Webアプリ実装で学ぶ、現場で役立つRust入門」のインデックス

連載:Webアプリ実装で学ぶ、現場で役立つRust入門

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。


コンテキストの利用

 前回は、新規投稿機能を実装しました。今回は、投稿の編集機能と削除機能を実装していきます。

 ここまで、コンポーネントで値を保持するためにuse_signal(Signal<T>)などのフックを使ってきました。フックを子コンポーネントでも使いたい場合には、propsを使って渡すのが基本です。ただし、子コンポーネントに渡すフックの数が多くなったり、コンポーネントの階層が深くなったりすると、同じようなpropsが増えてくる「Props Drilling」(バケツリレー)が発生します。

 Props Drillingではコードが複雑になり冗長にもなるので、これを回避するためにDioxusには「コンテキスト」(context)という仕組みが用意されています。コンテキストはフックの一つであり、Signal<T>などの値を直接保持します。つまり、フックのコンテナと見なすことができます。

 コンテキストを使うには、まず親コンポーネント(Appなど)で、use_context_provider関数により引数のクロージャでフックのインスタンスを生成します。そして、それを使う子コンポーネントで、use_context関数によりフックのインスタンスを取得します。

let s = use_context_provider(|| Signal::new(String::new()));	// 親
↓
let s = use_context::<Signal<String>>();			// 子

 ここで、sは親子ともにSignal<String>を保持します。つまり、親でも子でも、同じようにフックを操作できるということです。もちろん、propsへの記述は不要です。

 このようにコンテキストは便利ですが、グローバルなフックと見なすことができるので、乱用はコードの可読性やコンポーネントの独立性を低下させます。そのため、コンテキストの利用にはその妥当性の検討が必要です。本稿では各コンポーネントがフックを利用するので、ただ渡すだけのpropsにはなりませんが、コンテキストの利用例として示します。

 なお、現時点でのDioxusにはコンテキストの利用において制約がありますが、それは都度言及します。

編集削除メニューの実装

 新規投稿機能と同様に、一覧表示から直接編集、削除できるようにします。具体的には、投稿表示のクリックで図1のようにボタン群をメニュー表示し、「編集」ボタンのクリックで投稿編集モードへ移行、「削除」ボタンのクリックで削除、「キャンセル」ボタンのクリックでメニューの消去、それぞれを実行します。

図1 編集削除メニュー 図1 編集削除メニュー

作業モードの列挙体Modeを拡張する

 前回、作業モードとしてMode列挙体を定義しました。これに、メニュー表示中のモードMode::Menuを追加し、後ほど投稿を編集するために編集中であることを示すモードMode::Editをリスト1のように追加しておきます。

#[derive(PartialEq)]
enum Mode {
    None,
    New,
    Menu,		// 追加:メニュー表示中
    Edit,		// 追加:投稿編集中
}
リスト1:src/main.rs(Mode列挙体)

Appコンポーネントを修正する

 Appコンポーネントには、投稿を保持するフックposts、作業モードを保持するフックmodeがありましたが、子コンポーネントと共有するためにこれらをリスト2のようにコンテキスト化します(1)(2)。さらに、メニューを表示する投稿を表す「対象ID」を保持する、コンテキストtarget_idを新規に作成します(3)。コンテキスト化により、PostEntryコンポーネント呼び出しの引数は、(4)のようにidのみとシンプルになります。

#[component]
fn App() -> Element {
    let mut posts = use_context_provider(|| Signal::new(Posts::default()));	(1)
    let mut mode = use_context_provider(|| Signal::new(Mode::None));	(2)
    let _target_id = use_context_provider(|| Signal::new(0));		(3)
    …略…
    rsx!(PostEntry { id: *id })		(4)
    …略…
リスト2:src/main.rs(Appコンポーネント)

【補足】use_context_provider関数

 use_context_provider関数は、引数にフックを生成するクロージャを指定し、生成されたコンテキストへの参照を返します。実際には、型はCloneトレイトを実装すればよく、Signal<T>はその一つです。ここで指定した型でuse_context関数を呼び出すことで、対応するコンテキストを取得できます。

PostEntryコンポーネントを修正する

 コンテキスト化により、投稿表示コンポーネントPostEntryにはidのみを渡せばよいので、リスト3のようにpropsを単純化します(実際には#[component]注釈により、propsは関数の引数として記述されています)。

#[component]
fn PostEntry(id: i32) -> Element {	// idのみでOK
    let set_posts = use_context::<Signal<Posts>>();	(1)
    let mut mode = use_context::<Signal<Mode>>();
    let mut target_id = use_context::<Signal<i32>>();
    let posted = use_signal(String::new);		(2)
    let sender = use_signal(String::new);
    let content = use_signal(String::new);
    let post = &set_posts.read()[&id];
    if *mode.read() != Mode::Edit || id != *target_id.read() {	(3)
        rsx! {
            div {
                onclick: move |_| {				(4)
                    *mode.write() = Mode::Menu;
                    *target_id.write() = id;
                },
                class: "card mb-3",
                div {
                    div {
                        class: "card-header",
                        "{post.sender} {post.posted}",
                        EditMenu {				(5)
                            id: id,
                            posted: posted,
                            sender: sender,
                            content: content,
                        }
                    }
                    …既存のコードと同じなので略…
                }
            }
        }
    } else {
        rsx! {
            PostEdit {						(6)
                id: id,
                posted: posted,
                sender: sender,
                content: content,
            }
        }
    }
}
リスト3:src/main.rs(PostEntryコンポーネント)

 (1)からの3行では、Appコンポーネントでコンテキスト化したフックを、use_context関数で取得しています。(2)からの3行は、Appコンポーネントにおけるものと同様に、編集フォームで編集中の内容を保持するフックです。

 (3)では、大きく処理を分けています。作業モードが編集中でないか、対象IDが現idと異なる場合は、通常の投稿表示を行います。それ以外の場合は編集中かつ対象IDと見なして、(6)のように編集コンポーネントPostEditを呼び出します。

 (4)では、メニュー表示への移行となる、投稿クリックのイベントハンドラを設定します。作業モードをMode::Menuにセットし、対象IDに現idをセットします。

 (5)では、投稿者と投稿日時の右に編集削除メニューを配置するために、コンポーネントEditMenuを呼び出しています。

【補足】use_context関数

 use_context関数は、指定される型のコンテキストへの参照を返します。このとき、リスト3(1)のような、型パラメーターを「::」で区切って渡す構文は「ターボフィッシュ」(turbofish)と呼ばれます。意味的にはuse_context<i32>()となりますが、Rustではジェネリック関数に型を明示する場合にはターボフィッシュを使うと覚えておきましょう。

【補足】同じ型のコンテキストは使えない?

 use_context_provider関数とuse_context関数の構文を見ると、そこにはコンテキストを識別できる名前というものがありません。そのため、リスト3の(2)のようなフック(全てString型)をコンテキスト化しても、取得時に意図したものが得られるとは限りません。そのため、ここではコンテキストを使わずにpropsで子コンポーネントに渡しています。なお、型が異なれば目的のものを得られるので、String型のフィールドを3つ持つ構造体を定義し、それをフックにすればコンテキスト化も可能です(今回は未実施)。

EditMenuコンポーネントを作成する

 EditMenuコンポーネントは、編集削除メニューの表示に特化したコンポーネントです(リスト4)。

#[component]
fn EditMenu(id: i32, posted: Signal<String>, sender: Signal<String>, content: Signal<String>) -> Element {
    let mut posts = use_context::<Signal<Posts>>();		(1)
    let mut mode = use_context::<Signal<Mode>>();
    let mut target_id = use_context::<Signal<i32>>();
    rsx!{
        if *mode.read() == Mode::Menu && id == *target_id.read() {	(2)
            button {						(3)
                class: "btn btn-primary btn-sm mx-2",
                onclick: move |e| {				(4)
                    *mode.write() = Mode::Edit;
                    *posted.write() = posts.read()[&id].posted.clone();
                    *sender.write() = posts.read()[&id].sender.clone();
                    *content.write() = posts.read()[&id].content.clone();
                    e.stop_propagation();
                },
                "編集"
            }
            button {						(5)
                class: "btn btn-danger btn-sm me-2",
                …(A)ここに削除のイベントハンドラを追加…
                "削除"
            }
            button {						(6)
                class: "btn btn-outline-dark btn-sm",
                onclick: move |e| {				(7)
                    *mode.write() = Mode::None;
                    *target_id.write() = 0;
                    e.stop_propagation();
                },
                "キャンセル"
            }
        }
    
    }
}
リスト4:src/main.rs(EditMenuコンポーネント)

 ここでも、(1)のようにAppコンポーネントでコンテキスト化されたフックをuse_context関数で取得しています。メニューボタンは、作業モードがMode::Menuであり、現idが対象IDであるときに表示すればよいので、その条件文を(2)に記述しています。

 (3)(5)(6)は、メニューを構成するボタンです。(5)のイベントハンドラの内容は削除処理となるので、後述します。

 (4)は、「編集」ボタンのイベントハンドラです。内容は、編集モードMode::Editへの移行と、投稿の内容を初期値としてpropsで渡されたフックに書き込むというものです。

 (7)は、「キャンセル」ボタンのイベントハンドラです。内容は、作業モードをMode::Noneに戻し、対象IDを0にクリアするのみです。このとき、第10回で紹介したstop_propagationメソッドをイベントに対して呼び出し、イベントの伝播(でんぱ)を抑止して、投稿クリックのイベントハンドラを呼び出さないようにします。

投稿編集機能の実装

 投稿編集機能を実装します。先ほど、編集削除メニューとして「編集」ボタンを設置したので、ボタンクリック時のイベントハンドラをここで実装します。大まかに以下のコードを実装していきます。

  • PostEditコンポーネントの作成
  • API(update)呼び出し関数の作成
  • 投稿更新実行のイベントハンドラの作成

PostEditコンポーネントを作成する

 投稿編集フォームは、PostEditコンポーネントとして実装します。新規投稿同様に、投稿者、投稿内容、「更新」ボタン、「キャンセル」ボタンから構成されます(図2、リスト5)。

図2 投稿編集フォーム 図2 投稿編集フォーム
#[component]
fn PostEdit(id: i32, posted: Signal<String>, sender: Signal<String>, content: Signal<String>) -> Element {
    …コンテキストの取得はEditMenuと同様なので略…
    rsx! {
        div {							(1)
            class: "mb-4",
            input {
                class: "d-block mb-2 me-2",
                placeholder: "お名前を修正してください",
                value: "{sender.read()}",
                oninput: move |e| {
                    sender.set(e.value().clone());
                    e.stop_propagation();
                },
                autofocus: "true",
            }
            textarea {
                class: "d-block w-100 mb-2",
                placeholder: "メッセージを修正してください",
                value: "{content.read()}",
                oninput: move |e| {
                    content.set(e.value().clone());
                    e.stop_propagation();
                }
            }
            button {
                r#type: "button",
                class: "btn btn-primary btn-sm me-2",
                …(A)更新実行のイベントハンドラをここに記述…
                "更新",
            }
            button {
                class: "btn btn-outline-dark btn-sm",
                onclick: move |e| {
                    info!("onclick cancel {id}");
                    *target_id.write() = 0;
                    *mode.write() = Mode::None;
                    e.stop_propagation();
                },
                "キャンセル"
            }
        }
    }
}
リスト5:src/main.rs(PostEditコンポーネント)

 (1)以降が投稿編集フォームとなりますが、基本的な構造は第10回で紹介した新規投稿フォームと同じです。

API(update)呼び出し関数を作成する

 投稿更新(update)のAPI呼び出し関数をリスト6のように作成します。

pub async fn call_update(message: &Message) -> Result<ApiResponse, reqwest::Error> {
    let url = format!("{}/update", BASE_API_URL);
    let client = reqwest::Client::new();
    client.put(&url)		(1)
        .json(message)
        .send()
        .await?.json::<ApiResponse>().await
}
リスト6:src/data.rs(call_update関数)

 基本的な成り立ちは、投稿作成のcall_create関数と同じです。投稿更新はHTTP PUTによって送信するため、(1)のようにpostメソッドではなくputメソッドを呼び出します。

投稿更新実行のイベントハンドラを作成する

 リスト5(A)の処理内容はリスト7の通りです。

…略…
onclick: move |e| {
    if !sender.read().is_empty() && !content.read().is_empty() {
        spawn( async move {
            let message = Message {
                id: id,
                posted: posted.read().clone(),
                sender: sender.read().clone(),
                content: econtent.read().clone(),
            };
            let res = data::call_update(&message).await.unwrap();
            match &res.result {
                ResponseContent::Item(item) => {
                    posts.write().insert(
                        item.id,
                        item.clone()
                    );
                },
                _ => {}
            };
        });
        *mode.write() = Mode::None;
        e.stop_propagation();
    }
},
…略…
リスト7:src/main.rs(投稿更新実行のイベントハンドラ)

 これで、投稿編集機能を実装できました。APIサーバを起動、本アプリもdx serveコマンドでビルド、実行し、投稿編集フォームの表示と編集内容が反映されれば成功です。

投稿削除機能の実装

 最後は、投稿の削除機能です。すでに編集削除メニューの実装は済んでいるので、投稿削除API(delete)呼び出し関数の作成と、「削除」ボタンクリック時のイベントハンドラを実装するだけです。

API(delete)呼び出し関数を作成する

 投稿削除(delete)のAPI呼び出し関数をリスト8のように作成します。

pub async fn call_delete(id: i32) -> Result<ApiResponse, reqwest::Error> {
    let url = format!("{}/{}/delete", BASE_API_URL, id);
    let client = reqwest::Client::new();
    client.delete(&url)				(1)
        .send()
        .await?.json::<ApiResponse>().await
}
リスト8:src/data.rs(call_delete関数)

 こちらも基本的な成り立ちは、call_create関数やcall_upate関数と同じです。投稿削除はHTTP DELETEによって送信するため、(1)のようにdeleteメソッドを呼び出します。

投稿削除実行のイベントハンドラを実装する

 リスト4(A)の処理内容はリスト9の通りです。処理が複雑になるので削除確認は入れていません。

…略…
onclick: move |e| {
    spawn( async move {
        let res = data::call_delete(id).await.unwrap();
        match (&res.status).as_str() {
            "OK" => {
                posts.write().remove(&id);		(1)
                *mode.write() = Mode::None;
                *target_id.write() = 0;
            },
            _ => {}
        };
    });
    e.stop_propagation();
},
…略…
リスト9:src/main.rs(EditMenuコンポーネントの投稿削除実行のイベントハンドラ)

 削除APIの呼び出し後、エラーでなければ(1)のようにremoveメソッドをハッシュマップに対して呼び出すことで、投稿一覧からも削除されます。

 これで、投稿削除機能を実装できました。APIサーバを起動、本アプリもdx serveコマンドでビルド、実行し、投稿が削除されれば成功です。

まとめ

 今回は、Dioxusシリーズの最終回として、投稿編集と削除の機能を実装し、Dioxusのコンテキストの使い方も紹介しました。

 次回からは、Rust and WebAssemblyを使って、より直接的にDOM(Document Object Model)を操作するSPA(Single Page Application)の開発について紹介します。

筆者紹介

WINGSプロジェクト

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。

サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/
RSS(https://wings.msn.to/contents/rss.php
X: @WingsPro_info(https://x.com/WingsPro_info
Facebook(https://www.facebook.com/WINGSProject


Copyright © ITmedia, Inc. All Rights Reserved.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。