* WinUI plugin enhancements and mvvm toolkit skill * Split mvvm-toolkit skill for slimming
6.9 KiB
Messenger patterns
CommunityToolkit.Mvvm.Messaging provides decoupled pub/sub between
ViewModels (or any objects) without forcing a shared reference graph.
Choosing an implementation
| Type | When to use |
|---|---|
WeakReferenceMessenger.Default |
Default. Recipients held weakly — eligible for GC even if still registered. Internal trimming runs during full GCs. No manual Cleanup() required. |
StrongReferenceMessenger.Default |
Use when profiling shows the messenger is hot and allocation matters. Recipients are pinned until you Unregister. Forgetting to unregister leaks them. |
Custom IMessenger instance |
Per-window/per-scope messengers (e.g., one per app window). Construct directly and inject through DI. |
ObservableRecipient's parameterless constructor uses
WeakReferenceMessenger.Default. Pass a different IMessenger to its
constructor to override.
Defining messages
The toolkit ships a few base classes you can inherit from, but any class works.
Plain payload
public sealed record ThemeChangedMessage(AppTheme NewTheme);
ValueChangedMessage<T>
using CommunityToolkit.Mvvm.Messaging.Messages;
public sealed class LoggedInUserChangedMessage(User user)
: ValueChangedMessage<User>(user);
Access the payload via .Value.
Empty signal
public sealed record RefreshRequestedMessage;
Useful for "reload now" or "save now" broadcasts where there is no payload.
Registering recipients
Lambda style (recommended)
WeakReferenceMessenger.Default.Register<MyViewModel, ThemeChangedMessage>(
this,
static (recipient, message) => recipient.OnThemeChanged(message.NewTheme));
The static modifier ensures the lambda doesn't capture this (or any
local variable), keeping it allocation-free and preventing accidental strong
references back to the recipient through closure capture.
IRecipient<TMessage> interface style
public sealed class MyViewModel : ObservableRecipient,
IRecipient<ThemeChangedMessage>,
IRecipient<RefreshRequestedMessage>
{
public void Receive(ThemeChangedMessage message) { /* ... */ }
public void Receive(RefreshRequestedMessage message) { /* ... */ }
}
ObservableRecipient.OnActivated() calls Messenger.RegisterAll(this),
which subscribes every IRecipient<T> interface implemented by the type.
If you're not using ObservableRecipient, register manually:
WeakReferenceMessenger.Default.RegisterAll(this);
Sending messages
WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(AppTheme.Dark));
// Empty payloads can use the parameterless overload:
WeakReferenceMessenger.Default.Send<RefreshRequestedMessage>();
Channels (tokens)
Send/receive over a named channel to scope messages to a sub-system. The
token is any equatable value (commonly int, string, or Guid).
const int LeftPaneChannel = 1;
const int RightPaneChannel = 2;
WeakReferenceMessenger.Default.Register<MyViewModel, RefreshRequestedMessage, int>(
this, LeftPaneChannel,
static (r, _) => r.RefreshLeft());
WeakReferenceMessenger.Default.Send(new RefreshRequestedMessage(), LeftPaneChannel);
Messages sent without a token use the default shared channel and are not delivered to channel-scoped recipients.
Request / reply
For ask-style scenarios where a recipient should provide a value back to
the sender, use the RequestMessage<T> family.
Sync request
public sealed class CurrentUserRequest : RequestMessage<User> { }
// Recipient
WeakReferenceMessenger.Default.Register<UserService, CurrentUserRequest>(
this,
static (r, m) => m.Reply(r.CurrentUser));
// Caller
User user = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
The implicit conversion from CurrentUserRequest to User throws if no
recipient called Reply. To check first, capture the message:
var request = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
if (request.HasReceivedResponse)
{
User user = request.Response;
}
Async request
public sealed class CurrentUserRequest : AsyncRequestMessage<User> { }
WeakReferenceMessenger.Default.Register<UserService, CurrentUserRequest>(
this,
static (r, m) => m.Reply(r.GetCurrentUserAsync()));
User user = await WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
Collection requests (fan-in)
CollectionRequestMessage<T> and AsyncCollectionRequestMessage<T> collect
a Reply from every recipient that handles the message:
public sealed class OpenDocumentsRequest : CollectionRequestMessage<Document> { }
var responses = WeakReferenceMessenger.Default.Send<OpenDocumentsRequest>();
foreach (Document doc in responses) { /* ... */ }
Unregistering
Always unregister when a recipient's lifetime ends. With
WeakReferenceMessenger, this is for performance (trimming dead entries);
with StrongReferenceMessenger, it's required to avoid leaks.
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage>(this);
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage, int>(this, LeftPaneChannel);
WeakReferenceMessenger.Default.UnregisterAll(this);
ObservableRecipient.OnDeactivated() unregisters everything for you when
IsActive flips to false — set IsActive = true in your activation flow
(e.g., page OnNavigatedTo) and IsActive = false on tear-down.
Lifetime pitfalls
- Closure-captured
this. Avoid(r, m) => OnX(m)lambdas that implicitly capture the enclosingthis. Use(r, m) => r.OnX(m)so the recipient is passed in instead. - Long-lived strong-ref recipients. With
StrongReferenceMessenger, forgettingUnregisterAllkeeps the recipient (and its entire object graph) alive forever. - Inherited message types. A handler registered for
BaseMessageis not invoked forDerivedMessage : BaseMessage. Register each concrete type you want to handle. - Multiple
ObservableRecipientactivations. SettingIsActive = truetwice without an intermediate deactivation throws — guard the toggle. - UI-thread marshalling. The messenger is thread-agnostic. If a
handler updates UI, marshal manually
(
DispatcherQueue.TryEnqueue/Dispatcher.BeginInvoke).
Multiple messengers
A common architecture is one messenger per window or per scope:
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default); // app-wide
services.AddScoped<WindowScopedMessenger>(); // per-window
Inject the appropriate IMessenger into the ViewModel constructor:
public sealed partial class WindowViewModel(IMessenger messenger)
: ObservableRecipient(messenger) { /* ... */ }
This isolates broadcasts to a single window — useful for multi-window desktop apps (WinUI 3, WPF, MAUI desktop, Avalonia).