Alexander Zeitler

ASP.NET Core TagHelpers: underrated feature of an underrated framework

Published on Thursday, November 21, 2024

The State of ASP.NET Core MVC

ASP.NET Core TagHelpers have been around for quite some time but only few people seem to like them. Here's why I think they're awesome.

ASP.NET Core - and even more: MVC - is one of the most underrated Web Frameworks. This might be an unpopular opinion these days when Single Page Apps, JSX/TSX, Next.js and SSR are all the rage.

Even Microsoft thinks they need to move away from the old stuff and build shiny new things like Blazor, which - surprise, surprise - suddenly was given a plain SSR mode.

ASP.NET Core Tag Helper Basics

Razor Syntax (the language used in MVC Views and Razor Pages) has been around for ages, and it's a powerful language.

You can have iterators, C# code blocks, includes (partials) and you can inject everything from the IoC container.

And - to get back to the beginning of this post: you can have tag helpers.

TagHelpers can be used at the tag level (build a new HTML tag) or at the attribute level (add attributes to existing HTML tags).

The ladder is heavily used by the great HTMX TagHelpers library for .NET maintained by Khalid.

This also shows the great IDE support for Razor and TagHelpers in JetBrains Rider. "Go To Definition" and "Find Usages" works great as well as refactoring.

A popular example for HTML Tag TagHelpers is the partial Tag Helper provided by ASP.NET Core itself:

<partial name="SomePartialRazorView" model="SomeModel" />

Using TagHelpers, we can either create HTML Tags or attributes that encapsulate functionality.

A simple TagHelper that creates a custom element could look like this:

using Microsoft.AspNetCore.Razor.TagHelpers;

namespace MyApp.TagHelpers

public class MyTagHelper : TagHelper
{
    public string Text { get; set; } 
    public int Number { get; set; } 
 
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        for (int i = 0; i < Number; i++)
        {
            output.Content.AppendHtml($"{Text}<br/>");
        }
    }
}

The TagHelper gets registered in ViewImports.cshtml:

@addTagHelper MyApp.TagHelpers.MyTagHelper, MyAssembly

Or we can just register all TagHelpers defined within our app:

@addTagHelper *, MyAssembly

The TagHelper can be used in any Razor View like this:

<my-tag-helper text="Some text" number="5"></my-tag-helper>

The generated HTML output will be this:

Some text<br/>
Some text<br/>
Some text<br/>
Some text<br/>
Some text<br/>

Razor Views in Tag Helpers

What has been holding me back to make more use of Tag Helpers has been the need to build the HTML manually, sort of and the lack of ability to render Tag Helper in a nested style or having child content at all.

After some research I found out that it's actually possible to have child content, nest Tag Helpers and even better: have Razor Views for Tag Helpers instead of creating the content from strings. Having Razor views at hand is especially useful when using Tailwind CSS which builds the CSS file on the fly.

Long story short: this is what a base class for a Tag Helper, that loads its content from a Razor View, could look like:

public abstract class RazorTagHelperBase<TModel> : TagHelper
{
  [HtmlAttributeNotBound] [ViewContext] public ViewContext ViewContext { get; set; }
  private IHtmlHelper _htmlHelper;

  public RazorTagHelperBase(
    IHtmlHelper htmlHelper
  )
  {
    _htmlHelper = htmlHelper;
  }

  private string _partialName;
  private TModel? _model;
  private bool _allowChildContent;

  protected async Task<IHtmlContent> RenderPartial<T>(
    [AspMvcPartialView] string partialName,
    TModel model
  )
  {
    (_htmlHelper as IViewContextAware).Contextualize(ViewContext);

    return await _htmlHelper.PartialAsync(partialName, model);
  }

  /// <summary>
  /// 
  /// </summary>
  /// <param name="partialName"></param>
  /// <param name="model"></param>
  /// <param name="allowChildContent"></param>
  protected void SetPartialName(
    [AspMvcView] [AspMvcPartialView] string partialName,
    TModel? model,
    bool allowChildContent = false
  )
  {
    _partialName = partialName;
    _model = model;
    _allowChildContent = allowChildContent;
  }

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

      IHtmlContent content;

      string error;

      if (_allowChildContent)
      {
        var childContent = await output.GetChildContentAsync();
        var children = childContent.GetContent();
        if (_model is IHasChildContent modelWithChildContent)
          modelWithChildContent.ChildContent = children;
        else
          throw new InvalidOperationException(
            $"Model of type {typeof(TModel).Name} does not implement IHasChildContent"
          );

        content = await _htmlHelper.PartialAsync(_partialName, modelWithChildContent);
      }
      else
      {
        content = await _htmlHelper.PartialAsync(_partialName, _model);
      }

      output.SuppressOutput();
      output.TagMode = TagMode.StartTagAndEndTag;
      output.PreContent.AppendHtml(content);
    }

    catch (Exception exception)
    {
      output.PreContent.AppendHtml(await _htmlHelper.PartialAsync("RazorTagHelperBase", exception));
      Console.WriteLine(exception);
    }
  }
}

All you have to do is to create a class that derives from this class, define a model class or record type.

If you want to allow the Tag Helper to render child content, your model has to implement this interface:

public interface IHasChildContent
{
  string? ChildContent { get; set; }
}

Let's assume we're building a Tag Helper that renders a "thread" for discussions which we would use like this:

<thread items="@Model.Items">
   
</thread>

The C# class:

public class Thread
{
  public string Title { get; set; }
  public List<ThreadItem> Items { get; set; } = new();
}

public class ThreadTagHelper : RazorTagHelperBase<Thread>
{
  [HtmlAttributeName("title")] public string Title { get; set; }
  [HtmlAttributeName("thread-items")] public List<ThreadItem> Items { get; set; } = new();

  public ThreadTagHelper(
    IHtmlHelper htmlHelper
  ) : base(htmlHelper)
  {
  }

  public override Task ProcessAsync(
    TagHelperContext context,
    TagHelperOutput output
  )
  {
    var thread = new Thread
    {
      Title = Title,
      Items = Items
    };
    SetPartialName("ThreadTagHelper", thread);
    return base.ProcessAsync(context, output);
  }
}

The Razor view:

@model ThreadTagHelper.Thread

<section aria-labelledby="notes-title">
  <div class="bg-white shadow sm:rounded-lg sm:overflow-hidden">
    <div class="divide-y divide-gray-200">
      <div class="px-4 py-5 sm:px-6">
        <h2 class="text-lg font-medium text-gray-900"
            id="notes-title">
          @Model.Title
        </h2>
      </div>

      @{ var index = 0; }
      @foreach (var threadItem in Model.Items)
      {
        <thread-item thread-item="@threadItem"></thread-item>
        @if (index + 1 < Model.Items.Count)
        {
          <div class="relative pb-4">
            <span aria-hidden="true"
                  class="absolute top-1 left-6 -ml-px h-full w-0.5 bg-gray-300"></span>
          </div>
        }
      }

    </div>
  </div>
</section>

As you can see, the Razor view itself contains another Tag Helper <thread-item>.

Tag Helpers for View Composition in Distributed Systems / Vertical Slice Architecture

Two days ago, Khalid posted about creating an "Island" TagHelper, which loads a view fragment on demand.

This is quite similar to what I'm using in my Vertical Slice Architecture solutions for View Composition.

Different contexts / features are providing self-contained components, which provide fragments of the UI:

View Composition in an online store

While the parts highlighted yellow in the screenshot, are provided by a Catalog Context (Service), the parts highlighted blue are provided by a Pricing Context (Service).

In my case, the components are implemented using TagHelpers as shown above.

Depending on the level of Coupling/Decoupling your .NET Solutions/Projects have/allow, they can reside in a single project, or they can reside in shared projects and be imported in the _ViewImports.cshtml as shown above as well.

Also depending on your coupling/decoupling model, the Tag Helpers can either call services via HTTP (as Khalid has shown) to get their views, or they access their contexts in process and render the result using Razor views as shown above.

That way you can have decoupled, self-contained components in your server side rendered UI (which is the place where the coupling should actually happen).

And that's the major reason why I think that ASP.NET Core TagHelpers are one of the most underrated features of a highly underrated web framework.

Even more, since JetBrains Rider - whose Razor support is top-notch - can now be used for free for non-commercial projects.

What are your thoughts about
"ASP.NET Core TagHelpers: underrated feature of an underrated framework"?
Drop me a line - I'm looking forward to your feedback!
Please be aware that I'm no longer active on social media. I'm just cross posting things over there (it's a bot).
Imprint | Privacy