The Practical Client

Architecting Blazor (and Integrating JavaScript)

In a previous column I covered the mechanics of integrating Blazor and JavaScript to support extending existing JavaScript-enabled pages with Blazor code. However, as Blazor takes over more of your page's processing, you're going to need an effective structure for managing your page's C# code. That this also provides a better way to integrate the worlds of Blazor and JavaScript is almost a happy accident. That structure is the same design pattern used in JavaScript in tools like Knockout and Angular: Model-View-ViewModel (MVVM).

Caveats: My sample project was built with Blazor 0.6.0, Visual Studio 2017 15.8.2 and the .NET Core SDK 2.1 (v2.1.403). If you're missing any one of these (or later versions) this code probably won't even compile.

An Architectural Approach
In the MVVM design pattern you wrap all of your Blazor code into a single class (the ViewModel). Instead of binding HTML elements to some random collection of fields, you bind your elements to properties on the ViewModel. You then flesh out your ViewModel with methods that read and update those properties and bind those to events in your HTML.

There are, at least, two benefits to this approach, in addition to organizing your code. First, this positions you to, potentially, automate testing for your page by setting properties on the ViewModel and then calling the ViewModel's methods. If those methods do the right thing, then your page, once it's bound to your ViewModel, will also do the right thing. Second, to integrate with JavaScript, you only have to pass your ViewModel to your JavaScript code, which can then access the methods on the ViewModel to manipulate the page.

As an example, here's a class called ViewModel that exposes a property called Name and a method for changing it. I've marked that method as JSInvokable so that I can, if I choose, call the method from JavaScript code:

public class BlazorViewModel {
  public string Name { get; set; }

  [JSInvokable]
  public void ChangeName(string newName)
  {
    name = newName;            
  }
}

In my Blazor code, I declare a single field to hold my ViewModel. In the OnInitAsync method that is automatically called by Blazor as part of initializing my page, I load that variable with my class, like this:

@functions {
 private BlazorViewModel bm;

  protected override Task OnInitAsync()
  {
    bm = new BlazorModel();

Methods in my ViewModel can also be called from other methods that are built into my Blazor cshtml file (the OnAfterRenderAsync method, for example).

My BlazorViewModel doesn't even have to be in the same file as my Blazor page. In fact, when I put my ViewModel in a separate class file, I'll just need to add a using directive for the JSInvokable attribute on my methods:

using Microsoft.JSInterop;

Binding the ViewModel
In my Blazor file's HTML, I can now access the properties on my ViewModel using the @ sign that, as in a standard cshtml file, signals the start of code. Here's the simplest possible example:

<h1>@bm.Name</h1>

That, however, only gives me one-way databinding that causes changes to the Name property in my ViewModel to, eventually, be reflected in my page. To implement two-way databinding so that my Name property will be automatically updated with data that the user enters, I must add a bind attribute to my element. To bind a textbox element to my name property I use code like this:

<input type="text" id="mytextbox" value="Peter" bind="@bm.Name" />

Now, as users change the value in my textbox, my Name property will be updated.

Using lambda expressions enclosed in parentheses, I can also bind methods in my ViewModel to events triggered by my elements. This code ties my ViewModel's ChangeName method to a button's onclick event:

<input type="button" id="mybutton" value="Set Name to Vogel" 
  onclick='@(()=> bm.ChangeName("Vogel") )' />

Updating the Page
However, with two-way databinding, updating the page from my property isn't completely automatic. Currently and by default, Blazor doesn't update the HTML DOM with every change to a bound element. Instead, Blazor waits for you to call the base BlazorComponent's StateHasChanged method. At that point, Blazor compares the current state of the ElementRefs to the bound elements in the DOM and performs any updates. I can't call the StateHasChanged method directly from my ViewModel because, unlike the code in my Blazor cshtml file, my ViewModel doesn't inherit from BlazorComponent.

Fortunately, fixing this problem is easy (and familiar to anyone who's worked in any .NET desktop application). As your properties are updated, you just need to raise an event that can be caught by your Blazor page, which can then call the StateHasChanged method. The .NET Framework even provides an interface for doing this: INotifyPropertyChanged.

To implement this, I extend my ViewModel with the interface and declare my event:

public class BlazorViewModel : INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged;

I then rewrite my Name property to raise this event whenever the property's value is changed:

public string Name
{
  get { return name; }
  set
  {
    name = value;
    PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs("Name")));
  }
}

The final step is, back in my Blazor cshtml file, to wire up a method that calls StateHasChanged to this event in my ViewModel. Extending the code in OnInitAsync that creates my ViewModel will do the trick:

protected override Task OnInitAsync()
{
  bm = new BlazorViewModel();
  bm.PropertyChanged += NotifyStateChanged;
  return base.OnInitAsync();
}

The actual method that handles notifying Blazor about the change is very simple:

private void NotifyStateChanged(Object bm, EventArgs e)
{
  StateHasChanged();
}

Refactoring the Code
Adding the code that raises my PropertyChanged event to every property in every ViewModel in my application would a pain, however. So, instead, I create a base ViewModel class that implements the INotifyPropertyChanged interface and provides a standard method for raising it:

public class BaseBlazorViewModel : INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged;

  protected void NofifyChanged([CallerMemberName] string propertyName = "")
  {
     PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(propertyName)));
  }
}

My ViewModel class and Name property now shrink to this (and the event declaration disappears from this class):

public class BlazorViewModel : BaseBlazorViewModel
{
  private string name;
  public string Name
  {
    get { return name; }
    set
    {
      name = value;
      NofifyChanged();
    }
  }

With this infrastructure in place, I can continue to extend my ViewModel with the properties and methods my page needs.

Passing the ViewModel to JavaScript
Plus, now I only need to pass one thing to any JavaScript code I want to integrate: my ViewModel. All I need to do is wrap my ViewModel in a DotNetObjectRef object first. Code that would pass my ViewModel to a JavaScript function called InitPage might look like this:

  DotNetObjectRef bmw = new DotNetObjectRef(bm);
  await JSRuntime.Current.InvokeAsync<string>("InitPage", bmw );

A JavaScript InitPage function that would catch that object and use it might look like this:

function InitPage(bmw) 
{
  bmw.invokeMethod("ChangeName", "Vogel");
}

By wrapping your Blazor code into a single ViewModel, you create a firm structure for extending your Blazor page that also supports integrating new Blazor functionality into existing JavaScript-enabled pages.

About the Author

Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.

comments powered by Disqus
Most   Popular
Upcoming Events

.NET Insight

Sign up for our newsletter.

Terms and Privacy Policy consent

I agree to this site's Privacy Policy.