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/>
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>
.
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:
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.