Isolated JavaScript in Blazor with collocated JS files

· 3 min read

If you’ve worked with Blazor and JS Interop before, you’ve probably ended up with a massive app.js file full of random functions for different components. It works, but it gets messy fast. Luckily there’s a much cleaner approach: collocated JavaScript files.

The idea

Just like CSS isolation, Blazor lets you place a .razor.js file next to your component. The JavaScript module gets loaded on demand, only when the component actually needs it. No global scripts, no pollution.

Setting it up

Let’s say we have a Clipboard.razor component that copies text to the clipboard. Create a file called Clipboard.razor.js right next to it:

export function copyToClipboard(text) {
    navigator.clipboard.writeText(text).then(() => {
        console.log("Copied to clipboard!");
    });
}

Notice the export keyword — this is important. Blazor loads it as a standard ES module.

Loading the module in Blazor

In your component, you use IJSRuntime to import the module. The path follows a convention: ./_content/{ASSEMBLY_NAME}/{COMPONENT_PATH}.razor.js for libraries, or just the relative path for the current project.

@inject IJSRuntime JS
@implements IAsyncDisposable

<button @onclick="Copy">Copy to clipboard</button>

@code {
    [Parameter]
    public string Text { get; set; }

    private IJSObjectReference? module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSObjectReference>(
                "import", "./Components/Clipboard.razor.js");
        }
    }

    private async Task Copy()
    {
        if (module is not null)
        {
            await module.InvokeVoidAsync("copyToClipboard", Text);
        }
    }

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

A few things to note here:

  • We load the module in OnAfterRenderAsync because JS Interop isn’t available during server-side prerendering
  • We keep a reference to the module with IJSObjectReference
  • We implement IAsyncDisposable to clean up the module when the component is destroyed

Why this is better

Before, I used to dump everything into a single wwwroot/js/app.js. It worked, but finding functions was a pain, and every page loaded JavaScript it didn’t need. With collocated JS files:

  • Each component owns its own JavaScript
  • Modules are loaded lazily, only when needed
  • No global function name conflicts
  • Easier to maintain and delete — when you delete the component, you delete the JS with it

A gotcha with the path

The path you pass to import depends on whether you’re in a standalone Blazor app or a Razor Class Library. For a regular Blazor app, the path is relative to wwwroot. The .razor.js file gets copied there at build time, so you reference it from the component’s location in the project.

If you’re getting a 404 when loading the module, double check the path and make sure the file ends in .razor.js, not just .js.

Hope you liked the post! Feel free to contact me on any social media at @emimontesdeoca.

Resources