Ask the user from a ViewModel
On this page
A ViewModel asks a question, and the frontend shows it inside the page. With
ReactiveUI, use an Interaction; with CommunityToolkit.Mvvm, push a dialog
page into a navigation region.
Both keep the decision in the ViewModel and the presentation in the frontend. The ViewModel never opens a browser dialog itself, and the frontend never decides what happens after the answer.
With ReactiveUI: an Interaction #
ReactiveUI's Interaction<TInput, TOutput> is a question with a typed input
and answer. The Reactive Notes example asks before it discards a note's body.
The ViewModel declares the interaction as a public getter-only property, and
the input as a record:
public Interaction<DiscardNoteRequest, bool> ConfirmDiscard { get; } = new();
public sealed record DiscardNoteRequest(string Title, int BodyLength);
A command asks with Handle and continues with the answer:
private async Task DiscardAsync(CancellationToken token)
{
var request = new DiscardNoteRequest(Title, Body.Length);
if (request.BodyLength == 0)
{
SavedMessage = "Nothing to discard.";
return;
}
bool approved = await ConfirmDiscard.Handle(request);
token.ThrowIfCancellationRequested();
if (approved)
{
Body = "";
SavedMessage = $"Discarded {request.Title}";
}
else SavedMessage = "Kept current changes.";
}
The generator turns the property into a typed surface on the View's client,
view.interactions.confirmDiscard. The frontend registers a handler that
receives the request and returns the answer. The example uses the browser's
own window.confirm; an application renders its own dialog component the same
way and resolves the promise when the user answers:
const removeDiscardHandler = view.interactions.confirmDiscard.handle(async (request, { signal }) => {
if (signal.aborted) throw signal.reason;
return window.confirm(`Discard the ${request.bodyLength} unsaved characters in “${request.title}”?`);
});
handle returns a function that removes the handler; the example calls it
when the View unmounts. How the question travels:
- The request goes only to a mounted handler of the View that presents this ViewModel, in the Window whose command asked. It is never broadcast to other Windows and never appears in the published state.
- The handler's
signalaborts when the View unmounts, the connection drops, the command is cancelled or the Window closes. Answeringfalseis an ordinary answer; a cancelled or failed handler is a different outcome. Handletakes no cancellation token. A command's token still reaches the browser request, so cancelling the command aborts the handler.
With no mounted browser handler, for example in a test or before the View mounts, Runic doesn't answer, and ReactiveUI uses its own handlers as usual. Register a .NET handler with a safe default, so that a headless run declines instead of throwing an unhandled interaction exception:
ConfirmDiscard.RegisterHandler(context =>
{
// A native or headless invocation has no mounted browser endpoint.
// Keep ReactiveUI's normal handler precedence and decline the discard.
context.SetOutput(false);
return Task.CompletedTask;
});
A test can register its own handler to answer true or false.
With CommunityToolkit.Mvvm: a dialog region #
CommunityToolkit.Mvvm has no interaction type. The SDK's Toolkit examples ask through a second navigation region of the Window, used only for dialogs. The question is a ViewModel, pushed into that region for a result. Read Pages and navigation first.
The CommunityToolkit Notes example creates the region next to the main page region. It starts empty:
public WorkspaceNavigation(RunicNavigator navigator, HomeViewModel home)
{
Main = navigator.CreateRegion<IMainViewModel>(this, NavigationTarget.Borrow<IMainViewModel>(home));
Dialog = navigator.CreateRegion<IDialogViewModel>(this);
}
The navigator constructs the dialog's ViewModel and passes it its
NavigationEntryContext as a constructor parameter, so the ViewModel holds its
entry from the start. Its commands answer through it: CompleteAsync(true)
ends the request with a value, and DismissAsync() ends it without one.
Either leaves the region empty again:
public partial class ConfirmNavigationViewModel(NavigationEntryContext entry, string message) : ObservableObject, IDialogViewModel
{
public string Message => message;
// ...
[RelayCommand]
private Task Confirm() => entry.CompleteAsync(true).AsTask();
[RelayCommand]
private Task Cancel() => entry.DismissAsync().AsTask();
}
A constructor parameter of type NavigationEntryContext is filled by the
navigator, never by the container: a service that asks for one fails to
resolve. Content you create yourself and pass with NavigationTarget.Own or
Borrow exists before its entry, so it receives the context in
INavigationInitialize.InitializeAsync instead.
A command asks with PushForResult. A dialog that takes its entry and a
message in its constructor, as ConfirmNavigationViewModel does, is created
with the factory overload of NavigationTarget.Create: the navigator calls the
factory with the window's services and the new entry's context. The SDK's
ReactiveUI WPF example deletes a note this way:
var answer = await regions.Dialog.PushForResult<bool>(NavigationTarget.Create<ConfirmViewModel>( // [S2]
(_, dialog) => new ConfirmViewModel(dialog, $"Delete '{note.Title}'?")), cancellationToken: token).Completion; // [S2]
if (answer is NavigationCompletion<bool>.Completed { Value: true }) // [S2]
NavigationTarget.Create<TDialog, TInput>(input) is for a dialog that takes
its input in INavigationInitialize<TInput> instead of its constructor.
PushForResult returns a request whose Completion task ends as
NavigationCompletion<bool>.Completed with the value when the dialog called
CompleteAsync, and as Dismissed on every other path: the user cancelled,
the caller's token was cancelled, or the Window closed. So a command acts only
on Completed { Value: true }. Pass the command's cancellation token to
PushForResult; cancelling it also closes the dialog.
The most common question, "discard unsaved changes before leaving?", is ready
made. LeaveConfirmation.InDialog is a departure guard that asks in the
dialog region and discards the changes only when the user confirms:
_leave = LeaveConfirmation.InDialog(navigation.Dialog,
() => NavigationTarget.Create<IDialogViewModel>((_, entry) =>
new ConfirmNavigationViewModel(entry, "Discard the unsaved edits and return Home?")),
() => Editor.IsDirty, Editor.DiscardChanges);
The factory target's second argument is the new entry's context, which the dialog takes in its constructor.
The frontend renders the dialog region like any other ViewModel content: an outlet with one View per dialog kind, hidden while the region is empty. The example's plain TypeScript frontend mounts it into a modal element:
const dialogViews = {
confirmNavigation: mountConfirmNavigation,
} satisfies ViewTemplates<NonNullable<ShellState["dialog"]>>;
...
const unmountDialog = mountContent(modalHost, shell, "dialog", dialogViews, report);
const unsubscribe = shell.subscribe(state => {
modalHost.hidden = state.dialog === null;
});
The dialog View calls the ViewModel's commands, such as dialog.confirm() and
dialog.cancel(), and moves focus into the dialog and back as any modal
should. With React, Vue, Svelte or Angular, use the framework's View outlet
for the dialog region in the same way.
Which one to use #
- ReactiveUI apps: use an
Interaction. It needs no navigation and keeps the question inside the View that is already on screen. With navigation,ReactiveLeaveConfirmation.WithInteractionasks "discard unsaved changes?" through one; see Guard against leaving. - CommunityToolkit.Mvvm apps that use navigation: use a dialog region.
- A CommunityToolkit.Mvvm app without navigation can model the question as ViewModel state: a nullable property holding the question, set by the command, and an answer command that the dialog calls. The SDK has no example of this yet.
Next steps #
- Pages and navigation introduces regions and departure guards.
- Operations and cancellation explains the tokens that end a pending question.
- Open and save files uses native dialogs, which the operating system renders, instead of in-page prompts.