Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/mvvm-toolkit-messenger/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-14 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-12 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
| case-10 | ✗→✓ | ▲ Improved | — | — |
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 the static modifier so you > never capture this. Inherit from ObservableRecipient and toggle > IsActive at activation/deactivation to get automatic register/unregister.
save, navigation) without holding references to each other
problems
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.
csharpusing 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;
csharpWeakReferenceMessenger.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 stylecsharppublic 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:
csharpWeakReferenceMessenger.Default.RegisterAll(this);
csharpWeakReferenceMessenger.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):
csharpconst 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.
csharppublic 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:
csharpvar request = WeakReferenceMessenger.Default.Send<CurrentUserRequest>(); if (request.HasReceivedResponse) User user = request.Response;
csharppublic 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:
csharppublic 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:
csharpWeakReferenceMessenger.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:
csharpprotected 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) implicitlycaptures this; allocates a closure and confuses lifetime. Always use (r, m) => r.OnX(m) with static.
Unregister. WithStrongReferenceMessenger, recipients (and their entire object graph) stay pinned forever. Either inherit from ObservableRecipient (auto-unregisters in OnDeactivated) or call UnregisterAll(this).
BaseMessage isnot invoked for DerivedMessage : BaseMessage. Register each concrete type.
WeakReferenceMessenger.Defaultand 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 registersIRecipient<T> handlers when IsActive flips from false to true.
handler updates UI, marshal manually (DispatcherQueue.TryEnqueue / Dispatcher.BeginInvoke).
csharpservices.AddSingleton<IMessenger>(WeakReferenceMessenger.Default); // app-wide services.AddScoped<WindowScopedMessenger>(); // per-window
Inject the appropriate IMessenger into the ViewModel constructor:
csharppublic 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.weakreferencemessenger>| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.