第11回では、第10回に引き続き投稿アプリに更新機能を実装します。編集、削除機能の実装を通じて、DioxusによるSPA開発の、より実践的な手順を学びます。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードを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のようにボタン群をメニュー表示し、「編集」ボタンのクリックで投稿編集モードへ移行、「削除」ボタンのクリックで削除、「キャンセル」ボタンのクリックでメニューの消去、それぞれを実行します。
前回、作業モードとしてMode列挙体を定義しました。これに、メニュー表示中のモードMode::Menuを追加し、後ほど投稿を編集するために編集中であることを示すモードMode::Editをリスト1のように追加しておきます。
#[derive(PartialEq)] enum Mode { None, New, Menu, // 追加:メニュー表示中 Edit, // 追加:投稿編集中 }
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) …略…
use_context_provider関数は、引数にフックを生成するクロージャを指定し、生成されたコンテキストへの参照を返します。実際には、型はCloneトレイトを実装すればよく、Signal<T>はその一つです。ここで指定した型でuse_context関数を呼び出すことで、対応するコンテキストを取得できます。
コンテキスト化により、投稿表示コンポーネント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, } } } }
(1)からの3行では、Appコンポーネントでコンテキスト化したフックを、use_context関数で取得しています。(2)からの3行は、Appコンポーネントにおけるものと同様に、編集フォームで編集中の内容を保持するフックです。
(3)では、大きく処理を分けています。作業モードが編集中でないか、対象IDが現idと異なる場合は、通常の投稿表示を行います。それ以外の場合は編集中かつ対象IDと見なして、(6)のように編集コンポーネントPostEditを呼び出します。
(4)では、メニュー表示への移行となる、投稿クリックのイベントハンドラを設定します。作業モードをMode::Menuにセットし、対象IDに現idをセットします。
(5)では、投稿者と投稿日時の右に編集削除メニューを配置するために、コンポーネントEditMenuを呼び出しています。
use_context関数は、指定される型のコンテキストへの参照を返します。このとき、リスト3(1)のような、型パラメーターを「::」で区切って渡す構文は「ターボフィッシュ」(turbofish)と呼ばれます。意味的にはuse_context<i32>()となりますが、Rustではジェネリック関数に型を明示する場合にはターボフィッシュを使うと覚えておきましょう。
use_context_provider関数とuse_context関数の構文を見ると、そこにはコンテキストを識別できる名前というものがありません。そのため、リスト3の(2)のようなフック(全てString型)をコンテキスト化しても、取得時に意図したものが得られるとは限りません。そのため、ここではコンテキストを使わずにpropsで子コンポーネントに渡しています。なお、型が異なれば目的のものを得られるので、String型のフィールドを3つ持つ構造体を定義し、それをフックにすればコンテキスト化も可能です(今回は未実施)。
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(); }, "キャンセル" } } } }
ここでも、(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コンポーネントとして実装します。新規投稿同様に、投稿者、投稿内容、「更新」ボタン、「キャンセル」ボタンから構成されます(図2、リスト5)。
#[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(); }, "キャンセル" } } } }
(1)以降が投稿編集フォームとなりますが、基本的な構造は第10回で紹介した新規投稿フォームと同じです。
投稿更新(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 }
基本的な成り立ちは、投稿作成の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(); } }, …略…
これで、投稿編集機能を実装できました。APIサーバを起動、本アプリもdx serveコマンドでビルド、実行し、投稿編集フォームの表示と編集内容が反映されれば成功です。
最後は、投稿の削除機能です。すでに編集削除メニューの実装は済んでいるので、投稿削除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 }
こちらも基本的な成り立ちは、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(); }, …略…
削除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.