UI Composition in ASP.NET Core

2022 September 10th • ☕️ 7 min read

Not too long ago I was working for a project in which I was forced to use only MVC due to the requirements of a customer. Being used to advanced UI composition techniques in React and Svelte, I thought to find a way to at least have some kind of reusable UI composition in MVC. The obvious answer was using partial views.

Partial Views

So partial views are the simplest and longest standing way of creating UI composition in ASP.NET Core. Tied with razor syntax you can do a lot of things with them, but as great as they are, they are no silver bullet.

The invocation of a partial view looks something like this:

<partial name="~/Views/Folder/_PartialName.cshtml" />

Which is great until you realize that unless you specify the entire name of the razor view, you're stuck with relative path rules, which make reusability somewhat tedious.

Something else that happens is that, because the <partial> call is generic to any partial view used, you get little to no compile time validations that your partial will work.

View Components

View components are a way to compose your UI similarly to partial views, but unlike partial views, you can get compile time validation and custom parameters in them.

public class CustomViewComponent : ViewComponent
{
    public IViewComponentResult Invoke(string message)
    {
        return View();
    }
}

The call to the View function looks for the same folder the view component is in first for the razor view to render. If none is specified, it looks for "Default.cshtml".

Then you execute it this way:

<vc:custom message="Text"></vc:custom>

View Components have compile time safeguards and offer easier composition than partial views, but they have an issue, they don't support child elements.

Tag Helpers

Then we have tag helpers. Tag helpers were introduced fairly early on with ASP.NET Core and they are varied in what they can do and how you can implement them.

You can have tag helpers like asp-controller and asp-action, that when combined in the right HTML element can have certain effects, such a link generation:

//Input
<a asp-controller="Home" asp-action="Index">
    Click me
</a>

//Output
<a href="/Home">
    Click me
</a>

These kinds of tag helpers are the ones that are the most commonly implemented for the framework. They work to keep dynamic urls rather than hard-coded ones, allow the application to know when to show which validation for which field, or give a property attached to an input it's correct name for model binding.

But tag helpers can also be used to create your own custom tags. The most common example of this within the framework is the environment tag helper.

<environment names="Development">
    Content will only display in Development environment.
</environment>

We can create tag helpers of both kinds, and the code to define one looks something like this:

[HtmlTargetElement("custom-button")]
public class ModalOpenButtonTagHelper : TagHelper
{
    [HtmlAttributeName("css-class")]
    public string CssClass { get; set; }

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "button";
        output.Attributes.Add("type", "button");
        output.Attributes.Add("class", CssClass);

        output.TagMode = TagMode.StartTagAndEndTag;

        var childContent = await output.GetChildContentAsync();
        var trimmedContent = childContent.GetContent().Trim();

        output.Content.SetHtmlContent(trimmedContent);
    }
}

And it would be used like this:

<custom-button css-class="primary-red">Save</custom-button>

Which would then be rendered like this:

<button type="button" class="primary-red">
    Save
</button>

A simple tag helper like the previous one is useful since it does allow very good component composition. But it's not without it's issues. Tag helpers don't support rendering a razor template by default because their use case is too broad for that. Most of the tag helpers defined by ASP.NET Core are not full fledged components, they are helpers to properly formatting one's HTML.

They can be used to create components, certainly, but they don't specialize in that. If you create a big composable UI this way, you'll have to deal with StringBuilders and write UI with a very poor developer experience. This method is more useful than a partial view, but complicated still due to the sheer amount of customizability that tag helpers have.

A hybrid approach - Tag Helpers with Razor Templates

So what if we could use the power of tag helpers of supporting child content, but also allow them to use razor templates so we can use a more ergonomic syntax than a StringBuilder?

This is possible, but it requires some setup. First we need to find a way in code to render a razor View without the View method. There are some approaches that are relatively common of doing this for handling email templates, but through many hours of googling I found a simpler approach:

[HtmlTargetElement("input-mask")]
public class InputMaskTagHelper : TagHelper
{
    private readonly IHtmlHelper _html;

    public InputMaskTagHelper(IHtmlHelper html)
    {
        _html = html;
    }

    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; }

    [HtmlAttributeName("asp-for")]
    public ModelExpression InputExpression { get; set; }

    [HtmlAttributeName("element-mask")]
    public string ElementMask { get; set; }

    [HtmlAttributeName("element-placeholder")]
    public string ElementPlaceholder { get; set; }

    [HtmlAttributeName("element-value")]
    public string ElementValue { get; set; }
    
    [HtmlAttributeName("element-class")]
    public string ElementClass { get; set; }

    [HtmlAttributeNotBound]
    private Guid IdForDOM { get; set; } = Guid.NewGuid();

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        (_html as IViewContextAware).Contextualize(ViewContext);

        var model = new InputMaskModel
        {
            AspFor = InputExpression,
            ElementMask = ElementMask,
            ElementPlaceholder = ElementPlaceholder,
            ElementValue = ElementValue,
            ElementClass = ElementClass,
            IdToAssignInDOM = IdForDOM
        };
        var content = await _html.PartialAsync("~/Views/TagHelpers/InputMask/InputMaskTemplate.cshtml", model);

        output.Content.SetHtmlContent(content);
    }
}

This is an example of a tag helper I wrote for the project I mentioned at the beginning. This is a bit of a more complex example. For the sake of brevity I will not include all the code this requires to work, but this should suffice to explain how to use a partial inside a tag helper.

The first thing that we need is a ViewContext property, to which we specify that we do not want it to be considered an assignable parameter in our tag helper, thus the [HtmlAttributeNotBound] attribute.

Internally, a ViewContext is always required for anything that requires rendering Razor into an HTML string. Because a tag helper will always be used inside a Razor View or Razor Page, we have access to a ViewContext at all times. We just need imperative access to it, which is precisely what we get with the [ViewContext] attribute.

Now, the ViewContext alone is necessary, but does not have a RenderRazor method or anything like that. In order to render a partial view we will take advantage of another class in the framework that is often only used inside razor, enter HtmlHelper. If you have ever used .NET Framework, you will more easily recognize HtmlHelper as the way that ASP.NET used to handle link generation, routing and model binding with razor.

HtmlHelpers are still part of .NET Core and they are still necessary for some things that can only be done with them, like calling @Html.Raw(htmlString) when needed as well as for backwards compatibility.

In this case we are getting access to HtmlHelper programatically into our tag helper by using it's interface IHtmlHelper and injecting it into the constructor of the custom tag helper.

Then, in ProcessAsync we need to let the HtmlHelper know it is in a valid ViewContext, thus we call (_html as IViewContextAware).Contextualize(ViewContext);. At this point we can now call any function of the HtmlHelper without any errors occurring.

We now just call await _html.PartialAsync("~/Views/TagHelpers/InputMask/InputMaskTemplate.cshtml", model); to get the rendered razor template into an IHtmlContent or string value. Finally, we tell the output object of the tag helper to take that output in the line output.Content.SetHtmlContent(content);.

Allowing children in tag helpers with templates

This last specific example does not use child elements when invoked, but as we are aware from the first example, tag helpers can do something that ViewComponents cannot, have child content.

If we wanted to implement a tag helper that does support child elements you need to modify the tag helper a little:

[HtmlTargetElement("input-mask")]
public class InputMaskTagHelper : TagHelper
{
    private readonly IHtmlHelper _html;

    public InputMaskTagHelper(IHtmlHelper html)
    {
        _html = html;
    }

    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; }

    /*Same as properties above code example, omitted for brevity*/

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        (_html as IViewContextAware).Contextualize(ViewContext);

        var childContent = await output.GetChildContentAsync();
        var trimmedContent = childContent.GetContent().Trim();
        
        var model = new InputMaskModel
        {
            AspFor = InputExpression,
            ElementMask = ElementMask,
            ElementPlaceholder = ElementPlaceholder,
            ElementValue = ElementValue,
            ElementClass = ElementClass,
            IdToAssignInDOM = IdForDOM,
            ChildContent = trimmedContent
        };

        var content = await _html.PartialAsync("~/Views/TagHelpers/InputMask/InputMaskTemplate.cshtml", model);

        output.Content.SetHtmlContent(content);
    }
}

You would then be able to access this HTML in it's corresponding view like this:

@model InputMaskModel;

<span>
    @Html.Raw(Model.ChildContent)
</span>

Then you can use your child accepting tag helper this way:

<input-mask>
    Child content here:
    <!--You can use tag helpers inside the child elements as well.-->
    <a asp-controller="Home" asp-action="Index">Go Home</a>
</input-mask>

This allows very expressive UI composition without derailing from an MVC centric approach to declaring UI. You could even combine this with a library such as Petite Vue or AlpineJS and you can add all kinds of interactivity with JS without buying in entirely into the complexity of maintaining a node environment and dependencies.

You can even create a Tag Helper Component or a View Component that allows you to dynamically select JS library versions based on parameters.

What about Blazor?

Blazor is an option of UI composition in ASP.NET Core as well and it isn't very hard to use inside an MVC project. I'm not going to cover fully how to set it up in an MVC project since there are better and more detailed posts about the topic.

What I do want to discuss is the syntax and it's boons as well as it's limitations. Unlike other approaches, using Blazor Components in MVC allows us to define component lifecycle. MVC supports five modes of rendering a Blazor component:

  • Server: Render a component as a Blazor Server component, requiring JS dependencies with a SignalR Hub and full component lifecycle.
  • Server Prerenderred: Same as above, but the component will come pre-rendered into HTML.
  • Static: Render the component into HTML. No lifecycle nor JS dependencies needed.
  • WebAssembly: Render a component as a Blazor WASM component, requiring JS dependencies and DLLs.
  • WebAssemblyPrerendered: Same as above, but the component will come pre-rendered into HTML.

If we are to compare modes of render with everything else in comparisson other than Blazor, static is the mode all other methods would fit into.

The syntax is something like this:

    <component type="typeof(AppController)" render-mode="ServerPrerendered" /> 

This is by far one of the most flexible ways of composing UI in an MVC app, because you get full components. You get no children inside the MVC scope and the composition is handled entirely in Blazor code.

This however comes with some strings attached, if you go with Blazor Server, you need the JS dependency, the nuget dependency and your app will have a SignalR Hub and network connection required to run Blazor Server.

Essentially, if you render in Server mode, you buy into all the good and bad things of Blazor Server. The same can be said of WebAssembly mode, you need the setup and buy into all the tradeoffs of Blazor WASM.

Static rendering also has tradeoffs. If you're still using MVC (which you are if you're reading this article), you lose access to Tag Helpers and Html helpers since those aren't part of Blazor components.

This means no dynamic link generation based on your MVC apps route table and no easy to use model binding should you choose to keep your blazor forms and inputs being rendered statically without WASM or SignalR.

It also means no lifecycle, but you knew that already.

Razor Tag Helpers - A compromise for those that have cases where it makes sense.

We have explored many different approaches for UI composition in ASP.NET Core. Some times, a partial view or a view component will be enough, other times you need higher composability and a tag helper with children is the better choice. Maybe you want some WASM interactivity and calling a Blazor component inside your Razor View or Razor Page is the way to go.

But as I explained above, sometimes, you need more power than a view component, but less power than a Blazor component, or you simply want to keep writing your simple MVC forms with page refresh because development speed is faster, rather than going through the trouble of writing lots of complex form interactivity in Blazor.

For this use case, I have created Razor Tag Helpers as a library/sample for a simpler to use implementation for a server component that remains compatible with MVC workflow with tag helpers.

This is still a work in progress, but feel free to check it out!

© 2021 Carlos Jiménez - Built with Gatsby