skills/mvvm-toolkit-messenger/SKILL.md
CommunityToolkit.Mvvm Messenger pub/sub for decoupled communication between ViewModels (or any objects). Covers WeakReferenceMessenger vs StrongReferenceMessenger, IRecipient<TMessage>, RequestMessage<T> / AsyncRequestMessage<T> / CollectionRequestMessage<T>, ValueChangedMessage<T>, channels (tokens), and the ObservableRecipient activation lifecycle. Use across WPF, WinUI 3, .NET MAUI, Uno, and Avalonia.
npx skillsauth add williamlimasilva/.copilot mvvm-toolkit-messengerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Pub/sub messaging for ViewModels (or any objects) without forcing a shared
reference graph. Part of CommunityToolkit.Mvvm 8.x.
TL;DR. Default to
WeakReferenceMessenger.Default. Register handlers with the(recipient, message)lambda and thestaticmodifier so you never capturethis. Inherit fromObservableRecipientand toggleIsActiveat activation/deactivation to get automatic register/unregister.
For source generators, base classes, and commands see the mvvm-toolkit
skill. For DI wiring (registering an IMessenger instance), see
mvvm-toolkit-di.
| Type | When |
|------|------|
| WeakReferenceMessenger.Default | Default. Recipients held weakly — eligible for GC even while registered. Internal trimming runs during full GCs; no manual Cleanup() needed. |
| StrongReferenceMessenger.Default | Profiler shows the messenger is hot and allocation matters. Recipients are pinned until you Unregister. Forgetting unregistration leaks them. |
| Custom IMessenger instance | Per-window/per-scope (e.g., one messenger per app window). Construct directly, inject via DI. |
ObservableRecipient's parameterless constructor uses
WeakReferenceMessenger.Default. Pass a different IMessenger to its
constructor to override.
The toolkit ships base classes; any class works.
using CommunityToolkit.Mvvm.Messaging.Messages;
// Single-payload broadcast
public sealed class LoggedInUserChangedMessage(User user)
: ValueChangedMessage<User>(user);
// Custom shape (records are great for this)
public sealed record ThemeChangedMessage(AppTheme NewTheme);
// Empty signal
public sealed record RefreshRequestedMessage;
WeakReferenceMessenger.Default.Register<MyViewModel, ThemeChangedMessage>(
this,
static (recipient, message) => recipient.OnThemeChanged(message.NewTheme));
The static modifier prevents accidental closure allocation and keeps
this out of the lambda — use the recipient parameter instead.
IRecipient<TMessage> interface stylepublic 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);
WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(AppTheme.Dark));
// Empty payloads use the parameterless overload:
WeakReferenceMessenger.Default.Send<RefreshRequestedMessage>();
Scope messages to a sub-system or window with a token (any equatable
value — int, string, Guid):
const int LeftPaneChannel = 1;
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 — they are not delivered to channel-scoped recipients.
For ask-style scenarios where a recipient provides a value back to the
sender, use the RequestMessage<T> family.
public sealed class CurrentUserRequest : RequestMessage<User> { }
WeakReferenceMessenger.Default.Register<UserService, CurrentUserRequest>(
this,
static (r, m) => m.Reply(r.CurrentUser));
User user = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
The implicit conversion from CurrentUserRequest to User throws if no
recipient called Reply. Capture the message to check first:
var request = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
if (request.HasReceivedResponse)
User user = request.Response;
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>();
CollectionRequestMessage<T> and AsyncCollectionRequestMessage<T> collect
a Reply from every responding recipient:
public sealed class OpenDocumentsRequest : CollectionRequestMessage<Document> { }
var docs = WeakReferenceMessenger.Default.Send<OpenDocumentsRequest>();
foreach (Document doc in docs) { /* ... */ }
Even with WeakReferenceMessenger, unregister explicitly when a recipient
is being torn down — it trims dead entries and improves performance:
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage>(this);
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage, int>(this, LeftPaneChannel);
WeakReferenceMessenger.Default.UnregisterAll(this);
ObservableRecipient.OnDeactivated() does this automatically when
IsActive flips to false. Set it from your activation hook:
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
ViewModel.IsActive = true;
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
ViewModel.IsActive = false;
base.OnNavigatedFrom(e);
}
this in the lambda. (r, m) => OnX(m) implicitly
captures this; allocates a closure and confuses lifetime. Always use
(r, m) => r.OnX(m) with static.Unregister. With
StrongReferenceMessenger, recipients (and their entire object graph)
stay pinned forever. Either inherit from ObservableRecipient
(auto-unregisters in OnDeactivated) or call UnregisterAll(this).BaseMessage is
not invoked for DerivedMessage : BaseMessage. Register each
concrete type.WeakReferenceMessenger.Default
and registering via an injected per-window messenger means the message
never arrives. Use the same IMessenger everywhere (typically inject
it via ObservableRecipient(messenger)).OnActivated never runs. ObservableRecipient only registers
IRecipient<T> handlers when IsActive flips from false to true.DispatcherQueue.TryEnqueue / Dispatcher.BeginInvoke).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).
| Topic | File |
|-------|------|
| Full deep dive (more channel/lifecycle examples, diagnostics) | references/messenger-patterns.md |
External:
WeakReferenceMessenger API: https://learn.microsoft.com/en-us/dotnet/api/communitytoolkit.mvvm.messaging.weakreferencemessengertools
Narrative and synthesis profile for Wiggins: framing, explanation, and audience-aware communication patterns for Ember sessions.
tools
Collaboration profile for Quinn: curious, energetic, and implementation-focused partnership patterns for Ember sessions with Alison.
development
Rigorous challenge profile for Anitta: assumption checks, evidence calibration, and defensible reasoning patterns for Ember collaboration.
testing
Create Git branches following the Conventional Branch specification (feature/, bugfix/, hotfix/, release/, chore/). Use when creating a new branch, naming a branch, or checking whether a branch name complies with the spec.