Mastering MVVM with TypeScript and React: Part 2 - Parent ViewModels

RickRick
7 min read

This article is a continuation of the article MVVM with TypeScript and React.

There are countless topics to cover when discussing MVVM, especially when using TypeScript, because there's essentially no documentation or examples for this approach in the TypeScript ecosystem.

In this blog, I'll gradually try to change that. Although this blog is primarily for .NET developers, TypeScript is essential in my stack for frontend development, so I'll also cover many aspects of it as I work on different applications.

Problem to Solve

In the previous post, we built a Stepper where each step has its own View and ViewModel. So far so good, but this approach presents several challenges:

  • How can I access the data from each ViewModel to build the request that we'll send to the backend?

  • How can I determine if any ViewModel is in an invalid state?

  • How can I add more ViewModels to the Stepper without affecting the existing logic?

  • How can I change their order?

  • How can I remove them if necessary?

As you can see, dear reader, there are quite a few scenarios we need to cover if we want our Stepper to be flexible, scalable, and above all, maintainable in the long term.

The Solution: Parent ViewModel

To solve this problem, we'll use a concept called the Parent ViewModel.

What is a Parent ViewModel?

As the name suggests, it's a ViewModel that acts as an orchestrator. Its job is to read the list of ViewModels that the Stepper will have and dynamically display them in the UI.

The child ViewModels (each section of the Stepper) don't know how they're being used and have no idea that other ViewModels exist. They are completely isolated, following principles of composition. This way, we can build components in a Lego-like fashion, block by block.

To achieve this, we first need to have a list of the ViewModels we want to display in our Stepper.

I've created a registry where we'll indicate the list of ViewModels we want to deploy in our Stepper:

import type { IStepViewModel } from "~/interfaces/IStepViewModel";
import { PersonalInformationViewModel } from "~/viewModels/personalInformationViewModel";
import { LoanDetailsViewModel } from "~/viewModels/loanDetailsViewModel";
import { BankInformationViewModel } from "~/viewModels/bankInformationViewModel";
import PersonalInformationView from "~/views/personalInformationView";
import LoanDetailsView from "~/views/loanDetailsView";
import BankInformationView from "~/views/bankInformationView";

type StepRegistry = {
  [key: string]: {
    viewModelClass: new () => IStepViewModel<any>;
    component: React.ComponentType<{ viewModel: IStepViewModel<any> }>;
  };
};

export const stepRegistry: StepRegistry = {
  personalInfo: {
    viewModelClass: PersonalInformationViewModel,
    component: PersonalInformationView,
  },
  loanDetails: {
    viewModelClass: LoanDetailsViewModel,
    component: LoanDetailsView,
  },
  bankInfo: {
    viewModelClass: BankInformationViewModel,
    component: BankInformationView,
  },
};

This registry serves as a central configuration point for our Stepper. Each entry maps a unique key to an object containing both the ViewModel class and its corresponding View component. This approach provides several advantages:

  1. It decouples the definition of our steps from their implementation

  2. It makes the order of steps explicit and easily modifiable

  3. It allows for dynamic step management (adding, removing, or reordering steps)

Standardizing ViewModel Structure

To standardize the structure of the ViewModels and make it easy for the Parent ViewModel and UI to access the required information from all ViewModels, I've created new interfaces and extended the functionality of some existing ones.

IViewModel

This generic interface has been extended where T is the object we're going to validate. The concept is straightforward:

import { Observable } from "rxjs";
import type { IValidationError } from "./IValidationError";

export interface IViewModel<T> {
  data$: Observable<T>;
  errors$: Observable<IValidationError[]>;
  updateData(data: Partial<T>): void;
  validate(): void;
}

The interface uses RxJS observables to expose reactive streams of both the data and validation errors. This reactive approach is central to the MVVM pattern, allowing the View to automatically update when the underlying data or validation state changes. The updateData method allows for partial updates to the model, while validate triggers validation of the current state.

IValidationError

This interface indicates the structure our validation errors will have:

export interface IValidationError {
  field: string;
  message: string;
}

By standardizing our validation errors with a consistent structure, we enable uniform error handling across all ViewModels. The field property identifies which specific field has the error, while the message provides user-friendly information about the validation failure.

IStepViewModel

This interface is a marker that will help the Parent ViewModel identify the different ViewModels that will be deployed in the Stepper:

import type { IViewModel } from "./IViewModel";

export interface IStepViewModel<T> extends IViewModel<T> {
}

While this interface doesn't add additional methods, it serves an important architectural purpose by clearly indicating which ViewModels are designed to work as steps in our Stepper. This kind of type-based classification enhances code readability and maintainability.

Parent ViewModel Implementation

The Parent ViewModel reads the ViewModel registry, creates an instance of each ViewModel (we could use dependency injection to construct the ViewModel), and can move steps forward, backward, and get the current ViewModel.

The Parent ViewModel persists the instance of each of the ViewModels to prevent React from creating new instances of them on each render:

import { BehaviorSubject, Observable } from "rxjs";
import { map } from "rxjs/operators";
import { stepRegistry } from "~/helpers/stepRegistry";
import type { IStepViewModel } from "~/interfaces/IStepViewModel";

export class ParentViewModel {
  private viewModels: Array<IStepViewModel<any>> = [];
  private currentStepSubject = new BehaviorSubject<number>(0);

  public currentStep$ = this.currentStepSubject.asObservable();
  public currentViewModel$: Observable<IStepViewModel<any>>;

  constructor() {
    // we could use DI to inject the view models, but for now we'll just use the registry
    this.viewModels = Object.values(stepRegistry).map((entry) => new entry.viewModelClass());
    this.currentViewModel$ = this.currentStep$.pipe(
      map((step) => this.viewModels[step])
    );
  }

  nextStep(): void {
    const currentStep = this.currentStepSubject.value;
    if (currentStep < this.viewModels.length - 1) {
      this.currentStepSubject.next(currentStep + 1);
    }
  }

  previousStep(): void {
    const currentStep = this.currentStepSubject.value;
    if (currentStep > 0) {
      this.currentStepSubject.next(currentStep - 1);
    }
  }

  getCurrentStep(): number {
    return this.currentStepSubject.value;
  }

  getViewModel(step: number): IStepViewModel<any> | undefined {
    return this.viewModels[step];
  }
}

The Parent ViewModel uses RxJS's BehaviorSubject to maintain the current step index and expose it as an observable. This reactive approach allows components to subscribe to step changes and automatically update when the step changes.

Another key implementation detail is the currentViewModel$ observable, which provides a stream of the current ViewModel based on the current step. This makes it easy for the View to always have access to the appropriate ViewModel without having to manually determine which one is active.

Parent View Implementation

The Parent View persists the instance of Parent ViewModel using useRef. Thanks to this, we'll have only one instance of this ViewModel and, in turn, one instance of each ViewModel in the Stepper, thus preventing React from creating a new instance on each render.

This component subscribes to the currentStep$ property at the component's initialization:

useEffect(() => {
  subscription = parentViewModel.currentStep$.subscribe((step) => {
    setCurrentStep(step);
  });

  return () => {
    if (subscription) {
      subscription.unsubscribe();
    }
  };
}, []);

Here's where the magic happens:

As the user moves through the Stepper, we obtain the ViewModel that corresponds to each step:

const currentEntry = Object.values(stepRegistry)[currentStep];
const CurrentComponent = currentEntry.component;
const currentViewModel = parentViewModel.getViewModel(currentStep);

Finally, we render the Component:

<CurrentComponent viewModel={currentViewModel} />

Benefits of This Approach

With this technique, to add, remove, or change ViewModels, all I have to do is go to the registry and modify the ViewModels that will be displayed. This provides exceptional flexibility without having to touch the core implementation of the Stepper component.

I could even extend the registry to also be a ViewModel and dynamically add ViewModels using dependency injection or some other strategy.

The separation of concerns here is particularly strong:

  1. Each ViewModel handles only its own data and validation logic

  2. The Parent ViewModel orchestrates the flow between steps without knowing the details of each step

  3. The Views focus solely on rendering the UI based on the ViewModel state

  4. The registry serves as a centralized configuration point

This approach also makes testing much easier, as each component can be tested in isolation.

Conclusion

The Parent ViewModel pattern offers a powerful solution for complex multi-step interfaces in React applications. By leveraging TypeScript's type system and the principles of the MVVM pattern, we can create highly modular, maintainable, and extensible components.

This approach particularly shines in enterprise applications where forms may need to evolve over time, with steps being added, removed, or reordered. The registry-based configuration makes these changes trivial, without requiring changes to the core implementation.

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.