Micro Frontends in Action With ASP.NET Core - Communication Patterns for Blazor WebAssembly Based Web Components

I'm continuing my series on implementing the Micro Frontends in Action samples in ASP.NET Core, and I'm continuing the subject of Blazor WebAssembly based Web Components. In the previous post, the project has been expanded with a new service that provides its fronted fragment as a Custom Element power by Blazor WebAssembly. In this post, I will explore how Custom Elements can communicate with other frontend parts.

There are three communication scenarios I would like to explore: passing information from page to Custom Element (parent to child), passing information from Custom Element to page (child to parent), and passing information between Custom Elements (child to child). Let's go through them one by one.

Page to Custom Element

When it comes to passing information from page to Custom Element, there is a standard approach that every web developer will expect. If I want to disable a button, I set an attribute. If I want to change the text on a button, I set an attribute. In general, if I want to change the state of an element, I set an attribute. The same expectation applies to Custom Elements. How to achieve that?

As mentioned in the previous post, the ES6 class, which represents a Custom Element, can implement a set of lifecycle methods. One of these methods is attributeChangedCallback. It will be invoked each time an attribute from a specified list is added, removed, or its value is changed. The list of the attributes which will result in invoking the attributeChangedCallback is defined by a value returned from observedAttributes static get method.

So, in the case of Custom Elements implemented in JavaScript, one has to implement the observedAttributes to return an array of attributes that can modify the state of the Custom Element and implement the attributeChangedCallback to modify that state. Once again, you will be happy to know that all this work has already been done in the case of Blazor WebAssembly. The Microsoft.AspNetCore.Components.CustomElements package, which wraps Blazor components as Custom Elements handles that. It provides an implementation of observedAttributes which returns all the properties marked as parameters, and an implementation of attributeChangedCallback which will update parameters values and give the component a chance to rerender. That makes the implementation quite simple.

I've added a new property named Edition to the BuyButton component, which I created in the previous post. The new property impacts the price depending if the client has chosen a standard or platinum edition. I've also marked the new property as a parameter.

<button type="button" @onclick="OnButtonClick">
    buy for @(String.IsNullOrWhiteSpace(Sku) || String.IsNullOrWhiteSpace(Edition)  ? "???" : _prices[Sku][Edition])
</button>
...

@code {
    private IDictionary<string, Dictionary<string, int>> _prices = new Dictionary<string, Dictionary<string, int>>
    {
        { "porsche", new Dictionary<string, int> { { "standard", 66 }, { "platinum", 966 } } },
        { "fendt", new Dictionary<string, int> { { "standard", 54 }, { "platinum", 945 } }  },
        { "eicher", new Dictionary<string, int> { { "standard", 58 }, { "platinum", 958 } }  }
    };

    [Parameter]
    public string? Sku { get; set; }

    [Parameter]
    public string? Edition { get; set; }

    ...
}

This should be all from the component perspective. The rest should be only about using the attribute representing the property. First, I've added it to the markup served by the Decide service with the default value. I've also added a checkbox that allows choosing the edition.

<html>
    ...
    <body class="decide_layout">
        ...
        <div class="decide_details">
            <label class="decide_editions">
                <p>Material Upgrade?</p>
                <input type="checkbox" name="edition" value="platinum" />
                <span>Platinum<br />Edition</span>
                <img src="https://mi-fr.org/img/porsche_platinum.svg" />
            </label>
            <checkout-buy sku="porsche" edition="standard"></checkout-buy>
        </div>
        ...
    </body>
</html>

Then I implemented an event handler for the change event of that checkbox, where depending on its state, I would change the value of the edition attribute on the custom element.

(function() {
    ...
    const editionsInput = document.querySelector(".decide_editions input");
    ...
    const buyButton = document.querySelector("checkout-buy");

    ...

    editionsInput.addEventListener("change", e => {
        const edition = e.target.checked ? "platinum" : "standard";
        buyButton.setAttribute("edition", edition);
        ...
    });
})();

It worked without any issues. Checking and unchecking the checkbox would result in nicely displaying different prices on the button.

Custom Element to Page

The situation with passing information from Custom Element to the page is similar to passing information from page to Custom Element - there is an expected standard mechanism: events. If something important has occurred internally in the Custom Element and the external world should know about it, Custom Element should raise an event to which whoever is interested can subscribe.

How to raise a JavaScript event from Blazor? This requires calling a JavaScript function which will wrap a call to dispatchEvent. Why can't dispatchEvent be called directly? That's because Blazor requires function identifier to be relative to the global scope, while dispatchEvent needs to be called on an instance of an element. This raises another challenge. Our wrapper function will require a reference to the Custom Element. Blazor supports capturing references to elements to pass them to JavaScript. The @ref attribute can be included in HTML element markup, resulting in a reference being stored in the variable it is pointing to. This means that the reference to the Custom Element itself can't be passed directly, but a reference to its child element can.

I've written a wrapper function that takes the reference to the button element (but it could be any direct child of the Custom Element) as a parameter and then calls dispatchEvent on its parent.

window.checkout = (function () {
    return {
        dispatchItemAddedEvent: function (checkoutBuyChildElement) {
            checkoutBuyChildElement.parentElement.dispatchEvent(new CustomEvent("checkout:item_added"));
        }
    };
})();

I wanted the event to be raised when the button has been clicked, so I've modified the OnButtonClick to use injected IJSRuntime to call my JavaScript function. In the below code, you can also see the @ref attribute in action and how I'm passing that element reference to the wrapper function.

@using Microsoft.JSInterop

@inject IJSRuntime JS

<button type="button" @ref="_buttonElement" @onclick="OnButtonClick">
    buy for @(String.IsNullOrWhiteSpace(Sku) || String.IsNullOrWhiteSpace(Edition)  ? "???" : _prices[Sku][Edition])
</button>
...

@code {
    private ElementReference _buttonElement;

    ...

    private async Task OnButtonClick(MouseEventArgs e)
    {
        ...

        await JS.InvokeVoidAsync("checkout.dispatchItemAddedEvent", _buttonElement);
    }

    ...
}

For the whole thing to work, I had to reference the JavaScript from the Decide service markup so that the wrapper function could be called.

<html>
    ...
    <body class="decide_layout">
        ...
        <script src="/checkout/static/components.js"></script>
        <script src="/checkout/_content/Microsoft.AspNetCore.Components.CustomElements/BlazorCustomElements.js"></script>
        ...
    </body>
</html>

Now I could subscribe to the checkout:item_added event and add some bells and whistles whenever it's raised.

(function() {
    ...
    const productElement = document.querySelector(".decide_product");
    const buyButton = document.querySelector("checkout-buy");

    ...

    buyButton.addEventListener("checkout:item_added", e => {
        productElement.classList.add("decide_product--confirm");
    });

    ...
})();

Custom Element to Custom Element

Passing information between Custom Elements is where things get interesting. That is because there is no direct relation between Custom Elements. Let's assume that the Checkout service exposes a second Custom Element which provides a cart representation. The checkout button and mini-cart don't have to be used together. There might be a scenario where only one of them is present, or there might be scenarios where there are rendered by independent parents.

Of course, everything is happening in the browser's context, so there is always an option to search through the entire DOM tree. This is an approach that should be avoided. First, it's tight coupling, as it requires Custom Element to have detailed knowledge about other Custom Element. Second, it wouldn't scale. What if there are ten different types of Custom Elements to which information should be passed? That would require ten different searches.

Another option is leaving orchestration to the parent. The parent would listen to events from one Custom Element and change properties on the other. This breaks the separation of responsibilities as the parent (in our case, the Decide service) is now responsible for implementing logic that belongs to someone else (in our case, the Checkout Service).

What is needed is a communication channel that will enable a publish-subscribe pattern. This will ensure proper decoupling. The classic implementation of such a channel is events based bus. The publisher raises events with bubbling enabled (by default, it's not), so subscribers can listen for those events on the window object. This is an established approach, but it's not the one I've decided to implement. An events-based bus is a little bit "too public" for me. In the case of multiple Custom Elements communicating, there are a lot of events on the window object, and I would prefer more organization. Luckily, modern browsers provide an alternative way to implement such a channel - the Broadcast Channel API. You can think about Broadcast Channel API as a simple message bus that provides the capability of creating named channels. The hidden power of Broadcast Channel API is that it allows communication between windows/tabs, iframes, web workers, and service workers.

Using Broadcast Channel API in Blazor once again requires JavaScript interop. I've decided to use this opportunity to build a component library that provides easy access to it. I'm not going to describe the process of creating a component library in this post, but if you are interested just let me know and I'm happy to write a separate post about it. If you want to use the Broadcast Channel API, it's available on NuGet.

After building and publishing the component library, I referenced it in the Checkout project and registered the service it provides.

...

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.RegisterAsCustomElement<BuyButton>("checkout-buy");

builder.Services.AddBroadcastChannel();

...

In the checkout button component, I've injected the service. The channel can be created by calling CreateOrJoinAsync, and I'm doing that in OnAfterRenderAsync. I've also made the component implement IAsyncDisposable, where the channel is disposed to avoid JavaScript memory leaks. The last part was calling PostMessageAsync as part of OnButtonClick to send the message to the channel. This completes the publisher.

...

@implements IAsyncDisposable

...
@inject IBroadcastChannelService BroadcastChannelService

...

@code {
    ...
    private IBroadcastChannel? _broadcastChannel;

    ...

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _broadcastChannel = await BroadcastChannelService.CreateOrJoinAsync("checkout:item-added");
        }
    }

    private async Task OnButtonClick(MouseEventArgs e)
    {
        ...

        if (_broadcastChannel is not null)
        {
            await _broadcastChannel.PostMessageAsync(new CheckoutItem { Sku = Sku, Edition = Edition });
        }

        ...
    }

    ...

    public async ValueTask DisposeAsync()
    {
        if (_broadcastChannel is not null)
        {
            await _broadcastChannel.DisposeAsync();
        }
    }
}

The mini-cart component will be the subscriber. I've added there the same code for injecting the service, joining the channel, and disposing of it. The main difference here is that the component will subscribe to the channel Message event instead of sending anything. The BroadcastChannelMessageEventArgs contains the message which has been sent in the Data property as JsonDocument, which can be deserialized to the desired type. In the mini-cart component, I'm using the message to add items.

@using System.Text.Json;

@implements IAsyncDisposable

@inject IBroadcastChannelService BroadcastChannelService

@(_items.Count == 0  ? "Your cart is empty." : $"You've picked {_items.Count} tractors:")
@foreach (var item in _items)
{
    <img src="https://mi-fr.org/img/@(item.Sku)[email protected](item.Edition).svg" />
}

@code {
    private IList<CheckoutItem> _items = new List<CheckoutItem>();
    private IBroadcastChannel? _broadcastChannel;
    private JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _broadcastChannel = await BroadcastChannelService.CreateOrJoinAsync("checkout:item-added");
            _broadcastChannel.Message += OnMessage;
        }
    }

    private void OnMessage(object? sender, BroadcastChannelMessageEventArgs e)
    {
        _items.Add(e.Data.Deserialize<CheckoutItem>(_jsonSerializerOptions));

        StateHasChanged();
    }

    public async ValueTask DisposeAsync()
    {
        if (_broadcastChannel is not null)
        {
            await _broadcastChannel.DisposeAsync();
        }
    }
}

The last thing I did in the Checkout service was exposing the mini-cart component.

...

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.RegisterAsCustomElement<BuyButton>("checkout-buy");
builder.RootComponents.RegisterAsCustomElement<MiniCart>("checkout-minicart");

builder.Services.AddBroadcastChannel();

...

Now the mini-cart could be included in the HTML owned by the Decide service.

<html>
  ...
  <body class="decide_layout">
    ...
    <div class="decide_details">
      <checkout-buy sku="porsche"></checkout-buy>
    </div>
    ...
    <div class="decide_summary">
      <checkout-minicart></checkout-minicart>
    </div>
    ...
  </body>
</html>

Playing With the Complete Sample

The complete sample is available on GitHub. You can run it locally by spinning up all the services, but I've also included a GitHub Actions workflow that can deploy the whole solution to Azure (you just need to fork the repository and provide your own credentials).