Files
awesome-copilot/skills/mvvm-toolkit-messenger/references/messenger-patterns.md
T
Alvin Ashcraft e7755069e9 WinUI plugin enhancements and add MVVM Toolkit skill (#1643)
* WinUI plugin enhancements and mvvm toolkit skill

* Split mvvm-toolkit skill for slimming
2026-05-11 11:29:33 +10:00

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

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

  1. Closure-captured this. Avoid (r, m) => OnX(m) lambdas that implicitly capture the enclosing this. Use (r, m) => r.OnX(m) so the recipient is passed in instead.
  2. Long-lived strong-ref recipients. With StrongReferenceMessenger, forgetting UnregisterAll keeps the recipient (and its entire object graph) alive forever.
  3. Inherited message types. A handler registered for BaseMessage is not invoked for DerivedMessage : BaseMessage. Register each concrete type you want to handle.
  4. Multiple ObservableRecipient activations. Setting IsActive = true twice without an intermediate deactivation throws — guard the toggle.
  5. 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).