public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations
@ 2025-12-09 13:11 Dietmar Maurer
  2025-12-09 18:31 ` Thomas Lamprecht
  2025-12-10  7:53 ` Thomas Lamprecht
  0 siblings, 2 replies; 5+ messages in thread
From: Dietmar Maurer @ 2025-12-09 13:11 UTC (permalink / raw)
  To: yew-devel

Major Refactoring of the `LoadableComponent` system.

Encapsulate component state (loading, error, view_state) into a new
`LoadableComponentState` struct.
Instead of `LoadableComponentMaster` managing the state externally,
the concrete components now own their `LoadableComponentState`.

- Use `impl_deref_mut_property`LoadableComponentState` struct. to implement
  Deref/DerefMut for components, allowing `LoadableComponentMaster` to
  access the state transparently.
- `LoadableComponentContext` is now a normal yew Scope (removed custom implementation)
- Migrate all `LoadableComponent` implementations (ACL, ACME, APT, Network,
  User/Token/TFA views, etc.) to the new pattern.
- Use `link.custom_callback` and `link.send_custom_message` for internal
  messaging (rename is necessaray because of naming conflict with standard
  Scope function).
- avoid useless Redraw/Datachange/Refresh messages, because `LoadableComponentMaster`
  already implements that.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/acl/acl_view.rs                        |  26 +-
 src/acme/acme_accounts.rs                  |  28 +-
 src/acme/acme_domains.rs                   |  35 +-
 src/acme/acme_plugins.rs                   |  60 +-
 src/acme/certificate_list.rs               |  36 +-
 src/apt_package_manager.rs                 |  45 +-
 src/apt_repositories.rs                    |  44 +-
 src/auth_view.rs                           |  36 +-
 src/configuration/network_view.rs          |  40 +-
 src/configuration/pve/lxc_network_panel.rs |  31 +-
 src/lib.rs                                 |  33 +-
 src/loadable_component.rs                  | 698 ++++++++++++---------
 src/node_status_panel.rs                   |  37 +-
 src/notes_view.rs                          |  16 +-
 src/object_grid.rs                         |  36 +-
 src/permission_panel.rs                    |  17 +-
 src/subscription_panel.rs                  |  33 +-
 src/tasks.rs                               |  39 +-
 src/tfa/tfa_view.rs                        |  36 +-
 src/token_panel.rs                         |  24 +-
 src/user_panel.rs                          |  31 +-
 21 files changed, 825 insertions(+), 556 deletions(-)

diff --git a/src/acl/acl_view.rs b/src/acl/acl_view.rs
index 58da3fd..9347d72 100644
--- a/src/acl/acl_view.rs
+++ b/src/acl/acl_view.rs
@@ -24,7 +24,9 @@ use proxmox_access_control::types::{AclListItem, AclUgidType};
 use crate::percent_encoding::percent_encode_component;
 use crate::utils::render_boolean;
 use crate::{
-    ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponent,
+    LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+    LoadableComponentState,
 };
 
 use super::acl_edit::AclEditWindow;
@@ -95,15 +97,17 @@ enum ViewState {
 }
 
 enum Msg {
-    Reload,
     Remove,
 }
 
 struct ProxmoxAclView {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<AclListItem>,
 }
 
+impl_deref_mut_property!(ProxmoxAclView, state, LoadableComponentState<ViewState>);
+
 impl ProxmoxAclView {
     fn colmuns() -> Rc<Vec<DataTableHeader<AclListItem>>> {
         Rc::new(vec![
@@ -141,14 +145,21 @@ impl LoadableComponent for ProxmoxAclView {
         let link = ctx.link();
         link.repeated_load(5000);
 
-        let selection = Selection::new().on_select(link.callback(|_| Msg::Reload));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
 
         let store = Store::with_extract_key(|record: &AclListItem| {
             let acl_id = format!("{} for {} - {}", record.path, record.ugid, record.roleid);
             Key::from(acl_id)
         });
 
-        Self { selection, store }
+        Self {
+            state: LoadableComponentState::new(),
+            selection,
+            store,
+        }
     }
 
     fn load(
@@ -203,7 +214,7 @@ impl LoadableComponent for ProxmoxAclView {
             ConfirmButton::new(tr!("Remove ACL Entry"))
                 .confirm_message(tr!("Are you sure you want to remove this ACL entry?"))
                 .disabled(disabled)
-                .on_activate(ctx.link().callback(|_| Msg::Remove)),
+                .on_activate(ctx.link().custom_callback(|_| Msg::Remove)),
         );
 
         Some(toolbar.into())
@@ -211,14 +222,13 @@ impl LoadableComponent for ProxmoxAclView {
 
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::Reload => true,
             Msg::Remove => {
                 if let Some(key) = self.selection.selected_key() {
                     if let Some(record) = self.store.read().lookup_record(&key).cloned() {
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let url = ctx.props().acl_api_endpoint.to_owned();
 
-                        link.clone().spawn(async move {
+                        self.spawn(async move {
                             let data = match record.ugid_type {
                                 AclUgidType::User => json!({
                                     "delete": true,
diff --git a/src/acme/acme_accounts.rs b/src/acme/acme_accounts.rs
index 19bf48b..4c4db1a 100644
--- a/src/acme/acme_accounts.rs
+++ b/src/acme/acme_accounts.rs
@@ -15,8 +15,9 @@ use crate::common_api_types::AcmeAccountInfo;
 use crate::percent_encoding::percent_encode_component;
 use crate::utils::render_url;
 use crate::{
-    ConfirmButton, DataViewWindow, LoadableComponent, LoadableComponentContext,
-    LoadableComponentMaster,
+    impl_deref_mut_property, ConfirmButton, DataViewWindow, LoadableComponent,
+    LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+    LoadableComponentState,
 };
 
 use super::AcmeRegisterAccount;
@@ -44,14 +45,17 @@ impl AcmeAccountsPanel {
 
 #[doc(hidden)]
 pub struct ProxmoxAcmeAccountsPanel {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<AcmeAccountEntry>,
     columns: Rc<Vec<DataTableHeader<AcmeAccountEntry>>>,
 }
 
-pub enum Msg {
-    Redraw,
-}
+impl_deref_mut_property!(
+    ProxmoxAcmeAccountsPanel,
+    state,
+    LoadableComponentState<ViewState>
+);
 
 #[derive(PartialEq)]
 pub enum ViewState {
@@ -61,11 +65,14 @@ pub enum ViewState {
 
 impl LoadableComponent for ProxmoxAcmeAccountsPanel {
     type Properties = AcmeAccountsPanel;
-    type Message = Msg;
+    type Message = ();
     type ViewState = ViewState;
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         let store =
             Store::with_extract_key(|record: &AcmeAccountEntry| Key::from(record.name.clone()));
 
@@ -77,6 +84,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
             .into()]);
 
         Self {
+            state: LoadableComponentState::new(),
             selection,
             store,
             columns,
@@ -110,7 +118,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
                 Button::new(tr!("View"))
                     .disabled(selected_key.is_none())
                     .onclick({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let selected_key = selected_key.clone();
                         move |_| {
                             if let Some(selected_key) = &selected_key {
@@ -123,7 +131,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
                 ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
                     .disabled(selected_key.is_none())
                     .on_activate({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let selected_key = selected_key.clone();
 
                         move |_| {
@@ -159,7 +167,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
             .selection(self.selection.clone())
             .on_row_dblclick({
                 let selection = self.selection.clone();
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 move |_: &mut _| {
                     if let Some(selected_key) = selection.selected_key() {
                         link.change_view(Some(ViewState::View(selected_key.clone())));
diff --git a/src/acme/acme_domains.rs b/src/acme/acme_domains.rs
index 6ab924d..32003d8 100644
--- a/src/acme/acme_domains.rs
+++ b/src/acme/acme_domains.rs
@@ -19,8 +19,10 @@ use pwt_macros::builder;
 use crate::common_api_types::{create_acme_config_string, parse_acme_config_string, AcmeConfig};
 use crate::common_api_types::{create_acme_domain_string, parse_acme_domain_string, AcmeDomain};
 use crate::percent_encoding::percent_encode_component;
-use crate::{ConfirmButton, EditWindow};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponentState};
+use crate::{
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+};
 
 use super::{AcmeAccountSelector, AcmeChallengeTypeSelector, AcmePluginSelector};
 
@@ -54,14 +56,20 @@ impl AcmeDomainsPanel {
 
 #[doc(hidden)]
 pub struct ProxmoxAcmeDomainsPanel {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<AcmeDomainEntry>,
     columns: Rc<Vec<DataTableHeader<AcmeDomainEntry>>>,
     acme_account: Option<AcmeConfig>,
 }
 
+impl_deref_mut_property!(
+    ProxmoxAcmeDomainsPanel,
+    state,
+    LoadableComponentState<ViewState>
+);
+
 pub enum Msg {
-    Redraw,
     AcmeAccount(Option<AcmeConfig>),
 }
 
@@ -78,7 +86,10 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
     type ViewState = ViewState;
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         let store = Store::with_extract_key(|record: &AcmeDomainEntry| {
             Key::from(record.config_key.clone())
         });
@@ -109,6 +120,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
         ctx.link().repeated_load(3000);
 
         Self {
+            state: LoadableComponentState::new(),
             selection,
             store,
             columns,
@@ -122,7 +134,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
     ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
         let store = self.store.clone();
         let url = ctx.props().url.clone();
-        let link = ctx.link();
+        let link = ctx.link().clone();
         Box::pin(async move {
             let data: Value = crate::http_get(&*url, None).await?;
 
@@ -147,9 +159,9 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
 
             if let Some(Value::String(acme_account)) = data.get("acme") {
                 let acme_account = parse_acme_config_string(acme_account)?;
-                link.send_message(Msg::AcmeAccount(Some(acme_account)));
+                link.send_custom_message(Msg::AcmeAccount(Some(acme_account)));
             } else {
-                link.send_message(Msg::AcmeAccount(None));
+                link.send_custom_message(Msg::AcmeAccount(None));
             }
             Ok(())
         })
@@ -157,7 +169,6 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
 
     fn update(&mut self, _ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::Redraw => true,
             Msg::AcmeAccount(acme_account) => {
                 self.acme_account = acme_account;
                 true
@@ -179,7 +190,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
                 Button::new(tr!("Edit"))
                     .disabled(selected_key.is_none())
                     .onclick({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let selected_key = selected_key.clone();
                         move |_| {
                             if let Some(selected_key) = &selected_key {
@@ -192,7 +203,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
                 ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
                     .disabled(selected_key.is_none())
                     .on_activate({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let url = ctx.props().url.clone();
                         let selected_key = selected_key.clone();
                         move |_| {
@@ -238,7 +249,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
                 </div>}
             })
             .with_child(Button::new(tr!("Order Certificate Now")).onclick({
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 move |_| {
                     let command_path = "/nodes/localhost/certificates/acme/certificate";
                     link.start_task(command_path, None, false);
@@ -253,7 +264,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
             .class("pwt-flex-fit")
             .selection(self.selection.clone())
             .on_row_dblclick({
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 move |event: &mut DataTableMouseEvent| {
                     let key = &event.record_key;
                     link.change_view(Some(ViewState::Edit(key.clone())));
diff --git a/src/acme/acme_plugins.rs b/src/acme/acme_plugins.rs
index f0b1e69..66baa5f 100644
--- a/src/acme/acme_plugins.rs
+++ b/src/acme/acme_plugins.rs
@@ -9,19 +9,20 @@ use serde::{Deserialize, Serialize};
 use serde_json::Value;
 use yew::virtual_dom::{Key, VComp, VNode};
 
+use pwt::prelude::*;
 use pwt::state::{Selection, Store};
 use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent};
 use pwt::widget::form::{DisplayField, Field, FormContext, Number, TextArea};
 use pwt::widget::{Button, InputPanel, Toolbar};
-use pwt::{prelude::*, AsyncPool};
 
 use pwt_macros::builder;
 
 use crate::form::delete_empty_values;
 use crate::percent_encoding::percent_encode_component;
 use crate::{
-    http_get, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
-    LoadableComponentLink, LoadableComponentMaster,
+    http_get, impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponent,
+    LoadableComponentContext, LoadableComponentMaster, LoadableComponentScope,
+    LoadableComponentScopeExt, LoadableComponentState,
 };
 
 use super::{AcmeChallengeSchemaItem, AcmeChallengeSelector};
@@ -81,14 +82,20 @@ struct ChallengeSchemaInfo {
 
 #[doc(hidden)]
 pub struct ProxmoxAcmePluginsPanel {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<PluginConfig>,
     columns: Rc<Vec<DataTableHeader<PluginConfig>>>,
     challenge_schema: Option<AcmeChallengeSchemaItem>,
     schema_info: ChallengeSchemaInfo,
-    async_pool: AsyncPool,
 }
 
+impl_deref_mut_property!(
+    ProxmoxAcmePluginsPanel,
+    state,
+    LoadableComponentState<ViewState>
+);
+
 #[derive(PartialEq)]
 pub enum ViewState {
     Add,
@@ -96,7 +103,6 @@ pub enum ViewState {
 }
 
 pub enum Msg {
-    Redraw,
     CloseDialog,
     Add,
     Edit(Key),
@@ -126,16 +132,20 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
     type ViewState = ViewState;
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         let store =
             Store::with_extract_key(|record: &PluginConfig| Key::from(record.plugin.clone()));
 
         let schema_name_map = Rc::new(HashMap::new());
         let columns = columns(schema_name_map.clone());
 
-        ctx.link().send_message(Msg::LoadChallengeSchemaList);
+        ctx.link().send_custom_message(Msg::LoadChallengeSchemaList);
 
         Self {
+            state: LoadableComponentState::new(),
             selection,
             store,
             columns,
@@ -144,7 +154,6 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
                 schema_name_map,
                 store: Store::new(),
             },
-            async_pool: AsyncPool::new(),
         }
     }
 
@@ -164,7 +173,6 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
 
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::Redraw => true,
             Msg::Add => {
                 self.challenge_schema = None;
                 ctx.link().change_view(Some(ViewState::Add));
@@ -217,10 +225,10 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
             }
             Msg::LoadChallengeSchemaList => {
                 let url = ctx.props().challenge_shema_url.clone();
-                let link = ctx.link();
-                self.async_pool.spawn(async move {
+                let link = ctx.link().clone();
+                self.spawn(async move {
                     let result = http_get(&*url, None).await;
-                    link.send_message(Msg::UpdateChallengeSchemaList(result));
+                    link.send_custom_message(Msg::UpdateChallengeSchemaList(result));
                 });
                 false
             }
@@ -241,7 +249,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
                     );
                     let command_future = crate::http_delete(command_path, None);
                     let link = ctx.link().clone();
-                    self.async_pool.spawn(async move {
+                    self.spawn(async move {
                         match command_future.await {
                             Ok(()) => {
                                 link.send_reload();
@@ -265,16 +273,16 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
             .class("pwt-w-100")
             .class("pwt-overflow-hidden")
             .class("pwt-border-bottom")
-            .with_child(Button::new(tr!("Add")).onclick(ctx.link().callback(|_| Msg::Add)))
+            .with_child(Button::new(tr!("Add")).onclick(ctx.link().custom_callback(|_| Msg::Add)))
             .with_child(
                 Button::new(tr!("Edit"))
                     .disabled(selected_key.is_none())
                     .onclick({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let selected_key = selected_key.clone();
                         move |_| {
                             if let Some(selected_key) = &selected_key {
-                                link.send_message(Msg::Edit(selected_key.clone()));
+                                link.send_custom_message(Msg::Edit(selected_key.clone()));
                             }
                         }
                     }),
@@ -282,7 +290,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
             .with_child(
                 ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
                     .disabled(selected_key.is_none())
-                    .on_activate(ctx.link().callback({
+                    .on_activate(ctx.link().custom_callback({
                         let selected_key = selected_key.clone();
                         move |_| Msg::Delete(selected_key.clone())
                     })),
@@ -297,11 +305,11 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
             .selection(self.selection.clone())
             .on_row_dblclick({
                 let store = self.store.clone();
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 move |event: &mut DataTableMouseEvent| {
                     let key = &event.record_key;
                     if store.read().lookup_record(key).is_some() {
-                        link.send_message(Msg::Edit(key.clone()));
+                        link.send_custom_message(Msg::Edit(key.clone()));
                     };
                 }
             })
@@ -372,7 +380,7 @@ impl ProxmoxAcmePluginsPanel {
     }
 
     fn dns_plugin_input_panel(
-        link: &LoadableComponentLink<Self>,
+        link: &LoadableComponentScope<Self>,
         form_ctx: &FormContext,
         id: Option<&str>,
         challenge_schema: Option<&AcmeChallengeSchemaItem>,
@@ -411,7 +419,7 @@ impl ProxmoxAcmePluginsPanel {
                 AcmeChallengeSelector::with_store(challenge_store)
                     .name("api")
                     .required(true)
-                    .on_change(link.callback(Msg::ChallengeSchema)),
+                    .on_change(link.custom_callback(Msg::ChallengeSchema)),
             );
 
         if let Some(description) =
@@ -463,7 +471,7 @@ impl ProxmoxAcmePluginsPanel {
                         .on_change({
                             let field_name = field_name.clone();
                             let form_ctx = form_ctx.clone();
-                            link.callback(move |v| {
+                            link.custom_callback(move |v| {
                                 Msg::ApiDataChange(form_ctx.clone(), field_name.clone(), v)
                             })
                         }),
@@ -495,10 +503,10 @@ impl ProxmoxAcmePluginsPanel {
                 move |url: AttrValue| crate::http_get_full(url.to_string(), None),
                 url.clone(),
             ))
-            .on_done(ctx.link().callback(|_| Msg::CloseDialog))
+            .on_done(ctx.link().custom_callback(|_| Msg::CloseDialog))
             .renderer({
                 let id = id.to_owned();
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 let challenge_schema = self.challenge_schema.clone();
                 let challenge_store = self.schema_info.store.clone();
                 move |form_ctx: &FormContext| {
@@ -532,9 +540,9 @@ impl ProxmoxAcmePluginsPanel {
 
     fn create_add_dns_plugin_dialog(&self, ctx: &crate::LoadableComponentContext<Self>) -> Html {
         EditWindow::new(tr!("Add") + ": " + &tr!("ACME DNS Plugin"))
-            .on_done(ctx.link().callback(|_| Msg::CloseDialog))
+            .on_done(ctx.link().custom_callback(|_| Msg::CloseDialog))
             .renderer({
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 let challenge_schema = self.challenge_schema.clone();
                 let challenge_store = self.schema_info.store.clone();
                 move |form_ctx: &FormContext| {
diff --git a/src/acme/certificate_list.rs b/src/acme/certificate_list.rs
index d9ce92c..7ac20e6 100644
--- a/src/acme/certificate_list.rs
+++ b/src/acme/certificate_list.rs
@@ -18,8 +18,9 @@ use pwt::widget::{Button, Container, Dialog, FileButton, MessageBox, Toolbar};
 use crate::common_api_types::CertificateInfo;
 use crate::utils::render_epoch;
 use crate::{
-    ConfirmButton, EditWindow, KVGrid, KVGridRow, LoadableComponent, LoadableComponentContext,
-    LoadableComponentMaster,
+    impl_deref_mut_property, ConfirmButton, EditWindow, KVGrid, KVGridRow, LoadableComponent,
+    LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+    LoadableComponentState,
 };
 
 async fn upload_custom_certificate(form_ctx: FormContext) -> Result<(), Error> {
@@ -46,10 +47,6 @@ impl CertificateList {
     }
 }
 
-pub enum Msg {
-    Redraw,
-}
-
 #[derive(PartialEq)]
 pub enum ViewState {
     CertificateView(Rc<Value>),
@@ -59,24 +56,35 @@ pub enum ViewState {
 
 #[doc(hidden)]
 pub struct ProxmoxCertificateList {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<CertificateInfo>,
     columns: Rc<Vec<DataTableHeader<CertificateInfo>>>,
     rows: Rc<Vec<KVGridRow>>,
 }
 
+impl_deref_mut_property!(
+    ProxmoxCertificateList,
+    state,
+    LoadableComponentState<ViewState>
+);
+
 impl LoadableComponent for ProxmoxCertificateList {
     type Properties = CertificateList;
-    type Message = Msg;
+    type Message = ();
     type ViewState = ViewState;
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         let store =
             Store::with_extract_key(|record: &CertificateInfo| Key::from(record.filename.clone()));
         let columns = Rc::new(columns());
         let rows = Rc::new(rows());
         Self {
+            state: LoadableComponentState::new(),
             selection,
             store,
             columns,
@@ -121,7 +129,7 @@ impl LoadableComponent for ProxmoxCertificateList {
                         "proxy.pem"
                     ))
                     .on_activate({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         move |_| {
                             let link = link.clone();
                             let command_path = "/nodes/localhost/certificates/custom".to_string();
@@ -145,7 +153,7 @@ impl LoadableComponent for ProxmoxCertificateList {
                     .disabled(selected_cert.is_none())
                     .onclick({
                         let selected_cert = selected_cert.clone();
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         move |_| {
                             if let Some(selected_cert) = &selected_cert {
                                 let cert_data: Value =
@@ -167,7 +175,7 @@ impl LoadableComponent for ProxmoxCertificateList {
             .selection(self.selection.clone())
             .on_row_dblclick({
                 let store = self.store.clone();
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 move |event: &mut DataTableMouseEvent| {
                     let key = &event.record_key;
                     if let Some(selected_cert) = store.read().lookup_record(key).cloned() {
@@ -226,10 +234,10 @@ async fn update_field_from_file(
 
 impl ProxmoxCertificateList {
     fn create_upload_custom_certificate(&self, ctx: &LoadableComponentContext<Self>) -> Html {
-        let link = ctx.link();
+        let link = ctx.link().clone();
         EditWindow::new(tr!("Upload Custom Certificate"))
             .width(600)
-            .on_close(ctx.link().change_view_callback(|_| None))
+            .on_close(link.change_view_callback(|_| None))
             .submit_text(tr!("Upload"))
             .renderer(move |form_ctx: &FormContext| {
                 Form::new()
@@ -287,7 +295,7 @@ impl ProxmoxCertificateList {
                     .into()
             })
             .on_submit({
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 move |form_ctx: FormContext| {
                     let link = link.clone();
                     async move {
diff --git a/src/apt_package_manager.rs b/src/apt_package_manager.rs
index 82d536f..7264dae 100644
--- a/src/apt_package_manager.rs
+++ b/src/apt_package_manager.rs
@@ -18,13 +18,14 @@ use pwt::widget::data_table::{
     DataTable, DataTableCellRenderArgs, DataTableColumn, DataTableHeader, DataTableHeaderGroup,
 };
 use pwt::widget::{AlertDialog, Button, Container, Toolbar, Tooltip};
-use pwt::AsyncPool;
 
 use crate::percent_encoding::percent_encode_component;
 use crate::subscription_alert::subscription_is_active;
+use crate::LoadableComponentState;
 use crate::SubscriptionAlert;
 use crate::{
-    DataViewWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster, XTermJs,
+    DataViewWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, XTermJs,
 };
 use proxmox_apt_api_types::APTUpdateInfo;
 
@@ -163,31 +164,43 @@ pub enum ViewState {
 /// Messages for [`ProxmoxAptManager::update`]
 pub enum Msg {
     CheckSubscription,
-    SelectionChange,
 }
 
 pub struct ProxmoxAptPackageManager {
+    state: LoadableComponentState<ViewState>,
     tree_store: TreeStore<TreeEntry>,
     selection: Selection,
     columns: Rc<Vec<DataTableHeader<TreeEntry>>>,
-    async_pool: AsyncPool,
 }
 
+crate::impl_deref_mut_property!(
+    ProxmoxAptPackageManager,
+    state,
+    LoadableComponentState<ViewState>
+);
+
 impl LoadableComponent for ProxmoxAptPackageManager {
     type Properties = AptPackageManager;
     type Message = Msg;
     type ViewState = ViewState;
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+        let props = ctx.props();
         let tree_store = TreeStore::new().view_root(false);
         let columns = Self::columns(ctx, tree_store.clone());
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
+
+        let mut state = LoadableComponentState::new();
+        state.set_task_base_url(props.task_base_url.clone());
 
         Self {
+            state,
             tree_store,
             selection,
             columns,
-            async_pool: AsyncPool::new(),
         }
     }
 
@@ -200,14 +213,12 @@ impl LoadableComponent for ProxmoxAptPackageManager {
                     .clone()
                     .subscription_url
                     .unwrap_or("/nodes/localhost/subscription".into());
-                let task_base_url = props.task_base_url.clone();
                 let command = format!("{}/update", props.base_url);
-                self.async_pool.spawn(async move {
+                self.spawn(async move {
                     let data = crate::http_get::<Value>(url.as_str(), None).await;
                     let is_active = subscription_is_active(Some(&data));
 
                     if is_active {
-                        link.task_base_url(task_base_url);
                         link.start_task(&command, None, false);
                     } else {
                         link.change_view(Some(ViewState::ShowSubscriptionPopup));
@@ -215,7 +226,6 @@ impl LoadableComponent for ProxmoxAptPackageManager {
                 });
                 true
             }
-            Msg::SelectionChange => true,
         }
     }
 
@@ -264,15 +274,14 @@ impl LoadableComponent for ProxmoxAptPackageManager {
             .class("pwt-overflow-hidden")
             .class("pwt-border-bottom")
             .with_child(Button::new(tr!("Refresh")).on_activate({
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 let sub_check = props.subscription_url.is_some();
-                link.task_base_url(props.task_base_url.clone());
 
                 let command = format!("{}/update", props.base_url);
 
                 move |_| {
                     if sub_check {
-                        link.send_message(Msg::CheckSubscription);
+                        link.send_custom_message(Msg::CheckSubscription);
                     } else {
                         link.start_task(&command, None, false);
                     }
@@ -287,7 +296,7 @@ impl LoadableComponent for ProxmoxAptPackageManager {
                 Button::new(tr!("Changelog"))
                     .disabled(selected_package.is_none())
                     .onclick({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let view = selected_package
                             .as_ref()
                             .map(|p| ViewState::ShowChangelog(p.clone()));
@@ -296,8 +305,8 @@ impl LoadableComponent for ProxmoxAptPackageManager {
             )
             .with_flex_spacer()
             .with_child({
-                let loading = ctx.loading();
-                let link = ctx.link();
+                let loading = self.loading();
+                let link = ctx.link().clone();
                 Button::refresh(loading).onclick(move |_| link.send_reload())
             });
 
@@ -325,11 +334,9 @@ impl LoadableComponent for ProxmoxAptPackageManager {
             ViewState::ShowSubscriptionPopup => {
                 let link = ctx.link().clone();
                 let props = ctx.props();
-                let task_base_url = props.task_base_url.clone();
                 let command = format!("{}/update", props.base_url);
                 let on_close = move |_| {
                     link.change_view(None);
-                    link.task_base_url(task_base_url.clone());
                     link.start_task(&command, None, false);
                 };
                 Some(if let Some(msg) = props.subscription_message.clone() {
@@ -351,6 +358,8 @@ impl LoadableComponent for ProxmoxAptPackageManager {
     ) -> bool {
         let props = ctx.props();
 
+        self.set_task_base_url(props.task_base_url.clone());
+
         if props.base_url != old_props.base_url || props.task_base_url != old_props.task_base_url {
             ctx.link().send_reload();
             true
diff --git a/src/apt_repositories.rs b/src/apt_repositories.rs
index 7157c0c..904c052 100644
--- a/src/apt_repositories.rs
+++ b/src/apt_repositories.rs
@@ -22,7 +22,8 @@ use pwt::widget::{Button, Column, Container, Fa, Row, Toolbar, Tooltip};
 use crate::subscription_alert::subscription_is_active;
 use crate::{
     EditWindow, ExistingProduct, LoadableComponent, LoadableComponentContext,
-    LoadableComponentMaster, ProjectInfo, SubscriptionAlert,
+    LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, ProjectInfo,
+    SubscriptionAlert,
 };
 
 use pwt_macros::builder;
@@ -406,7 +407,6 @@ fn apt_configuration_to_tree(config: &APTRepositoriesResult) -> SlabTree<TreeEnt
 }
 
 pub enum Msg {
-    Refresh,
     ToggleEnable,
     UpdateStatus(APTRepositoriesResult),
     SubscriptionInfo(Result<Value, Error>),
@@ -419,6 +419,7 @@ pub enum ViewState {
 }
 
 pub struct ProxmoxAptRepositories {
+    state: LoadableComponentState<ViewState>,
     tree_store: TreeStore<TreeEntry>,
     selection: Selection,
     columns: Rc<Vec<DataTableHeader<TreeEntry>>>,
@@ -430,6 +431,12 @@ pub struct ProxmoxAptRepositories {
     status_columns: Rc<Vec<DataTableHeader<StatusLine>>>,
 }
 
+crate::impl_deref_mut_property!(
+    ProxmoxAptRepositories,
+    state,
+    LoadableComponentState<ViewState>
+);
+
 impl LoadableComponent for ProxmoxAptRepositories {
     type Properties = AptRepositories;
     type Message = Msg;
@@ -438,19 +445,27 @@ impl LoadableComponent for ProxmoxAptRepositories {
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
         let tree_store = TreeStore::new().view_root(false);
         let columns = Self::columns(ctx, tree_store.clone());
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Refresh));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         let status_columns = Self::status_columns(ctx);
 
         let subscription_url = ctx.props().subscription_url.clone();
 
-        let link = ctx.link();
-        link.send_future(async move {
-            // TODO: also reload this in load, not only create?!
-            let data = crate::http_get(subscription_url.to_string(), None).await;
-            Msg::SubscriptionInfo(data)
+        let state = LoadableComponentState::new();
+
+        state.spawn({
+            let link = ctx.link().clone();
+            async move {
+                // TODO: also reload this in load, not only create?!
+                let data = crate::http_get(subscription_url.to_string(), None).await;
+                link.send_custom_message(Msg::SubscriptionInfo(data));
+            }
         });
 
         Self {
+            state,
             tree_store,
             selection,
             columns,
@@ -470,13 +485,13 @@ impl LoadableComponent for ProxmoxAptRepositories {
         let props = ctx.props();
         let base_url = props.base_url.clone();
         let tree_store = self.tree_store.clone();
-        let link = ctx.link();
+        let link = ctx.link().clone();
 
         Box::pin(async move {
             let config = apt_configuration(base_url.clone()).await?;
             let tree = apt_configuration_to_tree(&config);
             tree_store.write().update_root_tree(tree);
-            link.send_message(Msg::UpdateStatus(config));
+            link.send_custom_message(Msg::UpdateStatus(config));
             Ok(())
         })
     }
@@ -484,7 +499,6 @@ impl LoadableComponent for ProxmoxAptRepositories {
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         let props = ctx.props();
         match msg {
-            Msg::Refresh => true,
             Msg::SubscriptionInfo(status) => {
                 self.subscription_status = Some(status);
                 if let Some(config) = &self.config {
@@ -547,7 +561,7 @@ impl LoadableComponent for ProxmoxAptRepositories {
                     });
                     // fixme: add digest to protect against concurrent changes
                     let url = format!("{}/repositories", props.base_url);
-                    let link = ctx.link();
+                    let link = ctx.link().clone();
                     link.clone().spawn(async move {
                         match crate::http_post(url, Some(param)).await {
                             Ok(()) => {
@@ -606,12 +620,12 @@ impl LoadableComponent for ProxmoxAptRepositories {
                     tr!("Enable")
                 })
                 .disabled(enabled.is_none())
-                .onclick(ctx.link().callback(|_| Msg::ToggleEnable))
+                .onclick(ctx.link().custom_callback(|_| Msg::ToggleEnable))
             })
             .with_flex_spacer()
             .with_child({
-                let loading = ctx.loading();
-                let link = ctx.link();
+                let loading = self.loading();
+                let link = ctx.link().clone();
                 Button::refresh(loading).onclick(move |_| link.send_reload())
             });
 
diff --git a/src/auth_view.rs b/src/auth_view.rs
index d6e5528..bb3960b 100644
--- a/src/auth_view.rs
+++ b/src/auth_view.rs
@@ -21,7 +21,8 @@ use pwt_macros::builder;
 
 use crate::{
     AuthEditLDAP, AuthEditOpenID, EditWindow, LoadableComponent, LoadableComponentContext,
-    LoadableComponentLink, LoadableComponentMaster,
+    LoadableComponentMaster, LoadableComponentScope, LoadableComponentScopeExt,
+    LoadableComponentState,
 };
 
 use crate::common_api_types::BasicRealmInfo;
@@ -76,17 +77,20 @@ pub enum ViewState {
 }
 
 pub enum Msg {
-    Redraw,
     Edit,
     Remove,
     Sync,
 }
+
 #[doc(hidden)]
 pub struct ProxmoxAuthView {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<BasicRealmInfo>,
 }
 
+crate::impl_deref_mut_property!(ProxmoxAuthView, state, LoadableComponentState<ViewState>);
+
 async fn delete_item(base_url: AttrValue, realm: AttrValue) -> Result<(), Error> {
     let url = format!("{base_url}/{}", percent_encode_component(&realm));
     crate::http_delete(&url, None).await?;
@@ -95,7 +99,7 @@ async fn delete_item(base_url: AttrValue, realm: AttrValue) -> Result<(), Error>
 
 async fn sync_realm(
     form_ctx: FormContext,
-    link: LoadableComponentLink<ProxmoxAuthView>,
+    link: LoadableComponentScope<ProxmoxAuthView>,
     url: impl Into<String>,
 ) -> Result<(), Error> {
     let mut data = form_ctx.get_submit_data();
@@ -178,8 +182,15 @@ impl LoadableComponent for ProxmoxAuthView {
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
         let store = Store::new();
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
-        Self { store, selection }
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
+        Self {
+            state: LoadableComponentState::new(),
+            store,
+            selection,
+        }
     }
 
     fn load(
@@ -201,7 +212,6 @@ impl LoadableComponent for ProxmoxAuthView {
         let props = ctx.props();
 
         match msg {
-            Msg::Redraw => true,
             Msg::Remove => {
                 let Some(info) = self.get_selected_record() else {
                     return true;
@@ -218,7 +228,7 @@ impl LoadableComponent for ProxmoxAuthView {
                     return true;
                 };
 
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 link.clone().spawn(async move {
                     if let Err(err) = delete_item(base_url, info.realm.into()).await {
                         link.show_error(tr!("Unable to delete item"), err, true);
@@ -319,17 +329,17 @@ impl LoadableComponent for ProxmoxAuthView {
             .with_child(
                 Button::new(tr!("Edit"))
                     .disabled(edit_disabled)
-                    .onclick(ctx.link().callback(|_| Msg::Edit)),
+                    .onclick(ctx.link().custom_callback(|_| Msg::Edit)),
             )
             .with_child(
                 Button::new(tr!("Remove"))
                     .disabled(remove_disabled)
-                    .onclick(ctx.link().callback(|_| Msg::Remove)),
+                    .onclick(ctx.link().custom_callback(|_| Msg::Remove)),
             )
             .with_child(
                 Button::new(tr!("Sync"))
                     .disabled(sync_disabled)
-                    .onclick(ctx.link().callback(|_| Msg::Sync)),
+                    .onclick(ctx.link().custom_callback(|_| Msg::Sync)),
             );
 
         Some(toolbar.into())
@@ -341,8 +351,8 @@ impl LoadableComponent for ProxmoxAuthView {
             .selection(self.selection.clone())
             .class("pwt-flex-fit")
             .on_row_dblclick({
-                let link = ctx.link();
-                move |_: &mut _| link.send_message(Msg::Edit)
+                let link = ctx.link().clone();
+                move |_: &mut _| link.send_custom_message(Msg::Edit)
             })
             .into()
     }
@@ -398,7 +408,7 @@ impl LoadableComponent for ProxmoxAuthView {
                     .into(),
             ),
             ViewState::Sync(realm) => {
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 let url = format!(
                     "{}/{}/sync",
                     ctx.props().base_url,
diff --git a/src/configuration/network_view.rs b/src/configuration/network_view.rs
index d7d519c..62b5e2e 100644
--- a/src/configuration/network_view.rs
+++ b/src/configuration/network_view.rs
@@ -12,7 +12,10 @@ use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
 use pwt::widget::menu::{Menu, MenuButton, MenuItem};
 use pwt::widget::{Button, Column, Container, SplitPane, Toolbar};
 
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster, TaskProgress};
+use crate::{
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, LoadableComponentState, TaskProgress,
+};
 use proxmox_client::ApiResponseData;
 
 use crate::percent_encoding::percent_encode_component;
@@ -66,12 +69,15 @@ impl NetworkView {
 
 #[doc(hidden)]
 pub struct ProxmoxNetworkView {
+    state: LoadableComponentState<ViewState>,
     columns: Rc<Vec<DataTableHeader<Interface>>>,
     store: Store<Interface>,
     changes: String,
     selection: Selection,
 }
 
+crate::impl_deref_mut_property!(ProxmoxNetworkView, state, LoadableComponentState<ViewState>);
+
 #[derive(PartialEq)]
 pub enum ViewState {
     AddBridge,
@@ -81,7 +87,6 @@ pub enum ViewState {
 }
 
 pub enum Msg {
-    SelectionChange,
     RemoveItem,
     Changes(String),
     RevertChanges,
@@ -119,19 +124,23 @@ impl LoadableComponent for ProxmoxNetworkView {
         ctx: &LoadableComponentContext<Self>,
     ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
         let store = self.store.clone();
-        let link = ctx.link();
+        let link = ctx.link().clone();
         Box::pin(async move {
             let (data, changes) = load_interfaces().await?;
             store.write().set_data(data);
-            link.send_message(Msg::Changes(changes));
+            link.send_custom_message(Msg::Changes(changes));
             Ok(())
         })
     }
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
         let store = Store::with_extract_key(|record: &Interface| Key::from(record.name.as_str()));
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         Self {
+            state: LoadableComponentState::new(),
             store,
             selection,
             changes: String::new(),
@@ -141,10 +150,9 @@ impl LoadableComponent for ProxmoxNetworkView {
 
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::SelectionChange => true,
             Msg::RemoveItem => {
                 if let Some(key) = self.selection.selected_key() {
-                    let link = ctx.link();
+                    let link = ctx.link().clone();
                     link.clone().spawn(async move {
                         if let Err(err) = delete_interface(key).await {
                             link.show_error(tr!("Unable to delete item"), err, true);
@@ -159,8 +167,8 @@ impl LoadableComponent for ProxmoxNetworkView {
                 true
             }
             Msg::RevertChanges => {
-                let link = ctx.link();
-                link.clone().spawn(async move {
+                let link = ctx.link().clone();
+                self.spawn(async move {
                     if let Err(err) = revert_changes().await {
                         link.show_error(tr!("Unable to revert changes"), err, true);
                     }
@@ -169,7 +177,7 @@ impl LoadableComponent for ProxmoxNetworkView {
                 false
             }
             Msg::ApplyChanges => {
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 link.clone().spawn(async move {
                     match apply_changes().await {
                         Err(err) => {
@@ -219,7 +227,7 @@ impl LoadableComponent for ProxmoxNetworkView {
             .with_child(
                 Button::new(tr!("Revert"))
                     .disabled(no_changes)
-                    .onclick(link.callback(|_| Msg::RevertChanges)),
+                    .onclick(link.custom_callback(|_| Msg::RevertChanges)),
             )
             .with_child(
                 Button::new(tr!("Edit"))
@@ -229,18 +237,18 @@ impl LoadableComponent for ProxmoxNetworkView {
             .with_child(
                 Button::new(tr!("Remove"))
                     .disabled(disabled)
-                    .onclick(link.callback(|_| Msg::RemoveItem)),
+                    .onclick(link.custom_callback(|_| Msg::RemoveItem)),
             )
             .with_spacer()
             .with_child(
                 Button::new(tr!("Apply Configuration"))
                     .disabled(no_changes)
-                    .onclick(link.callback(|_| Msg::ApplyChanges)),
+                    .onclick(link.custom_callback(|_| Msg::ApplyChanges)),
             )
             .with_flex_spacer()
             .with_child({
-                let loading = ctx.loading();
-                let link = ctx.link();
+                let loading = self.loading();
+                let link = ctx.link().clone();
                 Button::refresh(loading).onclick(move |_| link.send_reload())
             });
 
@@ -248,7 +256,7 @@ impl LoadableComponent for ProxmoxNetworkView {
     }
 
     fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
-        let link = ctx.link();
+        let link = ctx.link().clone();
 
         let table = DataTable::new(Rc::clone(&self.columns), self.store.clone())
             .class("pwt-flex-fit")
diff --git a/src/configuration/pve/lxc_network_panel.rs b/src/configuration/pve/lxc_network_panel.rs
index 953edba..3527b1e 100644
--- a/src/configuration/pve/lxc_network_panel.rs
+++ b/src/configuration/pve/lxc_network_panel.rs
@@ -23,9 +23,12 @@ use crate::form::pve::lxc_network_property;
 use crate::form::typed_load;
 use crate::{
     configuration::guest_config_url, form::pve::PveGuestType, LoadableComponent,
-    LoadableComponentContext,
+    LoadableComponentContext, LoadableComponentScopeExt,
+};
+use crate::{
+    http_put, impl_deref_mut_property, ConfirmButton, LoadableComponentMaster,
+    LoadableComponentState, PropertyEditDialog,
 };
-use crate::{http_put, ConfirmButton, LoadableComponentMaster, PropertyEditDialog};
 
 #[derive(Clone, PartialEq, Properties)]
 #[builder]
@@ -82,21 +85,23 @@ pub enum ViewState {
 }
 
 pub enum Msg {
-    Redraw,
     SelectionChange,
     Remove(Key),
 }
 
 pub struct LxcNetworkComp {
+    state: LoadableComponentState<ViewState>,
     columns: Rc<Vec<DataTableHeader<NetworkEntry>>>,
     store: Store<NetworkEntry>,
     selection: Selection,
 }
 
+impl_deref_mut_property!(LxcNetworkComp, state, LoadableComponentState<ViewState>);
+
 impl LxcNetworkComp {
     fn edit_dialog(&self, ctx: &LoadableComponentContext<Self>, name: Option<String>) -> Html {
         let props = ctx.props();
-        let link = ctx.link();
+        let link = ctx.link().clone();
 
         let property = lxc_network_property(
             Some(props.node.clone()),
@@ -156,12 +161,17 @@ impl LoadableComponent for LxcNetworkComp {
     }
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
-        let store = Store::new().on_change(ctx.link().callback(|_| Msg::Redraw));
+        let selection =
+            Selection::new().on_select(ctx.link().custom_callback(|_| Msg::SelectionChange));
+        let store = Store::new().on_change({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
 
         ctx.link().repeated_load(3000);
 
         Self {
+            state: LoadableComponentState::new(),
             store,
             selection,
             columns: columns(),
@@ -170,15 +180,14 @@ impl LoadableComponent for LxcNetworkComp {
 
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         let props = ctx.props();
-        let link = ctx.link();
+        let link = ctx.link().clone();
         match msg {
             Msg::SelectionChange => true,
-            Msg::Redraw => true,
             Msg::Remove(key) => {
                 let url =
                     guest_config_url(props.vmid, &props.node, &props.remote, PveGuestType::Lxc);
 
-                link.clone().spawn(async move {
+                self.spawn(async move {
                     let param = json!({ "delete": [ key.to_string() ]});
                     let result: Result<(), _> = crate::http_put(&url, Some(param)).await;
                     if let Err(err) = result {
@@ -238,7 +247,7 @@ impl LoadableComponent for LxcNetworkComp {
                         let key = selected_key.clone();
                         move |_| {
                             if let Some(key) = &key {
-                                link.send_message(Msg::Remove(key.clone()))
+                                link.send_custom_message(Msg::Remove(key.clone()))
                             }
                         }
                     })
@@ -259,7 +268,7 @@ impl LoadableComponent for LxcNetworkComp {
     fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
         let props = ctx.props();
         let readonly = props.readonly;
-        let link = ctx.link();
+        let link = ctx.link().clone();
 
         if props.mobile {
             let mut tiles = Vec::new();
diff --git a/src/lib.rs b/src/lib.rs
index 6df8a32..f4761a2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -88,7 +88,8 @@ pub mod layout;
 
 mod loadable_component;
 pub use loadable_component::{
-    LoadableComponent, LoadableComponentContext, LoadableComponentLink, LoadableComponentMaster,
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScope,
+    LoadableComponentScopeExt, LoadableComponentState,
 };
 
 mod node_info;
@@ -336,3 +337,33 @@ pub fn available_language_list() -> Vec<LanguageInfo> {
         ),
     ]
 }
+
+#[cfg(doc)]
+use std::ops::{Deref, DerefMut};
+
+/// Implement [Deref] to a structure member.
+#[macro_export]
+macro_rules! impl_deref_property {
+    ($ty:ty, $property_name:ident, $property_type:ty) => {
+        impl std::ops::Deref for $ty {
+            type Target = $property_type;
+
+            fn deref(&self) -> &Self::Target {
+                &self.$property_name
+            }
+        }
+    };
+}
+
+/// Implement [DerefMut] to a structure member.
+#[macro_export]
+macro_rules! impl_deref_mut_property {
+    ($ty:ty, $property_name:ident, $property_type:ty) => {
+        $crate::impl_deref_property!($ty, $property_name, $property_type);
+        impl std::ops::DerefMut for $ty {
+            fn deref_mut(&mut self) -> &mut Self::Target {
+                &mut self.$property_name
+            }
+        }
+    };
+}
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index f0e28a9..43cb7a9 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -1,163 +1,313 @@
-use anyhow::Error;
-use serde_json::Value;
 use std::future::Future;
+use std::ops::DerefMut;
 use std::pin::Pin;
-use yew_router::scope_ext::RouterScopeExt;
 
+use anyhow::Error;
 use gloo_timers::callback::Timeout;
 
+use serde_json::Value;
 use yew::html::Scope;
 
 use pwt::dom::DomVisibilityObserver;
 use pwt::prelude::*;
-use pwt::state::NavigationContextExt;
 use pwt::widget::{AlertDialog, Column};
 use pwt::AsyncPool;
 
+#[cfg(doc)]
+use crate::impl_deref_mut_property;
+#[cfg(doc)]
+use pwt::widget::Dialog;
+
 use crate::{TaskProgress, TaskViewer};
 
-pub struct LoadableComponentState {
-    loading: usize,
-    last_load_error: Option<String>,
-    repeat_timespan: u32, /* 0 => no repeated loading */
-    task_base_url: Option<AttrValue>,
-}
+pub type LoadableComponentContext<L> = Context<LoadableComponentMaster<L>>;
+pub type LoadableComponentScope<L> = Scope<LoadableComponentMaster<L>>;
+
+/// Loadable Components
+///
+/// - Load data using an async function [LoadableComponent::load]
+/// - repeated load possible
+/// - pause repeated load when component is not visible (uses [DomVisibilityObserver])
+/// - display the loaded data [LoadableComponent::main_view]
+/// - display an optional toolbar [LoadableComponent::toolbar]
+/// - display any errors from failed load.
+/// - display additional dialogs depening on [LoadableComponent::ViewState]
+///
+/// The [LoadableComponentScopeExt] defines available control function on the scope.
+///
+/// The [LoadableComponentState] provides acces to load status informations and add the ability
+/// to spawn tasks.
+///
+/// ```
+/// use proxmox_yew_comp::{LoadableComponent, LoadableComponentState, LoadableComponentContext};
+/// // include the scope extension for (for `change_view`, `send_custom_message`, ...)
+/// use proxmox_yew_comp::LoadableComponentScopeExt;
+/// # use std::pin::Pin;
+/// # use std::rc::Rc;
+/// # use std::future::Future;
+/// # use pwt::prelude::*;
+/// # use proxmox_yew_comp::http_get;
+/// # use yew::virtual_dom::{VComp, VNode, Key};
+///
+/// // define the component properties
+/// #[derive(Clone, PartialEq, Properties)]
+/// pub struct MyComponent {
+///     key: Option<Key>,
+///     /* add whatever you need  */
+/// };
+///
+/// // define your view states
+/// #[derive(PartialEq)]
+/// pub enum ViewState { Add, Edit }
+///
+/// // define the component message type
+/// pub enum Msg { UpdateData(String) }
+///
+/// // define the component state
+/// pub struct MyComponentState {
+///     // you need to inlucde a LoadableComponentState
+///     state: LoadableComponentState<ViewState>,
+///     // Add any other data you need
+///     loaded_data: Option<String>,
+/// }
+///
+/// // implement DerefMut
+/// proxmox_yew_comp::impl_deref_mut_property!(
+///     MyComponentState,
+///     state,
+///     LoadableComponentState<ViewState>
+/// );
+///
+/// impl LoadableComponent for MyComponentState {
+///     type Properties = MyComponent;
+///     type Message = Msg; // component message type
+///     type ViewState = ViewState;
+///
+///     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+///         Self {
+///             state: LoadableComponentState::new(),
+///             loaded_data: None,
+///         }
+///     }
+///
+///     fn load(
+///         &self,
+///         ctx: &LoadableComponentContext<Self>,
+///     ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+///         let link = ctx.link().clone();
+///         Box::pin(async move {
+///             let data = http_get("/something", None).await?; // load something here
+///             link.send_custom_message(Msg::UpdateData(data));
+///             Ok(())
+///         })
+///     }
+///
+///     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+///         match msg {
+///             Msg::UpdateData(data) => self.loaded_data = Some(data),
+///         }
+///         true
+///     }
+///
+///     fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+///         let text: String  = if let Some(data) = &self.loaded_data {
+///             data.clone()
+///         } else {
+///             "no data".into()
+///         };
+///         html!{text}
+///     }
+/// }
+///
+/// // add ability to generate the yew Component (provided by [LoadableComponentMaster])
+/// use proxmox_yew_comp::LoadableComponentMaster;
+/// impl From<MyComponent> for VNode {
+///     fn from(props: MyComponent) -> VNode {
+///         let key =  props.key.clone();
+///         let comp = VComp::new::<LoadableComponentMaster<MyComponentState>>(Rc::new(props), key);
+///         VNode::from(comp)
+///     }
+/// }
+///
+/// ```
+pub trait LoadableComponent:
+    Sized + DerefMut<Target = LoadableComponentState<Self::ViewState>> + 'static
+{
+    /// The yew component properties.
+    type Properties: Properties;
+    /// The yew component message type.
+    type Message: 'static;
+    /// The view state
+    ///
+    /// The view state of the component can be changed with [LoadableComponentScopeExt::change_view].
+    /// The value is then passed to the [LoadableComponent::dialog_view] function which can render
+    /// different dialogs.
+    type ViewState: 'static + PartialEq;
 
-pub struct LoadableComponentContext<'a, L: LoadableComponent + Sized + 'static> {
-    ctx: &'a Context<LoadableComponentMaster<L>>,
-    comp_state: &'a LoadableComponentState,
-}
+    /// Create a new instance
+    fn create(ctx: &LoadableComponentContext<Self>) -> Self;
+
+    /// Async Load
+    fn load(
+        &self,
+        ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
 
-impl<L: LoadableComponent + Sized> LoadableComponentContext<'_, L> {
-    pub fn props(&self) -> &L::Properties {
-        self.ctx.props()
+    /// Yew component update function (see [Component::update])
+    #[allow(unused_variables)]
+    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+        true
     }
-    pub fn link(&self) -> LoadableComponentLink<L> {
-        LoadableComponentLink {
-            link: self.ctx.link().clone(),
-        }
+
+    /// Yew component changed function (see [Component::changed])
+    #[allow(unused_variables)]
+    fn changed(
+        &mut self,
+        ctx: &LoadableComponentContext<Self>,
+        _old_props: &Self::Properties,
+    ) -> bool {
+        true
     }
-    pub fn loading(&self) -> bool {
-        self.comp_state.loading > 0
+
+    /// Optional toolbar
+    #[allow(unused_variables)]
+    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+        None
     }
 
-    pub fn last_load_errors(&self) -> Option<&str> {
-        self.comp_state.last_load_error.as_deref()
+    /// Main view (see [Component::view])
+    ///
+    /// The difference is that we render the result into a [Column], with an optional
+    /// toolbar on the top.
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html;
+
+    /// ViewState dependent dialogs
+    ///
+    /// The result is rendered below the main view. Usually some kind of [Dialog] window.
+    ///
+    /// The view state can be changed with `link.change_view(..)` and `link.change_view_callback(...)`.
+    #[allow(unused_variables)]
+    fn dialog_view(
+        &self,
+        ctx: &LoadableComponentContext<Self>,
+        view_state: &Self::ViewState,
+    ) -> Option<Html> {
+        None
     }
+
+    /// Yew component rendered function (see [Component::rendered])
+    #[allow(unused_variables)]
+    fn rendered(&mut self, ctx: &LoadableComponentContext<Self>, first_render: bool) {}
 }
 
-pub struct LoadableComponentLink<L: LoadableComponent + Sized + 'static> {
-    link: Scope<LoadableComponentMaster<L>>,
+#[derive(Clone, PartialEq)]
+pub enum ViewState<V: PartialEq> {
+    Main,
+    /// Show the dialog returned by dialog_view
+    Dialog(V),
+    /// Show proxmox api task status
+    TaskProgress(String),
+    /// Show proxmox api task log
+    TaskLog(String, Option<i64>),
+    /// Show an error message dialog
+    Error(String, String, /* reload_on_close */ bool),
 }
 
-impl<L: LoadableComponent + Sized> Clone for LoadableComponentLink<L> {
-    fn clone(&self) -> Self {
-        Self {
-            link: self.link.clone(),
-        }
-    }
+pub enum Msg<M, V: PartialEq> {
+    DataChange,
+    Load,
+    RepeatedLoad(u32 /* repeat time in miliseconds */),
+    LoadResult(Result<(), Error>),
+    ChangeView(/*reload*/ bool, ViewState<V>),
+    ChildMessage(M),
+    Visible(bool),
+    Spawn(Pin<Box<dyn Future<Output = ()>>>),
 }
 
-impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
-    pub fn send_message(&self, msg: impl Into<L::Message>) {
-        let msg = msg.into();
-        self.link.send_message(Msg::ChildMessage(msg));
-    }
+pub trait LoadableComponentScopeExt<M, V: PartialEq> {
+    fn send_custom_message(&self, msg: M);
+    fn send_reload(&self);
+    fn send_redraw(&self);
+    fn repeated_load(&self, miliseconds: u32);
 
-    pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
+    fn change_view(&self, child_view_state: Option<V>);
+    fn custom_callback<F, IN>(&self, function: F) -> Callback<IN>
     where
-        M: Into<L::Message>,
-        F: Fn(IN) -> M + 'static,
-    {
-        self.link.callback(move |p: IN| {
-            let msg: L::Message = function(p).into();
-            Msg::ChildMessage(msg)
-        })
-    }
+        M: Into<M>,
+        F: Fn(IN) -> M + 'static;
 
-    /// Spawn a future using the [AsyncPool] from the component.
-    pub fn spawn<Fut>(&self, future: Fut)
+    fn change_view_callback<C, F, IN>(&self, function: F) -> Callback<IN>
     where
-        Fut: Future<Output = ()> + 'static,
-    {
-        self.link.send_message(Msg::Spawn(Box::pin(future)));
-    }
+        C: Into<Option<V>>,
+        F: Fn(IN) -> C + 'static;
 
-    pub fn send_future<Fut, M>(&self, future: Fut)
+    /// Spawn a future using the [AsyncPool] from the component.
+    fn spawn<Fut>(&self, future: Fut)
     where
-        M: Into<L::Message>,
-        Fut: Future<Output = M> + 'static,
-    {
-        let link = self.link.clone();
-        self.link.send_message(Msg::Spawn(Box::pin(async move {
-            let message: L::Message = future.await.into();
-            link.send_message(Msg::ChildMessage(message));
-        })));
-    }
+        Fut: Future<Output = ()> + 'static;
 
-    pub fn callback_future<F, Fut, IN, M>(&self, function: F) -> Callback<IN>
-    where
-        M: Into<L::Message>,
-        Fut: Future<Output = M> + 'static,
-        F: Fn(IN) -> Fut + 'static,
-    {
-        let link = self.clone();
+    fn show_error(
+        &self,
+        title: impl Into<String>,
+        msg: impl std::fmt::Display,
+        reload_on_close: bool,
+    );
 
-        let closure = move |input: IN| {
-            link.send_future(function(input));
-        };
+    fn show_task_progres(&self, task_id: impl Into<String>);
 
-        closure.into()
-    }
+    fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>);
 
-    pub fn send_reload(&self) {
-        self.link.send_message(Msg::Load)
-    }
+    fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool);
+}
 
-    pub fn repeated_load(&self, miliseconds: u32) {
-        self.link.send_message(Msg::RepeatedLoad(miliseconds));
+impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>>
+    LoadableComponentScopeExt<M, V> for Scope<LoadableComponentMaster<T>>
+{
+    fn send_custom_message(&self, msg: M) {
+        self.send_message(Msg::ChildMessage(msg));
     }
 
-    pub fn task_base_url(&self, base_url: impl Into<AttrValue>) {
-        self.link.send_message(Msg::TaskBaseUrl(base_url.into()));
+    fn send_reload(&self) {
+        self.send_message(Msg::Load);
     }
 
-    pub fn show_error(
-        &self,
-        title: impl Into<String>,
-        msg: impl std::fmt::Display,
-        reload_on_close: bool,
-    ) {
-        let view_state = ViewState::Error(title.into(), msg.to_string(), reload_on_close);
-        self.link.send_message(Msg::ChangeView(false, view_state));
+    fn send_redraw(&self) {
+        self.send_message(Msg::DataChange);
     }
 
-    pub fn show_task_progres(&self, task_id: impl Into<String>) {
-        let view_state = ViewState::TaskProgress(task_id.into());
-        self.link.send_message(Msg::ChangeView(false, view_state));
+    fn repeated_load(&self, miliseconds: u32) {
+        self.send_message(Msg::RepeatedLoad(miliseconds));
     }
 
-    pub fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>) {
-        let view_state = ViewState::TaskLog(task_id.into(), endtime);
-        self.link.send_message(Msg::ChangeView(false, view_state));
+    fn custom_callback<F, IN>(&self, function: F) -> Callback<IN>
+    where
+        M: Into<M>,
+        F: Fn(IN) -> M + 'static,
+    {
+        let scope = self.clone();
+        let closure = move |input| {
+            let output = function(input);
+            scope.send_custom_message(output);
+        };
+        Callback::from(closure)
     }
 
-    pub fn change_view(&self, child_view_state: Option<L::ViewState>) {
+    fn change_view(&self, child_view_state: Option<V>) {
         let view_state = if let Some(child_view_state) = child_view_state {
             ViewState::Dialog(child_view_state)
         } else {
             ViewState::Main
         };
-        self.link.send_message(Msg::ChangeView(false, view_state));
+        self.send_message(Msg::ChangeView(false, view_state));
     }
 
-    pub fn change_view_callback<F, IN, M>(&self, function: F) -> Callback<IN>
+    fn change_view_callback<C, F, IN>(&self, function: F) -> Callback<IN>
     where
-        M: Into<Option<L::ViewState>>,
-        F: Fn(IN) -> M + 'static,
+        C: Into<Option<V>>,
+        F: Fn(IN) -> C + 'static,
     {
-        self.link.callback(move |p: IN| {
-            let state: Option<L::ViewState> = function(p).into();
+        self.callback(move |p: IN| {
+            let state: Option<V> = function(p).into();
             if let Some(state) = state {
                 Msg::ChangeView(true, ViewState::Dialog(state))
             } else {
@@ -166,11 +316,38 @@ impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
         })
     }
 
-    pub fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool) {
+    fn spawn<Fut>(&self, future: Fut)
+    where
+        Fut: Future<Output = ()> + 'static,
+    {
+        self.send_message(Msg::Spawn(Box::pin(future)));
+    }
+
+    fn show_error(
+        &self,
+        title: impl Into<String>,
+        msg: impl std::fmt::Display,
+        reload_on_close: bool,
+    ) {
+        let view_state = ViewState::Error(title.into(), msg.to_string(), reload_on_close);
+        self.send_message(Msg::ChangeView(false, view_state));
+    }
+
+    fn show_task_progres(&self, task_id: impl Into<String>) {
+        let view_state = ViewState::TaskProgress(task_id.into());
+        self.send_message(Msg::ChangeView(false, view_state));
+    }
+
+    fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>) {
+        let view_state = ViewState::TaskLog(task_id.into(), endtime);
+        self.send_message(Msg::ChangeView(false, view_state));
+    }
+
+    fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool) {
         let command_path: String = command_path.into();
         let link = self.clone();
         let command_future = crate::http_post::<String>(command_path, data);
-        self.link.send_message(Msg::Spawn(Box::pin(async move {
+        self.send_message(Msg::Spawn(Box::pin(async move {
             match command_future.await {
                 Ok(task_id) => {
                     link.send_reload();
@@ -187,140 +364,86 @@ impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
             }
         })));
     }
-
-    /// Returns the original [`yew::html::Scope`] of the master component.
-    ///
-    /// This is useful when e.g. trying to get an higher level context
-    pub fn yew_link(&self) -> &Scope<LoadableComponentMaster<L>> {
-        &self.link
-    }
 }
 
-impl<L: LoadableComponent + Sized> RouterScopeExt for LoadableComponentLink<L> {
-    fn navigator(&self) -> Option<yew_router::prelude::Navigator> {
-        self.link.navigator()
-    }
-
-    fn location(&self) -> Option<yew_router::prelude::Location> {
-        self.link.location()
-    }
-
-    fn route<R>(&self) -> Option<R>
-    where
-        R: yew_router::Routable + 'static,
-    {
-        self.link.route()
-    }
-
-    fn add_location_listener(
-        &self,
-        cb: Callback<yew_router::prelude::Location>,
-    ) -> Option<yew_router::prelude::LocationHandle> {
-        self.link.add_location_listener(cb)
-    }
-
-    fn add_navigator_listener(
-        &self,
-        cb: Callback<yew_router::prelude::Navigator>,
-    ) -> Option<yew_router::prelude::NavigatorHandle> {
-        self.link.add_navigator_listener(cb)
-    }
+/// Base state for [LoadableComponent] implementations.
+///
+/// The struct provides the following features:
+///
+/// - access to load status informations
+/// - setup task base url
+/// - spawn tasks: includes an [AsyncPool], so that any [LoadableComponent] can spawn
+///   task via this pool.
+///
+/// The [LoadableComponent] trait requires access to this struct via [DerefMut]. The
+/// macro [impl_deref_mut_property] provides an easy way to
+/// implement that.
+///
+/// ```
+/// use proxmox_yew_comp::LoadableComponentState;
+/// # #[derive(PartialEq)]
+/// # pub enum ViewState { Add, Edit }
+/// pub struct MyComponentState {
+///     state: LoadableComponentState<ViewState>,
+///     // Add any other data you need
+///     other_data: String,
+/// }
+/// // implement DerefMut
+/// proxmox_yew_comp::impl_deref_mut_property!(MyComponentState, state, LoadableComponentState<ViewState>);
+/// ```
+pub struct LoadableComponentState<V: PartialEq> {
+    loading: usize,
+    last_load_error: Option<String>,
+    repeat_timespan: u32, /* 0 => no repeated loading */
+    task_base_url: Option<AttrValue>,
+    view_state: ViewState<V>,
+    reload_timeout: Option<Timeout>,
+    visible: bool,
+    visibitlity_observer: Option<DomVisibilityObserver>,
+    node_ref: NodeRef,
+    async_pool: AsyncPool,
 }
 
-impl<L: LoadableComponent + Sized> NavigationContextExt for LoadableComponentLink<L> {
-    fn nav_context(&self) -> Option<pwt::state::NavigationContext> {
-        self.link.nav_context()
-    }
-
-    fn full_path(&self) -> Option<String> {
-        self.link.full_path()
-    }
-
-    fn push_relative_route(&self, path: &str) {
-        self.link.push_relative_route(path)
+impl<V: PartialEq> LoadableComponentState<V> {
+    pub fn new() -> Self {
+        Self {
+            loading: 0,
+            last_load_error: None,
+            repeat_timespan: 0,
+            task_base_url: None,
+            view_state: ViewState::Main,
+            reload_timeout: None,
+            visible: true,
+            visibitlity_observer: None,
+            node_ref: NodeRef::default(),
+            async_pool: AsyncPool::new(),
+        }
     }
-}
-
-pub trait LoadableComponent: Sized {
-    type Properties: Properties;
-    type Message: 'static;
-    type ViewState: 'static + PartialEq;
 
-    fn create(ctx: &LoadableComponentContext<Self>) -> Self;
-
-    fn load(
-        &self,
-        ctx: &LoadableComponentContext<Self>,
-    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
-
-    #[allow(unused_variables)]
-    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
-        true
+    pub fn loading(&self) -> bool {
+        self.loading > 0
     }
 
-    #[allow(unused_variables)]
-    fn changed(
-        &mut self,
-        ctx: &LoadableComponentContext<Self>,
-        _old_props: &Self::Properties,
-    ) -> bool {
-        true
+    pub fn last_load_errors(&self) -> Option<&str> {
+        self.last_load_error.as_deref()
     }
 
-    #[allow(unused_variables)]
-    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
-        None
+    pub fn set_task_base_url(&mut self, base_url: AttrValue) {
+        self.task_base_url = Some(base_url);
     }
 
-    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html;
-
-    #[allow(unused_variables)]
-    fn dialog_view(
-        &self,
-        ctx: &LoadableComponentContext<Self>,
-        view_state: &Self::ViewState,
-    ) -> Option<Html> {
-        None
+    /// Spawn a future using the [AsyncPool] from the component.
+    pub fn spawn<Fut>(&self, future: Fut)
+    where
+        Fut: Future<Output = ()> + 'static,
+    {
+        self.async_pool.spawn(future);
     }
-
-    #[allow(unused_variables)]
-    fn rendered(&mut self, ctx: &LoadableComponentContext<Self>, first_render: bool) {}
-}
-
-#[derive(Clone, PartialEq)]
-pub enum ViewState<V: PartialEq> {
-    Main,
-    /// Show the dialog returned by dialog_view
-    Dialog(V),
-    /// Show proxmox api task status
-    TaskProgress(String),
-    /// Show proxmox api task log
-    TaskLog(String, Option<i64>),
-    /// Show an error message dialog
-    Error(String, String, /* reload_on_close */ bool),
-}
-
-pub enum Msg<M, V: PartialEq> {
-    DataChange,
-    Load,
-    RepeatedLoad(u32 /* repeat time in miliseconds */),
-    LoadResult(Result<(), Error>),
-    ChangeView(/*reload*/ bool, ViewState<V>),
-    ChildMessage(M),
-    TaskBaseUrl(AttrValue),
-    Visible(bool),
-    Spawn(Pin<Box<dyn Future<Output = ()>>>),
 }
 
+#[doc(hidden)]
 pub struct LoadableComponentMaster<L: LoadableComponent> {
     state: L,
-    comp_state: LoadableComponentState,
-    view_state: ViewState<L::ViewState>,
-    reload_timeout: Option<Timeout>,
-    visible: bool,
-    visibitlity_observer: Option<DomVisibilityObserver>,
-    node_ref: NodeRef,
-    async_pool: AsyncPool,
 }
 
 impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
@@ -328,91 +451,65 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
     type Properties = L::Properties;
 
     fn create(ctx: &Context<Self>) -> Self {
-        let loading = 0;
-
-        let comp_state = LoadableComponentState {
-            loading,
-            last_load_error: None,
-            repeat_timespan: 0,
-            task_base_url: None,
-        };
-
-        let sub_context = LoadableComponentContext {
-            ctx,
-            comp_state: &comp_state,
-        };
-
         // Send Msg::Load first (before any Msg::RepeatedLoad in create), so that we
         // can avoid multiple loads at startup
         ctx.link().send_message(Msg::Load);
 
-        let state = L::create(&sub_context);
+        let mut state = L::create(ctx);
+        state.visible = true;
 
-        Self {
-            state,
-            comp_state,
-            view_state: ViewState::Main,
-            reload_timeout: None,
-            visible: true,
-            visibitlity_observer: None,
-            node_ref: NodeRef::default(),
-            async_pool: AsyncPool::new(),
-        }
+        Self { state }
     }
 
     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
         match msg {
             Msg::Spawn(future) => {
-                self.async_pool.spawn(future);
+                self.state.async_pool.spawn(future);
                 false
             }
             Msg::DataChange => true,
             Msg::Load => {
-                self.comp_state.loading += 1;
+                let load_future = self.state.load(ctx);
+                self.state.loading += 1;
                 let link = ctx.link().clone();
-                let sub_context = LoadableComponentContext {
-                    ctx,
-                    comp_state: &self.comp_state,
-                };
-                let load_future = self.state.load(&sub_context);
-                self.async_pool.spawn(async move {
+                self.state.async_pool.spawn(async move {
                     let data = load_future.await;
                     link.send_message(Msg::LoadResult(data));
                 });
                 true
             }
             Msg::RepeatedLoad(timespan) => {
-                self.comp_state.repeat_timespan = timespan;
-                self.reload_timeout = None;
-                if self.comp_state.loading == 0 {
+                self.state.repeat_timespan = timespan;
+                self.state.reload_timeout = None;
+                if self.state.loading == 0 {
                     <Self as yew::Component>::update(self, ctx, Msg::Load);
                 }
                 false
             }
             Msg::LoadResult(data) => {
-                self.comp_state.loading -= 1;
+                self.state.loading -= 1;
                 match data {
                     Ok(()) => {
-                        self.comp_state.last_load_error = None;
+                        self.state.last_load_error = None;
                     }
                     Err(err) => {
-                        let this_is_the_first_error = self.comp_state.last_load_error.is_none();
-                        self.comp_state.last_load_error = Some(err.to_string());
+                        let this_is_the_first_error = self.state.last_load_error.is_none();
+                        self.state.last_load_error = Some(err.to_string());
                         if this_is_the_first_error {
-                            self.view_state =
+                            self.state.view_state =
                                 ViewState::Error(tr!("Load failed"), err.to_string(), false);
                         }
                     }
                 }
 
-                self.reload_timeout = None;
-                if self.comp_state.loading == 0 {
+                self.state.reload_timeout = None;
+                if self.state.loading == 0 {
                     /* no outstanding loads */
-                    if self.comp_state.repeat_timespan > 0 {
+                    if self.state.repeat_timespan > 0 {
                         let link = ctx.link().clone();
-                        if self.visible {
-                            self.reload_timeout =
-                                Some(Timeout::new(self.comp_state.repeat_timespan, move || {
+                        if self.state.visible {
+                            self.state.reload_timeout =
+                                Some(Timeout::new(self.state.repeat_timespan, move || {
                                     link.send_message(Msg::Load);
                                 }));
                         }
@@ -421,7 +518,7 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
                 true
             }
             Msg::ChangeView(reload_data, view_state) => {
-                if self.view_state == view_state {
+                if self.state.view_state == view_state {
                     return false;
                 }
 
@@ -429,29 +526,21 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
                     ctx.link().send_message(Msg::Load);
                 }
 
-                self.view_state = view_state;
+                self.state.view_state = view_state;
                 true
             }
             Msg::ChildMessage(child_msg) => {
-                let sub_context = LoadableComponentContext {
-                    ctx,
-                    comp_state: &self.comp_state,
-                };
-                self.state.update(&sub_context, child_msg);
+                self.state.update(ctx, child_msg);
                 true
             }
-            Msg::TaskBaseUrl(base_url) => {
-                self.comp_state.task_base_url = Some(base_url);
-                false
-            }
             Msg::Visible(visible) => {
-                if self.visible == visible {
+                if self.state.visible == visible {
                     return false;
                 }
-                self.visible = visible;
-                if self.comp_state.loading == 0 && self.visible {
+                self.state.visible = visible;
+                if self.state.loading == 0 && self.state.visible {
                     /* no outstanding loads */
-                    if self.comp_state.loading == 0 {
+                    if self.state.loading == 0 {
                         <Self as yew::Component>::update(self, ctx, Msg::Load);
                     }
                 }
@@ -461,26 +550,16 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
     }
 
     fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
-        let sub_context = LoadableComponentContext {
-            ctx,
-            comp_state: &self.comp_state,
-        };
-
-        self.state.changed(&sub_context, _old_props)
+        self.state.changed(ctx, _old_props)
     }
 
     fn view(&self, ctx: &Context<Self>) -> Html {
-        let sub_context = LoadableComponentContext {
-            ctx,
-            comp_state: &self.comp_state,
-        };
-
-        let main_view = self.state.main_view(&sub_context);
+        let main_view = self.state.main_view(ctx);
 
         let dialog: Option<Html> =
-            match &self.view_state {
+            match &self.state.view_state {
                 ViewState::Main => None,
-                ViewState::Dialog(view_state) => self.state.dialog_view(&sub_context, view_state),
+                ViewState::Dialog(view_state) => self.state.dialog_view(ctx, view_state),
                 ViewState::Error(title, msg, reload_on_close) => {
                     let reload_on_close = *reload_on_close;
                     Some(
@@ -498,7 +577,7 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
                             .callback(move |_| Msg::ChangeView(true, ViewState::Main)),
                     );
 
-                    if let Some(base_url) = &self.comp_state.task_base_url {
+                    if let Some(base_url) = &self.state.task_base_url {
                         task_progress.set_base_url(base_url);
                     }
 
@@ -510,19 +589,19 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
                             .callback(move |_| Msg::ChangeView(true, ViewState::Main)),
                     );
 
-                    if let Some(base_url) = &self.comp_state.task_base_url {
+                    if let Some(base_url) = &self.state.task_base_url {
                         task_viewer.set_base_url(base_url);
                     }
                     Some(task_viewer.into())
                 }
             };
 
-        let toolbar = self.state.toolbar(&sub_context);
+        let toolbar = self.state.toolbar(ctx);
 
         let mut alert_msg = None;
 
         if dialog.is_none() {
-            if let Some(msg) = &self.comp_state.last_load_error {
+            if let Some(msg) = &self.state.last_load_error {
                 alert_msg = Some(pwt::widget::error_message(msg).class("pwt-border-top"));
             }
         }
@@ -533,23 +612,18 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
             .with_child(main_view)
             .with_optional_child(alert_msg)
             .with_optional_child(dialog)
-            .into_html_with_ref(self.node_ref.clone())
+            .into_html_with_ref(self.state.node_ref.clone())
     }
 
     fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
-        if self.visibitlity_observer.is_none() && self.reload_timeout.is_some() {
-            if let Some(el) = self.node_ref.cast::<web_sys::Element>() {
-                self.visibitlity_observer = Some(DomVisibilityObserver::new(
+        if self.state.visibitlity_observer.is_none() && self.state.reload_timeout.is_some() {
+            if let Some(el) = self.state.node_ref.cast::<web_sys::Element>() {
+                self.state.visibitlity_observer = Some(DomVisibilityObserver::new(
                     &el,
                     ctx.link().callback(Msg::Visible),
                 ))
             }
         }
-        let sub_context = LoadableComponentContext {
-            ctx,
-            comp_state: &self.comp_state,
-        };
-
-        self.state.rendered(&sub_context, first_render);
+        self.state.rendered(ctx, first_render);
     }
 }
diff --git a/src/node_status_panel.rs b/src/node_status_panel.rs
index 3f8946d..9249c01 100644
--- a/src/node_status_panel.rs
+++ b/src/node_status_panel.rs
@@ -17,7 +17,7 @@ use proxmox_node_status::{NodePowerCommand, NodeStatus};
 use crate::utils::copy_text_to_clipboard;
 use crate::{
     http_get, http_post, node_info, ConfirmButton, LoadableComponent, LoadableComponentContext,
-    LoadableComponentMaster,
+    LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState,
 };
 
 #[derive(Properties, Clone, PartialEq)]
@@ -53,7 +53,6 @@ enum Msg {
     Error(Error),
     Loaded(Rc<NodeStatus>),
     RebootOrShutdown(NodePowerCommand),
-    Reload,
 }
 
 #[derive(PartialEq)]
@@ -62,10 +61,17 @@ enum ViewState {
 }
 
 struct ProxmoxNodeStatusPanel {
+    state: LoadableComponentState<ViewState>,
     node_status: Option<Rc<NodeStatus>>,
     error: Option<Error>,
 }
 
+crate::impl_deref_mut_property!(
+    ProxmoxNodeStatusPanel,
+    state,
+    LoadableComponentState<ViewState>
+);
+
 impl ProxmoxNodeStatusPanel {
     fn change_power_state(&self, ctx: &LoadableComponentContext<Self>, command: NodePowerCommand) {
         let Some(url) = ctx.props().status_base_url.clone() else {
@@ -79,8 +85,8 @@ impl ProxmoxNodeStatusPanel {
             }));
 
             match http_post(url.as_str(), data).await {
-                Ok(()) => link.send_message(Msg::Reload),
-                Err(err) => link.send_message(Msg::Error(err)),
+                Ok(()) => link.send_redraw(),
+                Err(err) => link.send_custom_message(Msg::Error(err)),
             }
         });
     }
@@ -90,8 +96,8 @@ impl ProxmoxNodeStatusPanel {
         ctx: &LoadableComponentContext<Self>,
         fingerprint: &str,
     ) -> Dialog {
-        let link = ctx.link();
-        let link_button = ctx.link();
+        let link = ctx.link().clone();
+        let link_button = ctx.link().clone();
         let fingerprint = fingerprint.to_owned();
 
         Dialog::new(tr!("Fingerprint"))
@@ -135,10 +141,10 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
     type ViewState = ViewState;
     type Properties = NodeStatusPanel;
 
-    fn create(ctx: &crate::LoadableComponentContext<Self>) -> Self {
+    fn create(ctx: &LoadableComponentContext<Self>) -> Self {
         ctx.link().repeated_load(5000);
-
         Self {
+            state: LoadableComponentState::new(),
             node_status: None,
             error: None,
         }
@@ -146,7 +152,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
 
     fn load(
         &self,
-        ctx: &crate::LoadableComponentContext<Self>,
+        ctx: &LoadableComponentContext<Self>,
     ) -> std::pin::Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
         let url = ctx.props().status_base_url.clone();
         let link = ctx.link().clone();
@@ -154,15 +160,15 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
         Box::pin(async move {
             if let Some(url) = url {
                 match http_get(url.as_str(), None).await {
-                    Ok(res) => link.send_message(Msg::Loaded(Rc::new(res))),
-                    Err(err) => link.send_message(Msg::Error(err)),
+                    Ok(res) => link.send_custom_message(Msg::Loaded(Rc::new(res))),
+                    Err(err) => link.send_custom_message(Msg::Error(err)),
                 }
             }
             Ok(())
         })
     }
 
-    fn update(&mut self, ctx: &crate::LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
             Msg::Error(err) => {
                 self.error = Some(err);
@@ -177,7 +183,6 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
                 self.change_power_state(ctx, command);
                 false
             }
-            Msg::Reload => true,
         }
     }
 
@@ -197,7 +202,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
         None
     }
 
-    fn main_view(&self, ctx: &crate::LoadableComponentContext<Self>) -> Html {
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
         let status = self
             .node_status
             .as_ref()
@@ -223,7 +228,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
                     .confirm_message(tr!("Are you sure you want to reboot the node?"))
                     .on_activate(
                         ctx.link()
-                            .callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Reboot)),
+                            .custom_callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Reboot)),
                     )
                     .icon_class("fa fa-undo"),
             );
@@ -232,7 +237,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
                     .confirm_message(tr!("Are you sure you want to shut down the node?"))
                     .on_activate(
                         ctx.link()
-                            .callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Shutdown)),
+                            .custom_callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Shutdown)),
                     )
                     .icon_class("fa fa-power-off"),
             );
diff --git a/src/notes_view.rs b/src/notes_view.rs
index 6b0a379..9c10c00 100644
--- a/src/notes_view.rs
+++ b/src/notes_view.rs
@@ -17,7 +17,7 @@ use proxmox_client::ApiResponseData;
 
 use crate::{
     ApiLoadCallback, EditWindow, LoadableComponent, LoadableComponentContext,
-    LoadableComponentMaster, Markdown,
+    LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, Markdown,
 };
 
 #[derive(Serialize, Deserialize, Clone, PartialEq)]
@@ -107,10 +107,13 @@ pub enum Msg {
 
 #[doc(hidden)]
 pub struct ProxmoxNotesView {
+    state: LoadableComponentState<ViewState>,
     data: NotesWithDigest,
     edit_window_loader: ApiLoadCallback<Value>,
 }
 
+crate::impl_deref_mut_property!(ProxmoxNotesView, state, LoadableComponentState<ViewState>);
+
 impl LoadableComponent for ProxmoxNotesView {
     type Properties = NotesView;
     type Message = Msg;
@@ -130,6 +133,7 @@ impl LoadableComponent for ProxmoxNotesView {
             }
         });
         Self {
+            state: LoadableComponentState::new(),
             data: NotesWithDigest {
                 notes: String::new(),
                 digest: None,
@@ -143,12 +147,12 @@ impl LoadableComponent for ProxmoxNotesView {
         ctx: &LoadableComponentContext<Self>,
     ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
         let loader = ctx.props().loader.clone();
-        let link = ctx.link();
+        let link = ctx.link().clone();
         Box::pin(async move {
             let resp = loader.apply().await?;
             let notes = resp.data;
             let digest = resp.attribs.get("digest").cloned();
-            link.send_message(Msg::Load(NotesWithDigest { notes, digest }));
+            link.send_custom_message(Msg::Load(NotesWithDigest { notes, digest }));
             Ok(())
         })
     }
@@ -169,10 +173,8 @@ impl LoadableComponent for ProxmoxNotesView {
                 .class("pwt-overflow-hidden")
                 .class("pwt-border-bottom")
                 .with_child(
-                    Button::new(tr!("Edit")).on_activate(
-                        ctx.link()
-                            .change_view_callback(|_| Some(ViewState::EditNotes)),
-                    ),
+                    Button::new(tr!("Edit"))
+                        .on_activate(ctx.link().change_view_callback(|_| ViewState::EditNotes)),
                 )
                 .into(),
         )
diff --git a/src/object_grid.rs b/src/object_grid.rs
index a6313d0..e2321d0 100644
--- a/src/object_grid.rs
+++ b/src/object_grid.rs
@@ -8,7 +8,7 @@ use indexmap::IndexMap;
 use proxmox_client::ApiResponseData;
 use serde_json::Value;
 
-use yew::html::IntoPropValue;
+use yew::html::{IntoPropValue, Scope};
 use yew::prelude::*;
 use yew::virtual_dom::{Key, VComp, VNode};
 
@@ -20,8 +20,11 @@ use pwt::widget::form::FormContext;
 use pwt::widget::{Button, Toolbar};
 
 use crate::{ApiLoadCallback, IntoApiLoadCallback};
-use crate::{EditWindow, KVGrid, KVGridRow, LoadableComponentLink};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{EditWindow, KVGrid, KVGridRow};
+use crate::{
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, LoadableComponentState,
+};
 
 use pwt_macros::builder;
 
@@ -257,6 +260,8 @@ pub enum ViewState {
 
 #[doc(hidden)]
 pub struct PwtObjectGrid {
+    state: LoadableComponentState<ViewState>,
+
     selection: Option<Key>,
     data: Rc<Value>,
 
@@ -266,6 +271,8 @@ pub struct PwtObjectGrid {
     controller_observer: Option<SharedStateObserver<Vec<ObjectGridCommand>>>,
 }
 
+crate::impl_deref_mut_property!(PwtObjectGrid, state, LoadableComponentState<ViewState>);
+
 impl PwtObjectGrid {
     fn update_rows(&mut self, props: &ObjectGrid) {
         let mut rows = Vec::new();
@@ -280,7 +287,11 @@ impl PwtObjectGrid {
         self.rows = Rc::new(rows);
     }
 
-    fn update_controller(&mut self, props: &ObjectGrid, link: LoadableComponentLink<Self>) {
+    fn update_controller(
+        &mut self,
+        props: &ObjectGrid,
+        link: Scope<LoadableComponentMaster<Self>>,
+    ) {
         match &props.controller {
             None => self.controller_observer = None,
             Some(controller) => {
@@ -292,7 +303,7 @@ impl PwtObjectGrid {
                             guard.split_off(0)
                         };
                         for command in commands {
-                            link.send_message(Msg::ControllerCommand(command));
+                            link.send_custom_message(Msg::ControllerCommand(command));
                         }
                     },
                 ));
@@ -335,6 +346,7 @@ impl LoadableComponent for PwtObjectGrid {
         ctx.link().repeated_load(3000);
 
         let mut me = Self {
+            state: LoadableComponentState::new(),
             data: Rc::new(Value::Null),
             rows: Rc::new(Vec::new()),
             editors: IndexMap::new(),
@@ -342,7 +354,7 @@ impl LoadableComponent for PwtObjectGrid {
             controller_observer: None,
         };
         me.update_rows(props);
-        me.update_controller(props, ctx.link());
+        me.update_controller(props, ctx.link().clone());
         me
     }
 
@@ -352,12 +364,12 @@ impl LoadableComponent for PwtObjectGrid {
     ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
         let props = ctx.props();
         let loader = props.loader.clone();
-        let link = ctx.link();
+        let link = ctx.link().clone();
 
         Box::pin(async move {
             if let Some(loader) = &loader {
                 let api_resp: ApiResponseData<Value> = loader.apply().await?;
-                link.send_message(Msg::DataChange(api_resp));
+                link.send_custom_message(Msg::DataChange(api_resp));
             }
             Ok(())
         })
@@ -418,7 +430,7 @@ impl LoadableComponent for PwtObjectGrid {
         let mut toolbar = Toolbar::new()
             .border_bottom(true)
             .with_child(Button::new("Edit").disabled(disable_edit).onclick({
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 move |_| {
                     link.change_view(Some(ViewState::EditObject));
                 }
@@ -437,11 +449,11 @@ impl LoadableComponent for PwtObjectGrid {
             .class("pwt-flex-fit")
             .rows(Rc::clone(&self.rows))
             .data(self.data.clone())
-            .on_select(ctx.link().callback(Msg::Select))
+            .on_select(ctx.link().custom_callback(Msg::Select))
             .on_row_dblclick({
                 let link = ctx.link().clone();
                 move |event: &mut DataTableMouseEvent| {
-                    link.send_message(Msg::Select(Some(event.record_key.clone())));
+                    link.send_custom_message(Msg::Select(Some(event.record_key.clone())));
                     link.change_view(Some(ViewState::EditObject));
                 }
             })
@@ -449,7 +461,7 @@ impl LoadableComponent for PwtObjectGrid {
                 let link = ctx.link().clone();
                 move |event: &mut DataTableKeyboardEvent| {
                     if event.key() == " " {
-                        link.send_message(Msg::Select(Some(event.record_key.clone())));
+                        link.send_custom_message(Msg::Select(Some(event.record_key.clone())));
                         link.change_view(Some(ViewState::EditObject));
                     }
                 }
diff --git a/src/permission_panel.rs b/src/permission_panel.rs
index 3ed07b1..e7d2c27 100644
--- a/src/permission_panel.rs
+++ b/src/permission_panel.rs
@@ -18,7 +18,7 @@ use pwt::widget::data_table::{
 
 use pwt_macros::builder;
 
-use crate::{http_get, LoadableComponent, LoadableComponentMaster};
+use crate::{http_get, LoadableComponent, LoadableComponentMaster, LoadableComponentState};
 
 #[derive(Clone, PartialEq, Properties)]
 #[builder]
@@ -46,10 +46,13 @@ impl PermissionPanel {
     }
 }
 pub struct ProxmoxPermissionPanel {
+    state: LoadableComponentState<()>,
     store: TreeStore<PermissionInfo>,
     columns: Rc<Vec<DataTableHeader<PermissionInfo>>>,
 }
 
+crate::impl_deref_mut_property!(ProxmoxPermissionPanel, state, LoadableComponentState<()>);
+
 #[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
 enum PermissionInfo {
     Permission(String, String, bool),
@@ -113,15 +116,19 @@ impl LoadableComponent for ProxmoxPermissionPanel {
     type Message = ();
     type ViewState = ();
 
-    fn create(_ctx: &crate::LoadableComponentContext<Self>) -> Self {
+    fn create(_ctx: &Context<LoadableComponentMaster<Self>>) -> Self {
         let store = TreeStore::new();
         let columns = Rc::new(columns(&store));
-        Self { store, columns }
+        Self {
+            state: LoadableComponentState::new(),
+            store,
+            columns,
+        }
     }
 
     fn load(
         &self,
-        ctx: &crate::LoadableComponentContext<Self>,
+        ctx: &Context<LoadableComponentMaster<Self>>,
     ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
         let props = ctx.props();
         let base_url = props.base_url.clone();
@@ -151,7 +158,7 @@ impl LoadableComponent for ProxmoxPermissionPanel {
         })
     }
 
-    fn main_view(&self, _ctx: &crate::LoadableComponentContext<Self>) -> Html {
+    fn main_view(&self, _ctx: &Context<LoadableComponentMaster<Self>>) -> Html {
         DataTable::new(Rc::clone(&self.columns), self.store.clone())
             .class("pwt-flex-fit")
             .into()
diff --git a/src/subscription_panel.rs b/src/subscription_panel.rs
index 9f8e65e..1b6a8e4 100644
--- a/src/subscription_panel.rs
+++ b/src/subscription_panel.rs
@@ -12,8 +12,13 @@ use pwt::widget::form::{Field, FormContext};
 use pwt::widget::{Button, Container, InputPanel, Toolbar};
 
 use crate::utils::render_epoch;
-use crate::{ConfirmButton, DataViewWindow, EditWindow, KVGrid, KVGridRow, ProjectInfo};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{
+    ConfirmButton, DataViewWindow, EditWindow, KVGrid, KVGridRow, LoadableComponentState,
+    ProjectInfo,
+};
+use crate::{
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+};
 
 #[derive(Properties, PartialEq, Clone)]
 pub struct SubscriptionPanel {
@@ -38,20 +43,26 @@ pub enum ViewState {
     SystemReport,
 }
 
-pub enum Msg {}
-
 pub struct ProxmoxSubscriptionPanel {
+    state: LoadableComponentState<ViewState>,
     rows: Rc<Vec<KVGridRow>>,
     data: Rc<RefCell<Rc<Value>>>,
 }
 
+crate::impl_deref_mut_property!(
+    ProxmoxSubscriptionPanel,
+    state,
+    LoadableComponentState<ViewState>
+);
+
 impl LoadableComponent for ProxmoxSubscriptionPanel {
-    type Message = Msg;
+    type Message = ();
     type Properties = SubscriptionPanel;
     type ViewState = ViewState;
 
     fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
         Self {
+            state: LoadableComponentState::new(),
             rows: Rc::new(rows()),
             data: Rc::new(RefCell::new(Rc::new(Value::Null))),
         }
@@ -59,7 +70,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
 
     fn load(
         &self,
-        ctx: &crate::LoadableComponentContext<Self>,
+        ctx: &Context<LoadableComponentMaster<Self>>,
     ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
         let data = self.data.clone();
         let base_url = ctx.props().base_url.to_string();
@@ -70,7 +81,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
         })
     }
 
-    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+    fn toolbar(&self, ctx: &Context<LoadableComponentMaster<Self>>) -> Option<Html> {
         let toolbar = Toolbar::new()
             .class("pwt-overflow-hidden")
             .with_child(
@@ -85,7 +96,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
                 Button::new(tr!("Check"))
                     .icon_class("fa fa-check-square-o")
                     .onclick({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let base_url = ctx.props().base_url.to_string();
                         move |_| {
                             link.spawn({
@@ -112,7 +123,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
                         html! {tr!("Are you sure you want to remove the subscription key?")},
                     )
                     .on_activate({
-                        let link = ctx.link();
+                        let link = ctx.link().clone();
                         let base_url = ctx.props().base_url.to_string();
                         move |_| {
                             link.spawn({
@@ -141,8 +152,8 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
             )
             .with_flex_spacer()
             .with_child({
-                let loading = ctx.loading();
-                let link = ctx.link();
+                let loading = self.loading();
+                let link = ctx.link().clone();
                 Button::refresh(loading).onclick(move |_| link.send_reload())
             });
 
diff --git a/src/tasks.rs b/src/tasks.rs
index d3e814f..6e1118e 100644
--- a/src/tasks.rs
+++ b/src/tasks.rs
@@ -26,7 +26,10 @@ use pbs_api_types::TaskListItem;
 
 use pwt_macros::builder;
 
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster, TaskViewer};
+use crate::{
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, LoadableComponentState, TaskViewer,
+};
 
 use super::{TaskStatusSelector, TaskTypeSelector};
 
@@ -98,7 +101,6 @@ pub enum ViewDialog {
 }
 
 pub enum Msg {
-    Redraw,
     ToggleFilter,
     LoadBatch(bool), // fresh load
     LoadFinished,
@@ -106,6 +108,7 @@ pub enum Msg {
     ShowTask,
 }
 pub struct ProxmoxTasks {
+    state: LoadableComponentState<ViewDialog>,
     selection: Selection,
     store: Store<TaskListItem>,
     show_filter: PersistentState<bool>,
@@ -117,6 +120,8 @@ pub struct ProxmoxTasks {
     columns: Rc<Vec<DataTableHeader<TaskListItem>>>,
 }
 
+crate::impl_deref_mut_property!(ProxmoxTasks, state, LoadableComponentState<ViewDialog>);
+
 impl ProxmoxTasks {
     fn columns(ctx: &LoadableComponentContext<Self>) -> Rc<Vec<DataTableHeader<TaskListItem>>> {
         if let Some(columns) = ctx.props().columns.clone() {
@@ -169,18 +174,21 @@ impl LoadableComponent for ProxmoxTasks {
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
         let link = ctx.link();
-        let selection = Selection::new().on_select(link.callback(|_| Msg::Redraw));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         let store = Store::with_extract_key(|item: &TaskListItem| Key::from(item.upid.clone()));
 
         let filter_form_context =
-            FormContext::new().on_change(ctx.link().callback(|_| Msg::UpdateFilter));
+            FormContext::new().on_change(ctx.link().custom_callback(|_| Msg::UpdateFilter));
 
         let row_render_callback = DataTableRowRenderCallback::new({
             let store = store.clone();
             let link = link.clone();
             move |args: &mut _| {
                 if args.row_index() > store.data_len().saturating_sub(LOAD_BUFFER_ROWS) {
-                    link.send_message(Msg::LoadBatch(false));
+                    link.send_custom_message(Msg::LoadBatch(false));
                 }
                 let record: &TaskListItem = args.record();
                 match record.status.as_deref() {
@@ -194,6 +202,7 @@ impl LoadableComponent for ProxmoxTasks {
         });
 
         Self {
+            state: LoadableComponentState::new(),
             selection,
             store,
             show_filter: PersistentState::new("ProxmoxTasksShowFilter"),
@@ -286,14 +295,13 @@ impl LoadableComponent for ProxmoxTasks {
                 }
                 store.append(&mut data);
             }
-            link.send_message(Msg::LoadFinished);
+            link.send_custom_message(Msg::LoadFinished);
             Ok(())
         })
     }
 
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::Redraw => true,
             Msg::ToggleFilter => {
                 self.show_filter.update(!*self.show_filter);
                 true
@@ -304,7 +312,7 @@ impl LoadableComponent for ProxmoxTasks {
                     return false;
                 }
                 let filter_params = form_context.get_submit_data();
-                if ctx.loading() && self.last_filter == filter_params {
+                if self.loading() && self.last_filter == filter_params {
                     return false;
                 }
 
@@ -369,7 +377,7 @@ impl LoadableComponent for ProxmoxTasks {
             .with_child(
                 Button::new(tr!("View"))
                     .disabled(disabled)
-                    .onclick(ctx.link().callback(|_| Msg::ShowTask)),
+                    .onclick(ctx.link().custom_callback(|_| Msg::ShowTask)),
             )
             .with_flex_spacer()
             .with_child({
@@ -381,12 +389,13 @@ impl LoadableComponent for ProxmoxTasks {
             .with_child(
                 Button::new("Filter")
                     .icon_class(filter_icon_class)
-                    .onclick(ctx.link().callback(|_| Msg::ToggleFilter)),
+                    .onclick(ctx.link().custom_callback(|_| Msg::ToggleFilter)),
             )
             .with_child({
-                let loading = ctx.loading();
-                let link = ctx.link();
-                Button::refresh(loading).onclick(move |_| link.send_message(Msg::LoadBatch(true)))
+                let loading = self.loading();
+                let link = ctx.link().clone();
+                Button::refresh(loading)
+                    .onclick(move |_| link.send_custom_message(Msg::LoadBatch(true)))
             });
 
         let filter_classes = classes!(
@@ -441,13 +450,13 @@ impl LoadableComponent for ProxmoxTasks {
 
     fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
         let columns = self.columns.clone();
-        let link = ctx.link();
+        let link = ctx.link().clone();
 
         DataTable::new(columns, self.store.clone())
             .class("pwt-flex-fit")
             .selection(self.selection.clone())
             .on_row_dblclick(move |_: &mut _| {
-                link.send_message(Msg::ShowTask);
+                link.send_custom_message(Msg::ShowTask);
             })
             .row_render_callback(self.row_render_callback.clone())
             .into()
diff --git a/src/tfa/tfa_view.rs b/src/tfa/tfa_view.rs
index c4c1a43..540c98b 100644
--- a/src/tfa/tfa_view.rs
+++ b/src/tfa/tfa_view.rs
@@ -16,7 +16,10 @@ use pwt::widget::{Button, Mask, Toolbar};
 
 use pwt_macros::builder;
 
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{
+    impl_deref_mut_property, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, LoadableComponentState,
+};
 
 use proxmox_tfa::{TfaType, TfaUser};
 
@@ -87,7 +90,6 @@ impl TfaView {
 }
 
 pub enum Msg {
-    Redraw,
     Edit,
     Remove(Option<String>),
     RemoveResult(Result<(), Error>),
@@ -104,11 +106,14 @@ pub enum ViewState {
 
 #[doc(hidden)]
 pub struct ProxmoxTfaView {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<TfaEntry>,
     removing: bool,
 }
 
+impl_deref_mut_property!(ProxmoxTfaView, state, LoadableComponentState<ViewState>);
+
 impl ProxmoxTfaView {
     fn get_selected_record(&self) -> Option<TfaEntry> {
         let selected_key = self.selection.selected_key();
@@ -127,8 +132,12 @@ impl LoadableComponent for ProxmoxTfaView {
 
     fn create(ctx: &LoadableComponentContext<Self>) -> Self {
         let store = Store::new();
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         Self {
+            state: LoadableComponentState::new(),
             store,
             selection,
             removing: false,
@@ -177,7 +186,6 @@ impl LoadableComponent for ProxmoxTfaView {
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         let props = ctx.props();
         match msg {
-            Msg::Redraw => true,
             Msg::Edit => {
                 let info = match self.get_selected_record() {
                     Some(info) => info,
@@ -204,8 +212,8 @@ impl LoadableComponent for ProxmoxTfaView {
                 // fixme: ask use if he really wants to remove
                 let link = ctx.link().clone();
                 let base_url = props.base_url.clone();
-                link.send_future(async move {
-                    Msg::RemoveResult(
+                self.spawn(async move {
+                    link.send_custom_message(Msg::RemoveResult(
                         delete_item(
                             base_url,
                             info.user_id.clone(),
@@ -213,7 +221,7 @@ impl LoadableComponent for ProxmoxTfaView {
                             password,
                         )
                         .await,
-                    )
+                    ))
                 });
 
                 false
@@ -273,7 +281,7 @@ impl LoadableComponent for ProxmoxTfaView {
             .with_child(
                 Button::new(tr!("Edit"))
                     .disabled(edit_disabled)
-                    .onclick(ctx.link().callback(|_| Msg::Edit)),
+                    .onclick(ctx.link().custom_callback(|_| Msg::Edit)),
             )
             .with_child(
                 Button::new(tr!("Remove"))
@@ -282,8 +290,8 @@ impl LoadableComponent for ProxmoxTfaView {
             )
             .with_flex_spacer()
             .with_child({
-                let loading = ctx.loading();
-                let link = ctx.link();
+                let loading = self.loading();
+                let link = ctx.link().clone();
                 Button::refresh(loading).onclick(move |_| link.send_reload())
             });
 
@@ -296,8 +304,8 @@ impl LoadableComponent for ProxmoxTfaView {
             .selection(self.selection.clone())
             .class("pwt-flex-fit")
             .on_row_dblclick({
-                let link = ctx.link();
-                move |_: &mut _| link.send_message(Msg::Edit)
+                let link = ctx.link().clone();
+                move |_: &mut _| link.send_custom_message(Msg::Edit)
             });
         Mask::new(view).visible(self.removing).into()
     }
@@ -314,8 +322,8 @@ impl LoadableComponent for ProxmoxTfaView {
                 TfaConfirmRemove::new(info)
                     .on_close(ctx.link().change_view_callback(|_| None))
                     .on_confirm({
-                        let link = ctx.link();
-                        move |password| link.send_message(Msg::Remove(password))
+                        let link = ctx.link().clone();
+                        move |password| link.send_custom_message(Msg::Remove(password))
                     })
                     .into()
             }),
diff --git a/src/token_panel.rs b/src/token_panel.rs
index 031d54f..66c25db 100644
--- a/src/token_panel.rs
+++ b/src/token_panel.rs
@@ -25,7 +25,8 @@ use crate::utils::{
 };
 use crate::{
     AuthidSelector, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
-    LoadableComponentLink, LoadableComponentMaster, PermissionPanel,
+    LoadableComponentMaster, LoadableComponentScope, LoadableComponentScopeExt,
+    LoadableComponentState, PermissionPanel,
 };
 
 async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
@@ -37,7 +38,7 @@ async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
 
 async fn create_token(
     form_ctx: FormContext,
-    link: LoadableComponentLink<ProxmoxTokenView>,
+    link: LoadableComponentScope<ProxmoxTokenView>,
 ) -> Result<(), Error> {
     let mut data = form_ctx.get_submit_data();
 
@@ -118,17 +119,19 @@ enum ViewState {
 }
 
 enum Msg {
-    Refresh,
     Remove,
     Regenerate,
 }
 
 struct ProxmoxTokenView {
+    state: LoadableComponentState<ViewState>,
     selection: Selection,
     store: Store<ApiToken>,
     columns: Rc<Vec<DataTableHeader<ApiToken>>>,
 }
 
+crate::impl_deref_mut_property!(ProxmoxTokenView, state, LoadableComponentState<ViewState>);
+
 fn token_api_url(user: &str, tokenname: &str) -> String {
     format!(
         "/access/users/{}/token/{}",
@@ -146,11 +149,15 @@ impl LoadableComponent for ProxmoxTokenView {
         let link = ctx.link();
         link.repeated_load(5000);
 
-        let selection = Selection::new().on_select(link.callback(|_| Msg::Refresh));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
         let store =
             Store::with_extract_key(|record: &ApiToken| Key::from(record.tokenid.to_string()));
 
         Self {
+            state: LoadableComponentState::new(),
             selection,
             store,
             columns: columns(),
@@ -193,7 +200,7 @@ impl LoadableComponent for ProxmoxTokenView {
                     .confirm_message(tr!("Are you sure you want to remove the API token? \
                         All existing users of the token will lose access!"))
                     .disabled(disabled)
-                    .on_activate(link.callback(|_| Msg::Remove)),
+                    .on_activate(link.custom_callback(|_| Msg::Remove)),
             )
             .with_spacer()
             .with_child(
@@ -203,7 +210,7 @@ impl LoadableComponent for ProxmoxTokenView {
                         All existing users of the token will lose access!"
                     ))
                     .disabled(disabled)
-                    .on_activate(link.callback(|_| Msg::Regenerate)),
+                    .on_activate(link.custom_callback(|_| Msg::Regenerate)),
             )
             .with_spacer()
             .with_child(
@@ -217,7 +224,6 @@ impl LoadableComponent for ProxmoxTokenView {
 
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::Refresh => true,
             Msg::Remove => {
                 let Some(record) = self.store.selected_record(&self.selection) else {
                     return false;
@@ -230,7 +236,7 @@ impl LoadableComponent for ProxmoxTokenView {
                 };
 
                 let url = token_api_url(&user, tokenname.as_str());
-                let link = ctx.link();
+                let link = ctx.link().clone();
                 link.clone().spawn(async move {
                     match crate::http_delete(url, None).await {
                         Ok(()) => {
@@ -271,7 +277,7 @@ impl LoadableComponent for ProxmoxTokenView {
     }
 
     fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
-        let link = ctx.link();
+        let link = ctx.link().clone();
 
         DataTable::new(self.columns.clone(), self.store.clone())
             .class("pwt-flex-fit")
diff --git a/src/user_panel.rs b/src/user_panel.rs
index d364df8..cbc6b49 100644
--- a/src/user_panel.rs
+++ b/src/user_panel.rs
@@ -23,8 +23,9 @@ use crate::form::delete_empty_values;
 use crate::percent_encoding::percent_encode_component;
 use crate::utils::{epoch_to_input_value, render_epoch_short};
 use crate::{
-    EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
-    PermissionPanel, RealmSelector, SchemaValidation,
+    impl_deref_mut_property, EditWindow, LoadableComponent, LoadableComponentContext,
+    LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, PermissionPanel,
+    RealmSelector, SchemaValidation,
 };
 
 async fn load_user_list() -> Result<Vec<UserWithTokens>, Error> {
@@ -111,15 +112,17 @@ pub enum ViewState {
 }
 
 pub enum Msg {
-    SelectionChange,
     RemoveItem,
 }
 
 pub struct ProxmoxUserPanel {
+    state: LoadableComponentState<ViewState>,
     store: Store<UserWithTokens>,
     selection: Selection,
 }
 
+impl_deref_mut_property!(ProxmoxUserPanel, state, LoadableComponentState<ViewState>);
+
 impl LoadableComponent for ProxmoxUserPanel {
     type Message = Msg;
     type Properties = UserPanel;
@@ -142,17 +145,23 @@ impl LoadableComponent for ProxmoxUserPanel {
             Key::from(record.user.userid.as_str())
         });
 
-        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+        let selection = Selection::new().on_select({
+            let link = ctx.link().clone();
+            move |_| link.send_redraw()
+        });
 
-        Self { store, selection }
+        Self {
+            state: LoadableComponentState::new(),
+            store,
+            selection,
+        }
     }
 
     fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::SelectionChange => true,
             Msg::RemoveItem => {
                 if let Some(key) = self.selection.selected_key() {
-                    let link = ctx.link();
+                    let link = ctx.link().clone();
                     link.clone().spawn(async move {
                         if let Err(err) = delete_user(key).await {
                             link.show_error(tr!("Unable to delete user"), err, true);
@@ -191,7 +200,7 @@ impl LoadableComponent for ProxmoxUserPanel {
             .with_child(
                 Button::new(tr!("Remove"))
                     .disabled(no_selection)
-                    .onclick(link.callback(|_| Msg::RemoveItem)),
+                    .onclick(link.custom_callback(|_| Msg::RemoveItem)),
             )
             .with_spacer()
             .with_child(
@@ -206,8 +215,8 @@ impl LoadableComponent for ProxmoxUserPanel {
             )
             .with_flex_spacer()
             .with_child({
-                let loading = ctx.loading();
-                let link = ctx.link();
+                let loading = self.loading();
+                let link = ctx.link().clone();
                 Button::refresh(loading).onclick(move |_| link.send_reload())
             });
 
@@ -215,7 +224,7 @@ impl LoadableComponent for ProxmoxUserPanel {
     }
 
     fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
-        let link = ctx.link();
+        let link = ctx.link().clone();
         DataTable::new(columns(), self.store.clone())
             .class("pwt-flex-fill pwt-overflow-auto")
             .selection(self.selection.clone())
-- 
2.47.3


_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

* Re: [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations
  2025-12-09 13:11 [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations Dietmar Maurer
@ 2025-12-09 18:31 ` Thomas Lamprecht
  2025-12-10  7:53 ` Thomas Lamprecht
  1 sibling, 0 replies; 5+ messages in thread
From: Thomas Lamprecht @ 2025-12-09 18:31 UTC (permalink / raw)
  To: Yew framework devel list at Proxmox, Dietmar Maurer

Am 09.12.25 um 15:36 schrieb Dietmar Maurer:
> Major Refactoring of the `LoadableComponent` system.
> 
> Encapsulate component state (loading, error, view_state) into a new
> `LoadableComponentState` struct.
> Instead of `LoadableComponentMaster` managing the state externally,
> the concrete components now own their `LoadableComponentState`.
> 
> - Use `impl_deref_mut_property`LoadableComponentState` struct. to implement
>   Deref/DerefMut for components, allowing `LoadableComponentMaster` to
>   access the state transparently.
> - `LoadableComponentContext` is now a normal yew Scope (removed custom implementation)
> - Migrate all `LoadableComponent` implementations (ACL, ACME, APT, Network,
>   User/Token/TFA views, etc.) to the new pattern.
> - Use `link.custom_callback` and `link.send_custom_message` for internal
>   messaging (rename is necessaray because of naming conflict with standard

s/necessaray/necessary/

>   Scope function).
> - avoid useless Redraw/Datachange/Refresh messages, because `LoadableComponentMaster`
>   already implements that.

Besides above typo in the commit message and another typo in the doc-comment
example (see below), this looks OK to me.

> diff --git a/src/loadable_component.rs b/src/loadable_component.rs
> index f0e28a9..43cb7a9 100644
> --- a/src/loadable_component.rs
> +++ b/src/loadable_component.rs
> @@ -1,163 +1,313 @@
> -use anyhow::Error;
> -use serde_json::Value;
>  use std::future::Future;
> +use std::ops::DerefMut;
>  use std::pin::Pin;
> -use yew_router::scope_ext::RouterScopeExt;
>  
> +use anyhow::Error;
>  use gloo_timers::callback::Timeout;
>  
> +use serde_json::Value;
>  use yew::html::Scope;
>  
>  use pwt::dom::DomVisibilityObserver;
>  use pwt::prelude::*;
> -use pwt::state::NavigationContextExt;
>  use pwt::widget::{AlertDialog, Column};
>  use pwt::AsyncPool;
>  
> +#[cfg(doc)]
> +use crate::impl_deref_mut_property;
> +#[cfg(doc)]
> +use pwt::widget::Dialog;
> +
>  use crate::{TaskProgress, TaskViewer};
>  
> -pub struct LoadableComponentState {
> -    loading: usize,
> -    last_load_error: Option<String>,
> -    repeat_timespan: u32, /* 0 => no repeated loading */
> -    task_base_url: Option<AttrValue>,
> -}
> +pub type LoadableComponentContext<L> = Context<LoadableComponentMaster<L>>;
> +pub type LoadableComponentScope<L> = Scope<LoadableComponentMaster<L>>;
> +
> +/// Loadable Components
> +///
> +/// - Load data using an async function [LoadableComponent::load]
> +/// - repeated load possible
> +/// - pause repeated load when component is not visible (uses [DomVisibilityObserver])
> +/// - display the loaded data [LoadableComponent::main_view]
> +/// - display an optional toolbar [LoadableComponent::toolbar]
> +/// - display any errors from failed load.
> +/// - display additional dialogs depening on [LoadableComponent::ViewState]
> +///
> +/// The [LoadableComponentScopeExt] defines available control function on the scope.
> +///
> +/// The [LoadableComponentState] provides acces to load status informations and add the ability
> +/// to spawn tasks.
> +///
> +/// ```
> +/// use proxmox_yew_comp::{LoadableComponent, LoadableComponentState, LoadableComponentContext};
> +/// // include the scope extension for (for `change_view`, `send_custom_message`, ...)
> +/// use proxmox_yew_comp::LoadableComponentScopeExt;
> +/// # use std::pin::Pin;
> +/// # use std::rc::Rc;
> +/// # use std::future::Future;
> +/// # use pwt::prelude::*;
> +/// # use proxmox_yew_comp::http_get;
> +/// # use yew::virtual_dom::{VComp, VNode, Key};
> +///
> +/// // define the component properties
> +/// #[derive(Clone, PartialEq, Properties)]
> +/// pub struct MyComponent {
> +///     key: Option<Key>,
> +///     /* add whatever you need  */
> +/// };
> +///
> +/// // define your view states
> +/// #[derive(PartialEq)]
> +/// pub enum ViewState { Add, Edit }
> +///
> +/// // define the component message type
> +/// pub enum Msg { UpdateData(String) }
> +///
> +/// // define the component state
> +/// pub struct MyComponentState {
> +///     // you need to inlucde a LoadableComponentState

typo s/inlucde/include/

> +///     state: LoadableComponentState<ViewState>,
> +///     // Add any other data you need
> +///     loaded_data: Option<String>,
> +/// }
> +///
> +/// // implement DerefMut
> +/// proxmox_yew_comp::impl_deref_mut_property!(
> +///     MyComponentState,
> +///     state,
> +///     LoadableComponentState<ViewState>
> +/// );
> +///



_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

* Re: [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations
  2025-12-09 13:11 [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations Dietmar Maurer
  2025-12-09 18:31 ` Thomas Lamprecht
@ 2025-12-10  7:53 ` Thomas Lamprecht
  2025-12-10  9:23   ` Dietmar Maurer
  1 sibling, 1 reply; 5+ messages in thread
From: Thomas Lamprecht @ 2025-12-10  7:53 UTC (permalink / raw)
  To: Yew framework devel list at Proxmox, Dietmar Maurer

Spotted another typo, and while checking that I found a parameter name a bit


Am 09.12.25 um 15:36 schrieb Dietmar Maurer:
> +pub struct LoadableComponentState<V: PartialEq> {
> +    loading: usize,
> +    last_load_error: Option<String>,
> +    repeat_timespan: u32, /* 0 => no repeated loading */

nit: repeat is IMO a bit generic for what this is used for and reads a tiny bit
awkwardly to me as I do not recognize that as widely used term for these thing.
Using timespan for interval or polling period feels also slightly odd.

You could use "reload_interval", that's apt, telling and fits to the reload_timeout
below. Adapting the repeated_load function to reload_interval (or set_reload_interval)
would make it also a bit more telling IMO, as is, it's not as clear as it could be
that this is a period for anybody not used to the code.

> +    task_base_url: Option<AttrValue>,
> +    view_state: ViewState<V>,
> +    reload_timeout: Option<Timeout>,
> +    visible: bool,
> +    visibitlity_observer: Option<DomVisibilityObserver>,

Another typo here (drop extra t): s/visibitlity_observer/visibility_observer/

> +    node_ref: NodeRef,
> +    async_pool: AsyncPool,
>  }
>  



_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

* Re: [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations
  2025-12-10  7:53 ` Thomas Lamprecht
@ 2025-12-10  9:23   ` Dietmar Maurer
  2025-12-10  9:32     ` Thomas Lamprecht
  0 siblings, 1 reply; 5+ messages in thread
From: Dietmar Maurer @ 2025-12-10  9:23 UTC (permalink / raw)
  To: Thomas Lamprecht, Yew framework devel list at Proxmox


On 12/10/25 8:53 AM, Thomas Lamprecht wrote:
> Spotted another typo, and while checking that I found a parameter name a bit
>
>
> Am 09.12.25 um 15:36 schrieb Dietmar Maurer:
>> +pub struct LoadableComponentState<V: PartialEq> {
>> +    loading: usize,
>> +    last_load_error: Option<String>,
>> +    repeat_timespan: u32, /* 0 => no repeated loading */
> nit: repeat is IMO a bit generic for what this is used for and reads a tiny bit
> awkwardly to me as I do not recognize that as widely used term for these thing.
> Using timespan for interval or polling period feels also slightly odd.
>
> You could use "reload_interval", that's apt, telling and fits to the reload_timeout
> below. Adapting the repeated_load function to reload_interval (or set_reload_interval)
> would make it also a bit more telling IMO, as is, it's not as clear as it could be
> that this is a period for anybody not used to the code.

Maybe we can defer that renaming after the structural changes got in?

Anyways, I will send a v2 including the typo fixes.

Also, Dominik found a way to avoid send_custom_message() and 
custom_callback(),
simply by implementing:


impl<M, V: PartialEq> From<M> for Msg<M, V> {
     fn from(value: M) -> Self {
         Msg::ChildMessage(value)
     }
}




_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel

^ permalink raw reply	[flat|nested] 5+ messages in thread

* Re: [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations
  2025-12-10  9:23   ` Dietmar Maurer
@ 2025-12-10  9:32     ` Thomas Lamprecht
  0 siblings, 0 replies; 5+ messages in thread
From: Thomas Lamprecht @ 2025-12-10  9:32 UTC (permalink / raw)
  To: Dietmar Maurer, Yew framework devel list at Proxmox

Am 10.12.25 um 10:23 schrieb Dietmar Maurer:
> 
> On 12/10/25 8:53 AM, Thomas Lamprecht wrote:
>> Spotted another typo, and while checking that I found a parameter name a bit
>>
>>
>> Am 09.12.25 um 15:36 schrieb Dietmar Maurer:
>>> +pub struct LoadableComponentState<V: PartialEq> {
>>> +    loading: usize,
>>> +    last_load_error: Option<String>,
>>> +    repeat_timespan: u32, /* 0 => no repeated loading */
>> nit: repeat is IMO a bit generic for what this is used for and reads a tiny bit
>> awkwardly to me as I do not recognize that as widely used term for these thing.
>> Using timespan for interval or polling period feels also slightly odd.
>>
>> You could use "reload_interval", that's apt, telling and fits to the reload_timeout
>> below. Adapting the repeated_load function to reload_interval (or set_reload_interval)
>> would make it also a bit more telling IMO, as is, it's not as clear as it could be
>> that this is a period for anybody not used to the code.
> 
> Maybe we can defer that renaming after the structural changes got in?

Sure, just thought it might be less churn now if you touch that much
code anyway already it might not be that much extra work, but having it
as separate change might indeed be nicer, so fine by me.

> Anyways, I will send a v2 including the typo fixes.
> 
> Also, Dominik found a way to avoid send_custom_message() and custom_callback(),
> simply by implementing:
> 
> 
> impl<M, V: PartialEq> From<M> for Msg<M, V> {
>     fn from(value: M) -> Self {
>         Msg::ChildMessage(value)
>     }
> }

great, that's better!


_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

end of thread, other threads:[~2025-12-10  9:32 UTC | newest]

Thread overview: 5+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-12-09 13:11 [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations Dietmar Maurer
2025-12-09 18:31 ` Thomas Lamprecht
2025-12-10  7:53 ` Thomas Lamprecht
2025-12-10  9:23   ` Dietmar Maurer
2025-12-10  9:32     ` Thomas Lamprecht

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal