Blazor - JavaScript Isolation (ES6 Modules & Blazor)

Introduction

When using JavaScript in Blazor, we typically add the reference to our JavaScript files in _Host.cshtml. This allows us to access our JavaScript from anywhere in our Blazor application.

Since Blazor apps are SPAs (Single-Page Applications), it makes sense why it works this way. But, we often don’t want to pollute the global JavaScript namespace with every function and variable. Often, an individual component will need its own JavaScript to work correctly. How can we isolate JavaScript code to a single component?

Objects As Namespaces

One solution you might find online is to wrap all JavaScript code in objects, effectively making namespaces for each component:

window.MyNamespace = {
    MyFunction: function() {
        ...
    },
};

This works, but it is cumbersome to work with and all of these objects are still globally visible. We still don’t have true isolation.

Luckily, JSInterop in Blazor supports ES6 Modules.

Using ES6 Modules in Blazor

Modules in JavaScript allow us to completely isolate scripts from the global namespace. With a module, only the components that import it can access it, and only code that is explicitly exported within the module can be accessed. This allows us to tightly control access to our code and removes namespace pollution in large projects.

Let’s say we have a component that displays the start time of an event. This start time is stored in our system and displayed as UTC, but when the user clicks a button, we want the displayed time converted to their local timezone. We can do this with the help of JavaScript, since the browser has access to the user's timezone.

First let's create a Blazor Component called TimeDisplay.

[TimeDisplay.razor]

<p>Event starts at: @displayTime.ToString("g")</p>
<button class="btn btn-primary" @onclick="OnClick">Click me</button>
@code {
    private DateTime displayTime = DateTime.UtcNow;
    private void OnClick() {}
}

For this example, we need to utilize JavaScript to get the user’s local timezone. The rest of the web app doesn’t care about this timezone logic, so we want to isolate it to our TimeDisplay component.

Within the project’s wwwroot folder, we can create a folder called scripts then create a subfolder called components

Then, we add our .js file in this folder,

Let’s write some JavaScript. We want a JavaScript function we can call from C# that lets us pass in a DateTime string in UTC, and it’ll return to us converted into the user’s timezone.

function ConvertStartTimeToLocal(utcDateTime) {
    const utcString = new Date(`${utcDateTime} UTC`);
    return utcString.toLocaleString();
}

Now, typically we would add the reference to this script in _Host.cshtml (In Blazor Server) so that we can access it via the JSInterop features.

But since this JavaScript only corresponds to an individual component, it would be ideal if the component itself could load this JavaScript file.

Loading the script in our Component

To begin, we will still need to [Inject] an IJSRuntime instance like normal so that we can do JS Interop.

The difference is that instead of calling JavaScript functions with the IJSRuntime instance directly, we will use it to import our module and store it as an IJSObjectReference instance.

// TimeDisplay.razor
@code {
[Inject] IJSRuntime JSRuntime { get; set; }
IJSObjectReference module;

JavaScript won’t be usable in a component until after the component is rendered to the client, so we must initialize it within the OnAfterRenderAsync method.

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./scripts/components/TimeDisplay.js");
}

With this code, instead of calling the JavaScript function directly using the IJSRuntime, we are instead using the import feature of ES6 to import our module. This module is stored as an IJSObjectReference instance, which we can use to communicate with our module directly.

Now let’s implement the button’s OnClick method.

private async Task OnClick()
{
    var converted = await module.InvokeAsync<string>("ConvertStartTimeToLocal", 
    displayTime.ToString("g"));
    displayTime = DateTime.Parse(converted);
}

Note carefully, we are using module.InvokeVoidAsync instead of JSRuntime.InvokeVoidAsync. This is the key difference here. We are not calling code from the global JS runtime, we are only accessing code from within our module. Everything else you know about how to use JS Interop stays exactly the same.

Finally, let's go to Index.razor and add our component

<TimeDisplay />

There's a slight catch

If we are using a module, we need to make sure we explicitly dispose of it for garbage collection. If we don't, the JSRuntime will continue holding a reference to our module for the lifetime of the application.

To do this, our component must implement IAsyncDisposable, then we can dispose of our module in the DisposeAsync method.

Here's the completed TimeDisplay component:

@implements IAsyncDisposable
<p>Event starts at: @displayTime.ToString("g")</p>
<button class="btn btn-primary" @onclick="OnClick">Click me</button>
@code {
    [Inject] IJSRuntime JSRuntime {
        get;
        set;
    }
    IJSObjectReference module;
    private DateTime displayTime = DateTime.UtcNow;
    protected override async Task OnAfterRenderAsync(bool firstRender) {
        module = await JSRuntime.InvokeAsync < IJSObjectReference > ("import", "./scripts/components/TimeDisplay.js");
    }
    private async Task OnClick() {
        var converted = await module.InvokeAsync < string > ("ConvertStartTimeToLocal", displayTime.ToString("g"));
        displayTime = DateTime.Parse(converted);
    }
    async ValueTask IAsyncDisposable.DisposeAsync() {
        if (module is not null) {
            await module.DisposeAsync();
        }
    }
}

Let’s run the app. Once the page loads, click on the button and see the result

Woops! We are seeing this error because with ES6 modules, only code that is marked with export can be seen from outside the module. There are a couple of ways to do this, and I’ll show both, but I prefer one over the other and I’ll explain why later.

The change we need to make is very simple, and we don’t need to stop or restart the app.

Note
In Blazor, you don’t need to restart the app after changing JavaScript code. You can just refresh the page after saving the file to see the changes in action.

We will just add the export keyword in front of the function declaration:

export function ConvertStartTimeToLocal(utcDateTime) {
    const utcTimeString = new Date(`${utcDateTime} UTC`);
    return utcTimeString.toLocaleString();
}

Save the file, then refresh the page and try again. You should see the time converted to your local time when the button is clicked.

On the topic of export

Now, as was mentioned previously there is another way to export code out of a JS module, and it’s done like so:

function ConvertStartTimeToLocal(utcDateTime) {
    const utcTimeString = new Date(`${utcDateTime} UTC`);
    return utcTimeString.toLocaleString();
}
export { ConvertStartTimeToLocal };

Both ways of exporting are acceptable. But, I personally prefer putting export before the function/variable declaration. Here’s why: When marking a function or variable explicitly as export function or export const, you can immediately tell just by reading the function declaration that the code is intended to be called from a Blazor component.

  • Functions and variables declared with export are clearly being used from C# with JSInterop
  • Functions and variables declared without export are local to the module and won’t be used by C# code

A list of exports at the bottom of the code file makes this far less obvious, in my opinion.

Summary

This module feature is, in my opinion, a hidden gem in Blazor. It allows you to write JavaScript files as if everything were global—not worrying about namespacing. The file itself is the namespace!

Stay safe everyone and happy coding!


Similar Articles