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のように追加しておきます。

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

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

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

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

【補足】use_context_provider関数

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

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

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

  1. #[component]
  2. fn PostEntry(id: i32) -> Element { // idのみでOK
  3. let set_posts = use_context::<Signal<Posts>>(); (1)
  4. let mut mode = use_context::<Signal<Mode>>();
  5. let mut target_id = use_context::<Signal<i32>>();
  6. let posted = use_signal(String::new); (2)
  7. let sender = use_signal(String::new);
  8. let content = use_signal(String::new);
  9. let post = &set_posts.read()[&id];
  10. if *mode.read() != Mode::Edit || id != *target_id.read() { (3)
  11. rsx! {
  12. div {
  13. onclick: move |_| { (4)
  14. *mode.write() = Mode::Menu;
  15. *target_id.write() = id;
  16. },
  17. class: "card mb-3",
  18. div {
  19. div {
  20. class: "card-header",
  21. "{post.sender} {post.posted}",
  22. EditMenu { (5)
  23. id: id,
  24. posted: posted,
  25. sender: sender,
  26. content: content,
  27. }
  28. }
  29. …既存のコードと同じなので略…
  30. }
  31. }
  32. }
  33. } else {
  34. rsx! {
  35. PostEdit { (6)
  36. id: id,
  37. posted: posted,
  38. sender: sender,
  39. content: content,
  40. }
  41. }
  42. }
  43. }
リスト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)。

  1. #[component]
  2. fn EditMenu(id: i32, posted: Signal<String>, sender: Signal<String>, content: Signal<String>) -> Element {
  3. let mut posts = use_context::<Signal<Posts>>(); (1)
  4. let mut mode = use_context::<Signal<Mode>>();
  5. let mut target_id = use_context::<Signal<i32>>();
  6. rsx!{
  7. if *mode.read() == Mode::Menu && id == *target_id.read() { (2)
  8. button { (3)
  9. class: "btn btn-primary btn-sm mx-2",
  10. onclick: move |e| { (4)
  11. *mode.write() = Mode::Edit;
  12. *posted.write() = posts.read()[&id].posted.clone();
  13. *sender.write() = posts.read()[&id].sender.clone();
  14. *content.write() = posts.read()[&id].content.clone();
  15. e.stop_propagation();
  16. },
  17. "編集"
  18. }
  19. button { (5)
  20. class: "btn btn-danger btn-sm me-2",
  21. (A)ここに削除のイベントハンドラを追加…
  22. "削除"
  23. }
  24. button { (6)
  25. class: "btn btn-outline-dark btn-sm",
  26. onclick: move |e| { (7)
  27. *mode.write() = Mode::None;
  28. *target_id.write() = 0;
  29. e.stop_propagation();
  30. },
  31. "キャンセル"
  32. }
  33. }
  34. }
  35. }
リスト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 投稿編集フォーム
  1. #[component]
  2. fn PostEdit(id: i32, posted: Signal<String>, sender: Signal<String>, content: Signal<String>) -> Element {
  3. …コンテキストの取得はEditMenuと同様なので略…
  4. rsx! {
  5. div { (1)
  6. class: "mb-4",
  7. input {
  8. class: "d-block mb-2 me-2",
  9. placeholder: "お名前を修正してください",
  10. value: "{sender.read()}",
  11. oninput: move |e| {
  12. sender.set(e.value().clone());
  13. e.stop_propagation();
  14. },
  15. autofocus: "true",
  16. }
  17. textarea {
  18. class: "d-block w-100 mb-2",
  19. placeholder: "メッセージを修正してください",
  20. value: "{content.read()}",
  21. oninput: move |e| {
  22. content.set(e.value().clone());
  23. e.stop_propagation();
  24. }
  25. }
  26. button {
  27. r#type: "button",
  28. class: "btn btn-primary btn-sm me-2",
  29. (A)更新実行のイベントハンドラをここに記述…
  30. "更新",
  31. }
  32. button {
  33. class: "btn btn-outline-dark btn-sm",
  34. onclick: move |e| {
  35. info!("onclick cancel {id}");
  36. *target_id.write() = 0;
  37. *mode.write() = Mode::None;
  38. e.stop_propagation();
  39. },
  40. "キャンセル"
  41. }
  42. }
  43. }
  44. }
リスト5:src/main.rs(PostEditコンポーネント)

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

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

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

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

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

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

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

  1. …略…
  2. onclick: move |e| {
  3. if !sender.read().is_empty() && !content.read().is_empty() {
  4. spawn( async move {
  5. let message = Message {
  6. id: id,
  7. posted: posted.read().clone(),
  8. sender: sender.read().clone(),
  9. content: econtent.read().clone(),
  10. };
  11. let res = data::call_update(&message).await.unwrap();
  12. match &res.result {
  13. ResponseContent::Item(item) => {
  14. posts.write().insert(
  15. item.id,
  16. item.clone()
  17. );
  18. },
  19. _ => {}
  20. };
  21. });
  22. *mode.write() = Mode::None;
  23. e.stop_propagation();
  24. }
  25. },
  26. …略…
リスト7:src/main.rs(投稿更新実行のイベントハンドラ)

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

投稿削除機能の実装

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

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

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

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

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

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

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

  1. …略…
  2. onclick: move |e| {
  3. spawn( async move {
  4. let res = data::call_delete(id).await.unwrap();
  5. match (&res.status).as_str() {
  6. "OK" => {
  7. posts.write().remove(&id); (1)
  8. *mode.write() = Mode::None;
  9. *target_id.write() = 0;
  10. },
  11. _ => {}
  12. };
  13. });
  14. e.stop_propagation();
  15. },
  16. …略…
リスト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

Coding Edge 險倅コ九Λ繝ウ繧ュ繝ウ繧ー

譛ャ譌・譛磯俣

注目のテーマ

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

RSSについて

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

メールマガジン登録

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