Action
The Action pattern enables ViewModels to delegate interactive tasks to child components and await their results.
This decouples ViewModels from the UI implementation details of user interactions.
| Use Case | Description |
|---|---|
| Form Submission | Collect and validate user input |
| Modal Dialogs | Present choices and await user decisions |
| Confirmation Prompts | Request user confirmation before actions (e.g., deletion) |
The Action<T> interface defines the contract for components that execute user-initiated operations:
interface Action<T> {
onAction(ctx: ActionContext<T>): void | Promise<void>;
}Implementations typically store the ActionContext reference to resolve it later when the user interaction completes (via form submission, dialog button click, etc.).
The ActionContext<T> class allows Action implementations to resolve with success or failure. It enforces single-resolution semantics to prevent race conditions.
The ActionResult<T> discriminated union type represents the outcome of an action.
Implementing Actions
Step 1: Implement the Action interface
interface LoginData {
username: string;
password: string;
keepLoggedIn: boolean;
}
export class MyFormControlModel extends UserControl implements Action<LoginData> {
private actionContext: ActionContext<LoginData> | null = null;
public onAction(ctx: ActionContext<LoginData>): void {
this.actionContext = ctx;
// optionally reset form state, focus first input, etc.
}
}Step 2: Handling User Interaction
interface LoginData {
username: string;
password: string;
keepLoggedIn: boolean;
}
export class MyFormControlModel extends UserControl implements Action<LoginData> {
private actionContext: ActionContext<LoginData> | null = null;
public username: string = this.ref("");
public password: string = this.ref("");
public keepLoggedIn: string = this.ref(false);
public onAction(ctx: ActionContext<LoginData>): void {
this.actionContext = ctx;
// optionally reset form state, focus first input, etc.
}
public onSubmit() {
if (!this.actionContext) {
return;
}
// optionally validate data
this.actionContext.completeAction({
username: this.username,
password: this.password,
keepLoggedIn: this.keepLoggedIn
});
}
public onCancel() {
if (!this.actionContext) {
return;
}
this.actionContext.failAction(new Error("User cancelled"));
}
}
<template>
<form @submit.prevent="vm.onSubmit">
<!-- form fields -->
<button type="submit">Submit</button>
<button type="button" @click="vm.onCancel">Cancel</button>
</form>
</template>
<script setup lang="ts">
const vm = useUserControl(MyFormControlModel);
</script>Step 3: Run Action
class MainViewModel extends ViewModel {
private readonly myFormControl: MyFormControlModel | null = this.getUserControl("myFormControl");
public async onLoginBtn(): Promise<void> {
if (!this.myFormControl) {
return;
}
const result: ActionResult<LoginData> = await this.runAction(this.myFormControl);
if (result.success) {
// perform login request
return;
}
// perform error handling
}
}
<template>
<MyFormControl ref="myFormControl"/>
<button @click="vm.onLoginBtn">
Start login
</button>
</template>
<script setup lang="ts">
const vm = useViewModel(MainViewModel);
</script>INFO
It is also possible to implement the Action interface on a class or object that neither is a UserControl or a ViewModel.
For example it can be used to collect multiple API request of the same route and resolve all at once, so that in the end only one http-request was actually made.
Multiple Execution Contexts
Each runAction call creates a fresh ActionContext. Important is that the Action implementation decides how to handle multiple contexts.
Mostly one of the following three implementations will be used.
First call wins
export class MyFormControlModel extends UserControl implements Action<LoginData> {
private actionContext: ActionContext | null = null;
public onAction(ctx: ActionContext) {
if (this.actionContext) {
ctx.failAction("Action already running");
return;
}
this.actionContext = ctx;
}
}We reject all incoming actions until the current action is completed.
Last call wins
export class MyFormControlModel extends UserControl implements Action<LoginData> {
private actionContext: ActionContext | null = null;
public onAction(ctx: ActionContext) {
if (this.actionContext) {
this.actionContext.failAction("Action was canceled do to a second action incoming");
}
this.actionContext = ctx;
}
}We reject the current running action when a new action request occours
Collect actions
export class MyFormControlModel extends UserControl implements Action<LoginData> {
private actionContexts: ActionContext[] = [];
public onAction(ctx: ActionContext) {
this.actionContexts.push(ctx);
}
}We collect all incoming actions and complete them all at once, when the action is complete.
INFO
Regardles of which implementation is used, it is important that after the action is completed that the action object is cleared away, e.g. by setting it to null or remove it out of the array.