Let me start by saying that I love the combination of Htmx and ASP.NET Core, and it is a pairing that any .NET developer should consider, whether maintaining an existing application or building a new one. It’s very cool. I was recently talking about revisiting the hx-trigger and HX-Trigger header technique with ASP.NET Core on Mastodon with Illya Busigin, and they mentioned they use the same method to update the avatar image when a user updates their profile. So, I thought I’d try and see how to implement it myself.

In this post, we’ll see how to use the HX-Trigger facility in Htmx to update existing UI elements on a rendered HTML page.

The Moving Parts

When building any new feature in an application, you need to arrange multiple components to work together. Let’s address all the parts you’ll need to make this experience.

  • A User Profile store
  • Profile Settings endpoints for display and updating
  • An endpoint to refresh the avatar element on the page

We’ll be using the capabilities of ASP.NET Core Razor Pages and Razor Views to handle the HTML snippets that Htmx needs for swapping elements. Before we get into HTML and Htmx, let’s deal with our user profile storage service.

Note that you’ll need the NuGet packages of Htmx and, optionally, Htmx.TagHelpers.

The User Service

I created a straightforward class for the demo that will hold the user name and the avatar URL. In a “real application”, you’d likely store this information in a database or third-party auth service. Let’s see what I wrote.

public class UserService
{
    public static readonly string[] AvatarUrls =
    [
        "~/img/avatar_one.png",
        "~/img/avatar_two.png",
        "~/img/avatar_three.png",
    ];
    
    public string Name { get; set; } = "Khalid Abuhakmeh";
    public string AvatarUrl { get; set; } = AvatarUrls[0];
}

The user can choose from three existing avatars, which are stored in the wwwroot/img folder. Fewer options mean less code for the demo and more focus on the Htmx bits that come later.

Next, we’ll register the UserService as a Singleton in our ASP.NET Core setup class to maintain the state between requests. Note: This is only for the demo and is not recommended in any other scenario.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddSingleton<UserService>();

var app = builder.Build();

Cool! Now, we can inject this service where we need it to grab the current state of the user profile, which includes the name and avatar URL.

I inject this service into my Layout.cshtml and then render the avatar partial.

@inject UserService UserService
<!-- some HTML -->
@await Html.PartialAsync("_Avatar", UserService)

We’ll examine the partial later in the article, but first, let’s discuss our account endpoints.

Profile Endpoints

In a new Razor page titled Index, we’ll have three endpoints:

  • Show the profile settings form.
  • Accept user updates
  • Render the _Avatar profile image as partial content.

I’ll paste the full class here, and we’ll break it down into one endpoint at a time.

using System.Diagnostics.CodeAnalysis;
using Htmx;
using HtmxAvatarChange.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace HtmxAvatarChange.Pages;

public class IndexModel(UserService userService, ILogger<IndexModel> logger) : PageModel
{
    [BindProperty] public string? Name { get; set; }

    [BindProperty] public string? AvatarUrl { get; set; }

    [TempData] public string? Message { get; set; }
    [TempData] public string? MessageCssClass { get; set; }

    [MemberNotNullWhen(true, nameof(Message))]
    public bool HasMessage => Message != null;

    public List<SelectListItem> Avatars => UserService
        .AvatarUrls
        .Select((x, i) => new SelectListItem($"avatar-{i:00}", x))
        .ToList();

    public void OnGet()
    {
        Name = userService.Name;
        AvatarUrl = userService.AvatarUrl;
    }

    public IActionResult OnPost()
    {
        if (ModelState.IsValid)
        {
            Message = "Successfully saved account settings";
            MessageCssClass = "alert-success";

            // change values
            userService.Name = Name!;
            userService.AvatarUrl = AvatarUrl!;
            
            Response.Htmx(h =>
                h.WithTrigger("avatar"));
        }
        else
        {
            Message = "Failed to save account settings";
            MessageCssClass = "alert-danger";
        }

        if (Request.IsHtmx())
        {
            return Partial("_Form", this);
        }

        return RedirectToPage("Index");
    }

    public IActionResult OnGetAvatar()
    {
        return Partial("_Avatar", userService);
    }

    public string? IsCurrentAvatar(string avatarValue)
    {
        return avatarValue == AvatarUrl ? "checked" : null;
    }
}

The first method we’ll look at is OnGet. This method takes the information from the UserService and hydrates our property. These properties will be used to bind information to our form.

public void OnGet()
{
	Name = userService.Name;
	AvatarUrl = userService.AvatarUrl;
}

Next, closer to the bottom of the class, let’s look at a similar method, OnGetAvatar.

public IActionResult OnGetAvatar()
{
	return Partial("_Avatar", userService);
}

This method returns the partial view of _Avatar, simply displaying our user information. Now, let’s look at the OnPost method, which is the juiciest of the implementations.

public IActionResult OnPost()
{
	if (ModelState.IsValid)
	{
		Message = "Successfully saved account settings";
		MessageCssClass = "alert-success";

		// change values
		userService.Name = Name!;
		userService.AvatarUrl = AvatarUrl!;
		
		Response.Htmx(h =>
			h.WithTrigger("avatar"));
	}
	else
	{
		Message = "Failed to save account settings";
		MessageCssClass = "alert-danger";
	}

	if (Request.IsHtmx())
	{
		return Partial("_Form", this);
	}

	return RedirectToPage("Index");
}

Here, we determine if the user input is valid and then update the page. The two most important lines include the Htmx references.

Response.Htmx(h =>
	h.WithTrigger("avatar"));

If the incoming request was initiated with Htmx, we add a response header to tell Htmx to fire a client-side event of avatar. Any element subscribing to this event will then trigger another request. In our case, we’ll be calling the OnGetAvatar endpoint. You can add as many events as you need here, but we only want one in our case.

The following reference to Htmx checks if Htmx initiated the request. If it was, we need to send back a partial Html snippet.

if (Request.IsHtmx())
{
	return Partial("_Form", this);
}

That’s it! But what do the Razor views look like?

Htmx and Partial Views

Let’s start with the form since it is the focal point of our user experience. It’s straightforward Razor code with one exception: the Htmx attributes on the form tag.

@model IndexModel

<fieldset id="account-settings">
    <legend>Account Settings</legend>

    @if (Model.HasMessage)
    {
        <div class="alert @(Model.MessageCssClass ?? "alert-info")" role="alert">
            @Model.Message
        </div>
    }

    <form method="post" hx-post hx-target="#account-settings" hx-swap="outerHTML">
        <div class="form-group row">
            <label asp-for="Name" class="col-sm-2 col-form-label">Name</label>
            <div class="col-sm-10">
                <input class="form-control" asp-for="Name">
            </div>
        </div>
        <fieldset class="form-group mt-3">
            <div class="row">
                <legend class="col-form-label col-sm-2 pt-0">Avatar</legend>
                <div class="col-sm-10">
                    @foreach (var avatar in Model.Avatars)
                    {
                        <div class="form-check">
                            <input
                                id="@avatar.Text"
                                asp-for="AvatarUrl"
                                class="form-check-inline" type="radio"
                                value="@avatar.Value"
                                checked="@Model.IsCurrentAvatar(avatar.Value)">
                            <label class="form-check-label" for="@avatar.Text">
                                <img src="@Url.Content(avatar.Value)" class="profile-pic" alt="@avatar.Text"/>
                            </label>
                        </div>
                    }
                </div>
            </div>
        </fieldset>
        <div class="form-group row mt-3">
            <button type="submit" class="btn btn-primary">Save Profile</button>
        </div>
    </form>
</fieldset>

These Htmx attributes allow us to hijack the form submission and route it through an Htmx request, allowing the library to process the header request. Once processed, it fires the event on the _Avatar partial. Let’s take a look at that view now.

@model HtmxAvatarChange.Models.UserService

<div id="profile-avatar"
     class="mx-2 smooth"
     hx-get="@Url.Page("Index", "Avatar")"
     hx-trigger="avatar from:body">
    <div class="profile-pic">
        <img src="@Url.Content(Model.AvatarUrl)" alt="Profile Picture">
    </div>
    <span class="navbar-text"> @Model.Name</span>
</div>

The essential attribute on this view is the hx-trigger attribute. Note the exact value.

hx-trigger="avatar from:body"

The from:body is essential, as the body element is what Htmx uses to broadcast the event. That’s it. Let’s see it in action!

Sample Running

Wow! That’s cool!

Conclusion

HX-Trigger headers can help you decouple UI elements from each other and create a system of elements that can act independently. This can be very powerful, but it should be used in moderation, like all things. Remember, each triggered event results in a request back to the server, which could come at a cost. That said, many existing SPA UI approaches already make expensive calls to the server, so this might not be any worse than a GraphQL call or JSON over an HTTP request.

I’ve uploaded the code for this sample to my GitHub repository so you can try it out.

As always, thanks for reading and hope you give this one a try.