MVVM with Typescript and React for .NET Developers

RickRick
12 min read

A long time ago, around 2008, was when I fully immersed myself in desktop software development with WPF and a framework that died long ago, MEF, along with PRISM, which has continued to evolve.

Windows Presentation Foundation, without fear of being wrong, is the father of MVVM, so to speak, as this pattern and the concept of data binding were introduced with this framework.

I was truly amazed by this UI framework when I had the opportunity to attend a Telerik conference where they showcased their UI components with WPF.

WPF was (and still is) very powerful, and what caught my attention was how the project in the presentation was structured, where the UI was completely empty (without codebehind). I was already familiar with MVVM, but it wasn't until that moment when I decided to deepen my knowledge of this pattern.

After many years making XAML apps for WPF, Silverlight, UWP with MVVM, times change, and I ventured into the world of TypeScript with React. Surprisingly, I realized that very few or almost no one, at least publicly, uses MVVM with this UI framework.

All the examples I found (and still find) are projects with a basic structure that honestly leaves much to be desired, as their level of separation of concerns is limited to the use of folders.

After developing desktop apps for more than a decade, I realized that in the web world, adopting these practices was relatively rare for web developers. Additionally, at the JavaScript/TypeScript level, I saw terrible practices (I still see them) when watching YouTubers and example code.

Although I understand they are examples, I would be quite concerned if these practices are carried over to enterprise-level applications (I fear that is the case).

That's why I decided to create a repository that uses a template for my personal projects as a base:

SrRickGrimes/SaaS-Template-V2

This repository only contains the skeleton of the projects I develop and I don't intend to touch on it in this article.

As I mentioned before, using this repository as a base, I've created a simple demo where I show patterns used in enterprise-level applications. You might say that some applied patterns are too much for the scenario, but the objective is to show how they are applied. It will be up to you whether to apply them depending on your requirements.

Let's Begin by Explaining the Problem to Solve

The best scenario I found is a banking loan app, where typically the fields to fill out are endless. I've seen applications with up to 1,000 properties divided among 60 or 70 forms.

The question is: how could we develop an app that is scalable, maintainable, and easy to understand?

This is where MVVM shines by itself, as it was created for these complex scenarios where we require a clear separation of responsibilities. By applying some advanced techniques, we can have a clean solution, and although it may grow over time, the technical debt will be manageable.

The Requirements

The requirements are basic. We need to create a Stepper where this stepper can have n forms. These forms can change order or no longer be required as time passes, or we can also add more.

If we think about it in React style, I can already imagine using React Context with some other State framework, etc., which could make sense. But the key here is that the business logic and everything we write in our forms should be agnostic of React. This principle is very useful, especially in a JavaScript ecosystem where a new framework comes out every weekend. Today it's React, next month it's Vue, and the month after that, it's Svelte or some other.

The JavaScript ecosystem is very unstable, so it's extremely important that enterprise applications and their business rules are decoupled from the UI of the moment.

What is MVVM?

MVVM stands for Model - View - ViewModel

Model

In simple words, the Model is our class with properties to encapsulate the data of our app, generally POCOs. These models are typically used with services, repositories, etc.

ViewModel

The ViewModel implements properties and commands with which the view can connect through data binding, and it notifies the view of any state change that occurs through notification events.

The commands provide functionality that the UI can use to determine the functionality that will be displayed in the UI.

View

The view is responsible for defining the structure, layout, and appearance of what the user sees on the screen. It does not contain business logic; however, in some cases, it could contain code-behind specific to the UI framework used, such as animations.

There is extensive documentation and many examples for .NET with WPF/UWP and other XAML-based frameworks.

To my surprise, the examples I found in TypeScript are very few or too basic, or in my humble opinion, they don't follow the principles of MVVM because they add UI functionalities within the ViewModels, breaking several principles.

The Loan Manager Wizard Components

The Loan Manager Wizard consists of several sections:

  1. Personal Information: Client's personal information

  2. Bank Information: Bank information

  3. Loan Details: Loan details

BankInformation Model

import { BehaviorSubject } from "rxjs";

export class BankInformation {
  private bankNameSubject = new BehaviorSubject<string>("");
  private accountTypeSubject = new BehaviorSubject<string>("");
  private accountNumberSubject = new BehaviorSubject<string>("");

  public bankName$ = this.bankNameSubject.asObservable();
  public accountType$ = this.accountTypeSubject.asObservable();
  public accountNumber$ = this.accountNumberSubject.asObservable();

  constructor(data?: { bankName?: string; accountType?: string; accountNumber?: string }) {
    if (data?.bankName) this.bankNameSubject.next(data.bankName);
    if (data?.accountType) this.accountTypeSubject.next(data.accountType);
    if (data?.accountNumber) this.accountNumberSubject.next(data.accountNumber);
  }

  setBankName(bankName: string): void {
    this.bankNameSubject.next(bankName);
  }

  setAccountType(accountType: string): void {
    this.accountTypeSubject.next(accountType);
  }

  setAccountNumber(accountNumber: string): void {
    this.accountNumberSubject.next(accountNumber);
  }

  get bankName(): string {
    return this.bankNameSubject.value;
  }

  get accountType(): string {
    return this.accountTypeSubject.value;
  }

  get accountNumber(): string {
    return this.accountNumberSubject.value;
  }
}

LoanDetails Model

import { BehaviorSubject } from "rxjs";

export class LoanDetails {
  private loanAmountSubject = new BehaviorSubject<number>(0);
  private loanTermSubject = new BehaviorSubject<number>(0);
  private loanPurposeSubject = new BehaviorSubject<number>(0);

  public loanAmount$ = this.loanAmountSubject.asObservable();
  public loanTerm$ = this.loanTermSubject.asObservable();
  public loanPurpose$ = this.loanPurposeSubject.asObservable();

  constructor(data?: { loanAmount?: number; loanTerm?: number; loanPurpose?: number }) {
    if (data?.loanAmount) this.loanAmountSubject.next(data.loanAmount);
    if (data?.loanTerm) this.loanTermSubject.next(data.loanTerm);
    if (data?.loanPurpose) this.loanPurposeSubject.next(data.loanPurpose);
  }

  setLoanAmount(loanAmount: number): void {
    this.loanAmountSubject.next(loanAmount);
  }

  setLoanTerm(loanTerm: number): void {
    this.loanTermSubject.next(loanTerm);
  }

  setLoanPurpose(loanPurpose: number): void {
    this.loanPurposeSubject.next(loanPurpose);
  }

  get loanAmount(): number {
    return this.loanAmountSubject.value;
  }

  get loanTerm(): number {
    return this.loanTermSubject.value;
  }

  get loanPurpose(): number {
    return this.loanPurposeSubject.value;
  }
}

PersonalInformation Model

import { BehaviorSubject } from "rxjs";

export class PersonalInformation {
  private fullNameSubject = new BehaviorSubject<string>('');
  private dateOfBirthSubject = new BehaviorSubject<string>('');
  private emailSubject = new BehaviorSubject<string>('');

  public fullName$ = this.fullNameSubject.asObservable();
  public dateOfBirth$ = this.dateOfBirthSubject.asObservable();
  public email$ = this.emailSubject.asObservable();

  constructor(data?: { fullName?: string; dateOfBirth?: string; email?: string }) {
    if (data?.fullName) this.fullNameSubject.next(data.fullName);
    if (data?.dateOfBirth) this.dateOfBirthSubject.next(data.dateOfBirth);
    if (data?.email) this.emailSubject.next(data.email);
  }

  get fullName(): string {
    return this.fullNameSubject.value;
  }

  get dateOfBirth(): string {
    return this.dateOfBirthSubject.value;
  }

  get email(): string {
    return this.emailSubject.value;
  }

  setFullName(fullName: string): void {
    this.fullNameSubject.next(fullName);
  }

  setDateOfBirth(dateOfBirth: string): void {
    this.dateOfBirthSubject.next(dateOfBirth);
  }

  setEmail(email: string): void {
    this.emailSubject.next(email);
  }
}

If you review the models, dear reader, you will have noticed that I'm using RxJs. This library is in charge of helping us with the notifications that our model needs to make to know that its state has changed.

In .NET with WPF, we have the INotifyPropertyChanged interface, and WPF and XAML-based apps (also Blazor) include Binding out of the box.

For our manager, we have to do it manually, that's why I'm using RxJs.

The code for the models shouldn't be complex to understand as we only have some observables for each property with its getter and setter.

Writing the ViewModel

In this case, the ViewModels of each section of the Stepper have a main function, which is to validate that the model is always in a Valid state.

import { BehaviorSubject, combineLatest, Observable } from "rxjs";
import { map, debounceTime } from "rxjs/operators";
import type { IStepViewModel } from "~/interfaces/IStepViewModel";
import type { IValidationError } from "~/interfaces/IValidationError";
import { BankInformation } from "~/models/bankInformation";
import { AccountNumberFormatByTypeSpecification } from "~/specifications/BankInformationSpecifications/AccountNumberFormatByTypeSpecification";
import { AccountNumberSpecification } from "~/specifications/BankInformationSpecifications/AccountNumberSpecification";
import { AccountTypeSpecification } from "~/specifications/BankInformationSpecifications/AccountTypeSpecification";
import { BankNameSpecification } from "~/specifications/BankInformationSpecifications/BankNameSpecification";
import type { ISpecification } from "~/specifications/ISpecification";

export class BankInformationViewModel implements IStepViewModel<BankInformation> {
  private model: BankInformation;
  private _errors: BehaviorSubject<IValidationError[]>;
  private formSpecification: ISpecification<BankInformation>;

  public data$: Observable<BankInformation>;
  public errors$: Observable<IValidationError[]>;
  public isValid$: Observable<boolean>;

  constructor() {
    this.model = new BankInformation();
    this._errors = new BehaviorSubject<IValidationError[]>([]);

    // Create individual specifications
    const bankNameSpec = new BankNameSpecification();
    const accountTypeSpec = new AccountTypeSpecification();
    const accountNumberSpec = new AccountNumberSpecification();
    const accountNumberFormatSpec = new AccountNumberFormatByTypeSpecification();

    // Combine specifications for the whole form
    this.formSpecification = bankNameSpec
      .and(accountTypeSpec)
      .and(accountNumberSpec)
      .and(accountNumberFormatSpec);

    // Set up data observable
    this.data$ = combineLatest([
      this.model.bankName$,
      this.model.accountType$,
      this.model.accountNumber$,
    ]).pipe(
      map(([bankName, accountType, accountNumber]) => {
        return new BankInformation({ bankName, accountType, accountNumber });
      })
    );

    // Set up errors observable
    this.errors$ = this._errors.asObservable();

    // Set up validity observable
    this.isValid$ = this.errors$.pipe(
      map(errors => errors.length === 0)
    );

    // Set up automatic validation when data changes
    this.data$.pipe(
      debounceTime(300) // Wait 300ms after the last change before validating
    ).subscribe(() => {
      this.validate();
    });

    // Run initial validation
    this.validate();
  }

  updateData(data: Partial<BankInformation>): void {
    if (data.bankName !== undefined) this.model.setBankName(data.bankName);
    if (data.accountType !== undefined) this.model.setAccountType(data.accountType);
    if (data.accountNumber !== undefined) this.model.setAccountNumber(data.accountNumber);
  }

  updateBankName(bankName: string): void {
    this.model.setBankName(bankName);
  }

  updateAccountType(accountType: string): void {
    this.model.setAccountType(accountType);
  }

  updateAccountNumber(accountNumber: string): void {
    this.model.setAccountNumber(accountNumber);
  }

  validate(): void {
    // Use the check method from the combined specification
    const validationResult = this.formSpecification.check(this.model);
    // Map validation errors to the expected format
    const errors: IValidationError[] = validationResult.errors.map(error => ({
      field: error.field,
      message: error.message
    }));
    // Update the errors subject
    this._errors.next(errors);
  }
}

As you will see, the ViewModel offers 3 key properties:

  1. Data: The model used by the ViewModel and where we can access the information created by the user.

  2. Errors: The list of validation errors produced by the ViewModel, and these validations will be shown in the UI.

  3. IsValid: If the ViewModel is in a valid state.

If you look more closely, I have implemented the specification pattern to validate each form.

I don't intend to go into details about what the specification pattern is and what it's used for.

But thanks to this, we can encapsulate the validation logic in different classes, removing that responsibility from the ViewModel (following SOLID principles).

Here is a specification example:

import { ValidationResult } from "~/helpers/validationResult";
import type { BankInformation } from "~/models/bankInformation";
import { Specification } from "../Specification";

export class BankNameSpecification extends Specification<BankInformation> {
  constructor(private minLength: number = 2) {
    super();
  }

  isSatisfiedBy(candidate: BankInformation): boolean {
    return candidate.bankName.length >= this.minLength;
  }

  check(candidate: BankInformation): ValidationResult {
    if (!candidate.bankName || candidate.bankName.trim().length === 0) {
      return ValidationResult.invalid('bankName', 'Bank name is required');
    }

    if (candidate.bankName.length < this.minLength) {
      return ValidationResult.invalid('bankName', `Bank name must be at least ${this.minLength} characters`);
    }

    return ValidationResult.valid();
  }
}

As you can see, the implementation is very simple and easy to understand, without using third-party libraries or dependencies, purely applying MVVM and following the principle that our implementation should be agnostic of the UI. That's the main reason I've opted for specifications.

Now, using Fluent Specifications design (Fluent API Design Pattern), we can combine the specifications to validate the entire form:

this.formSpecification = bankNameSpec
  .and(accountTypeSpec)
  .and(accountNumberSpec)
  .and(accountNumberFormatSpec);

Finally, in our Validate method, we call our formSpecification, and it will validate the entire form, and we only update the errors collection with the errors produced:

validate(): void {
  // Use the check method from the combined specification
  const validationResult = this.formSpecification.check(this.model);
  // Map validation errors to the expected format
  const errors: IValidationError[] = validationResult.errors.map(error => ({
    field: error.field,
    message: error.message
  }));
  // Update the errors subject
  this._errors.next(errors);
}

Connecting the View with the ViewModel

This React component is only subscribing to the notifications produced by Data from our ViewModel. Using React's useState, every time a change is produced, we update the bankInfo object, and this propagates the changes in the form components using normal React:

import { useEffect, useState, type FC } from "react";
import type { IStepViewModel } from "~/interfaces/IStepViewModel";
import type { IValidationError } from "~/interfaces/IValidationError";
import { BankInformation } from "~/models/bankInformation";

interface BankInformationViewProps {
  viewModel: IStepViewModel<BankInformation>;
}

const BankInformationView: FC<BankInformationViewProps> = ({ viewModel }) => {
  const [bankInfo, setBankInfo] = useState<BankInformation>(new BankInformation());
  const [errors, setErrors] = useState<IValidationError[]>([]);

  useEffect(() => {
    // Subscribe to data changes
    const dataSubscription = viewModel.data$.subscribe((info) => {
      setBankInfo(info);
    });
    // Subscribe to validation errors
    const errorsSubscription = viewModel.errors$.subscribe((validationErrors) => {
      setErrors(validationErrors);
    });

    // Force an initial validation
    if (typeof viewModel.validate === 'function') {
      viewModel.validate();
    }

    return () => {
      dataSubscription.unsubscribe();
      errorsSubscription.unsubscribe();
    };
  }, [viewModel]);

  // Helper function to get error for a specific field
  const getFieldError = (fieldName: string): string | null => {
    const error = errors.find(e => e.field === fieldName);
    return error ? error.message : null;
  };

  const handleBankNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    viewModel.updateData({ bankName: e.target.value });
  };

  const handleAccountTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    viewModel.updateData({ accountType: e.target.value });
  };

  const handleAccountNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    viewModel.updateData({ accountNumber: e.target.value });
  };

  return (
    <div className="space-y-4">
      <h2 className="text-xl font-semibold">Bank Information</h2>
      {/* Bank Name Field */}
      <div className="form-control">
        <label className="label">
          <span className="label-text">Bank Name</span>
        </label>
        <input
          type="text"
          placeholder="Bank Name"
          value={bankInfo.bankName}
          onChange={handleBankNameChange}
          className={`input input-bordered w-full ${getFieldError('bankName') ? 'border-red-500' : ''}`}
        />
        {getFieldError('bankName') && (
          <div className="text-red-500 text-sm mt-1">{getFieldError('bankName')}</div>
        )}
      </div>
      {/* Account Type Field */}
      <div className="form-control">
        <label className="label">
          <span className="label-text">Account Type</span>
        </label>
        <select
          value={bankInfo.accountType}
          onChange={handleAccountTypeChange}
          className={`select select-bordered w-full ${getFieldError('accountType') ? 'border-red-500' : ''}`}
        >
          <option value="">Select account type</option>
          <option value="checking">Checking</option>
          <option value="savings">Savings</option>
          <option value="credit">Credit</option>
        </select>
        {getFieldError('accountType') && (
          <div className="text-red-500 text-sm mt-1">{getFieldError('accountType')}</div>
        )}
      </div>
      {/* Account Number Field */}
      <div className="form-control">
        <label className="label">
          <span className="label-text">Account Number</span>
        </label>
        <input
          type="text"
          placeholder="Account Number"
          value={bankInfo.accountNumber}
          onChange={handleAccountNumberChange}
          className={`input input-bordered w-full ${getFieldError('accountNumber') ? 'border-red-500' : ''}`}
        />
        {getFieldError('accountNumber') && (
          <div className="text-red-500 text-sm mt-1">{getFieldError('accountNumber')}</div>
        )}
        <div className="text-gray-500 text-xs mt-1">
          {bankInfo.accountType === 'checking' && 'Checking accounts must start with 4 or 5'}
          {bankInfo.accountType === 'savings' && 'Savings accounts must start with 1 or 2'}
          {bankInfo.accountType === 'credit' && 'Credit accounts must start with 9'}
        </div>
      </div>
    </div>
  );
};

export default BankInformationView;

As you can see, MVVM is a very powerful pattern, and highly scalable and maintainable apps can be developed as long as the principles are followed correctly.

In the next article, I will cover other aspects of the same repository that are not mentioned in this article.

Here is the repository where you can find the implementation: SrRickGrimes/SaaS-Template-V2

Conclusion

The MVVM pattern, originally popularized in the desktop application world with WPF, can be successfully applied to modern web development with React. By keeping our business logic agnostic from the UI framework and using advanced patterns like Specification for validation, we can build scalable applications that are easier to maintain over time. The key benefits include:

  1. Clear separation of concerns

  2. Testable business logic independent of UI

  3. Reactive state management with RxJs

  4. Composable validation using the Specification pattern

  5. UI framework independence for core business rules

While this approach might seem more complex initially compared to typical React examples found online, it pays dividends as the application grows in complexity, especially for enterprise applications with numerous forms and complex validation rules.

0
Subscribe to my newsletter

Read articles from Rick directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rick
Rick

15+ years of experience having fun building apps with .NET I began my professional career in 2006, using Microsoft technologies where C# and Windows Forms and WPF were the first technologies I started working with during that time. I had the opportunity to actively participate in the Windows ecosystem as an MVP and Windows 8/Windows Phone application developer from 2013-2018. Throughout my career, I have used Azure as my default cloud platform and have primarily worked with technologies like ASP.NET Core for multiple companies globally across the US, UK, Korea, Japan, and Latin America. I have extensive experience with frameworks such as: ASP.NET Core Microsoft Orleans WPF UWP React with TypeScript Reactive Extensions Blazor I am an entrepreneur, speaker, and love traveling the world. I created this blog to share my experience with new generations and to publish all the technical resources that I had been writing privately, now made public as a contribution to enrich the ecosystem in which I have developed my career.