HTMX + ASP.NET Core MVC
HTMX + ASP.NET Core MVC

Use HTMX with ASP.NET Core MVC

2021-12-24 #csharp#.net#mvc#htmx

As I found the time to clean up my tests with HTMX, I can finally note how I developed a simple CRUD application with HTMX and ASP.NET Core MVC. At first, my goal is not to make zip, shebam, pow, blop, wizz... but to avoid reloading / displaying pages entirely to manage the basic CRUD functions.

Starting point

I quickly create an ASP.NET Core MVC application to manage a Movies table in an SQLite database. So I have a "MoviesController" controller with the following methods:

// GET: Movies
public async Task<IActionResult> Index() { ... }

// GET: Movies/Details/5
public async Task<IActionResult> Details(int? id) { ... }

// GET: Movies/Create
public IActionResult Create() { ... }

// POST: Movies/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(MovieEditorViewModel model) { ... }

// GET: Movies/Edit/5
public async Task<IActionResult> Edit(int? id) { ... }

// POST: Movies/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, MovieEditorViewModel model) { ... }

// GET: Movies/Delete/5
public async Task<IActionResult> Delete(int? id) { ... }

// POST: Movies/Delete/5
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id) { ... }

And 5 views that correspond to these 5 actions: "Index.cshtml", "Details.cshtml", "Create.cshtml", "Edit.cshtml" and "Delete.cshtml" (plus 2 partial views "_Display.cshtml" and "_Editor.cshtml" to avoid repeating code).

The code for this starter app is available on GitHub.

To test the application, you have to click on the "Movies" menu to navigate to the list of demo movies.

From this index page, you can see that when you click on the links "Créer", "Modifier", "Consulter" or "Supprimer", the page is completely reloaded: the time in the footer is updated each time.

In the same way, when you are in a detail page, the "Annuler" link to return to the movie list reloads the entire index page. Also, after submitting a form (to create, modify or delete data), it returns to the index page and fully reloads the page.

Now I will add HTMX to this app and then make some a few changes to use it and avoid to reload the whole pages every time.

Step 1 - Referencing HTMX

There are several ways to install HTMX, but to make it quick, I simply add the line <script src="https://unpkg.com/htmx.org@1.6.1"></script> in my "/Views/Shared/_Layout.cshtml" file:

        ...
        <div class="container">
            &copy; 2021 - MvcHtmx - @DateTime.Now.ToLongTimeString()
        </div>
    </footer>

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    <script src="https://unpkg.com/htmx.org@1.6.1"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

Step 2 - Update the "Index.cshtml" view

The "Index.cshtml" view contains a link <a asp-action="Create">Créer</a> in the table header to allow you to create a new movie.

It is a classic TagHelper that generates the following HTML code <a href="/movies/create/">Créer</a>. When the user clicks on this link, the browser hits the web server via an HTTP GET request and ASP.NET Core performs the "Create" action from the "MoviesController" controller which returns a new page to the browser.

As my application uses a "layout", most of the new page corresponds verbatim to the content of the index page... In fact, the only thing that changes is generated by the Razor method @RenderBody(). And from an HTML perspective, the change is in the content of the <main> tag.

    </header>

    <div class="container">
        <main role="main" class="pb-3">
            @RenderBody()
        </main>
    </div>

    <footer class="border-top footer text-muted">

This is where HTMX comes in handy as it will allow me to return only the new content for the <main> tag.

To do this, we need to add some information to our link, so that HTMX can do its work.

  • an "hx-get" attribute : hx-get="/movies/create/" to indicate to HTMX that it will have to make an HTTP GET request on the URL "/movies/create/", which corresponds to the "Create" action of the "MoviesController" controller.
  • an "hx-target" attribute : hx-target="main" to target where HTMX will have to insert the content returned by the action (instead of the current content of the tag <main>).
  • an attribute hx-push-url="true" so that the browser address bar is updated.

The "hx-push-url" attribute is interesting for several reasons:

  1. Without it, the address bar would not change and would still contain "https://localhost/movies/" which is the URL of the index page.
  2. With it, the address bar will display the URL of the page allowing to create a movie, namely "https://localhost/movies/create/".
  3. This is better if the user ever bookmarks this URL or gives it to someone.
  4. And most importantly, it allows the ASP.NET Core route system to work correctly, without having to change anything.

Note: A later modification will allow me to do without this attribute, without losing any of these 4 advantages.

With these 3 new attributes, the TagHelper now looks like this:

<td>
  <a asp-action="Create" hx-target="main" hx-push-url="true" hx-get="/movies/create/">Créer</a>
</td>

And it generates the following HTML code:

<td>
  <a href="/movies/create/" hx-target="main" hx-push-url="true" hx-get="/movies/create/">Créer</a>
</td>

Note: I did a test and HTMX does not allow to write <a href="/movies/create/" hx-target="main" hx-push-url="true">Créer</a> in order to avoid "href" and "hx-get" attributes being duplicated.

In the same way, I can modify the "Modifier", "Consulter" and "Supprimer" links by adding the 3 HTMX specific attributes:

<td>
  <a asp-action="Edit" asp-route-id="@item.Movie_ID"
     hx-target="main" hx-push-url="true" hx-get="/movies/edit/@item.Movie_ID/">Modifier</a> |
  <a asp-action="Details" asp-route-id="@item.Movie_ID"
     hx-target="main" hx-push-url="true" hx-get="/movies/details/@item.Movie_ID/">Consulter</a> |
  <a asp-action="Delete" asp-route-id="@item.Movie_ID"
     hx-target="main" hx-push-url="true" hx-get="/movies/delete/@item.Movie_ID/">Supprimer</a>
</td>

Step 3 - Modify other views

The "Details.cshtml", "Create.cshtml", "Edit.cshtml" and "Delete.cshtml" views all contain a link <a href="/movies/">Annuler</a> to exit the page and return to the movie list. This link is generated via the following TagHelper:

<a asp-action="Index">Annuler</a>

That I replace with:

<a asp-action="Index" hx-target="main" hx-push-url="true" hx-get="/movies/">Annuler</a>

The "Details.cshtml" view (which display a movie details) also contains a link to a new page to edit the current movie. This link is updated with the classic three "hx-*" attributes:

<a asp-action="Edit" asp-route-id="@Model.Movie_ID" class="btn btn-secondary"
   hx-target="main" hx-push-url="true" hx-get="/movies/edit/@Model.Movie_ID/">Modifier</a>

In addition, the "Create.cshtml" view contains an HTML form to send the entered data to the web server so that it can insert a new movie in the database.

<form asp-action="Create" method="post" class="form-horizontal">
  ...
</form>

Personally, I remove the asp-action="Create" because I make sure to always post a form on the same URL that displays this form. This is much better if there are any input errors detected afterwards on the server side.

<form method="post" class="form-horizontal">
  ...
</form>

I extend the TagHelper so that it is taken into account by HTMX:

<form method="post" class="form-horizontal" hx-post="/movies/create/">
  ...
</form>

In this case, the "hx-get" attribute is replaced by "hx-post" since the form makes an HTTP POST request and not an HTTP GET request. Since the attributes "hx-target" and "hx-push-url" have no effect when I did the test, I don't add them to the <form> tag.

Then I do the same with the view "Edit.cshtml" which is used to modify a movie :

<form method="post" class="form-horizontal" hx-post="/movies/edit/@Model.Movie_ID/">
  ...
</form>

And in the "Delete.cshtml" view which is used to delete a movie:

<form method="post" class="form-horizontal" hx-post="/movies/delete/@Model.Movie_ID/">
  ...
</form>

By the way, this is an MVC application and not an API. That's why I don't use HTTP PUT or HTTP DELETE methods. I follow the "traditional" ASP.NET MVC route system to link URLs to controller actions:

  • GET /movies/ => action "Index" to display the list of movies
  • GET /movies/details/99/ => "Details" action to display the details of a movie
  • GET /movies/create/ => "Create" action to display a form for creating a movie
  • POST /movies/create/ => "Create" action to create a new movie
  • GET /movies/edit/99/ => "Edit" action to display a form for editing a movie
  • POST /movies/edit/99/ => "Edit" action to modify a movie
  • GET /movies/delete/99/ => "Delete" action to display a form for deleting a movie
  • POST /movies/delete/99/ => "Delete" action to delete a movie

Note: The trailing "/" in the URL are not "standard", I prefer it that way.

Step 4 - Return a partial view from the controller

I haven't worked on the controller code yet. So the Movie controller doesn't know anything and especially that there is a new HTMX. And of course, all its action methods continue to return complete pages to the browser. The first required modification is that they only return what is specific and nothing at all for the "layout" part.

Thankfully, ASP.NET Core MVC applications use a "layout" template to avoid repeating HTML code, so it should be quite "easy".

Currently, actions typically end by returning a view to which they pass a template with return View(data). The ASP.NET Core view system then combines the data from this model, the Razor code from the view and the Razor code from the layout to generate a full HTML page that it sends back to the browser.

Instead of doing a return View(data), we can also use return PartialView(data) and in this case the layout is not included.

Be careful though, because the first time the movie list page is displayed, the "Index" action must return a full page (i.e. with the layout). It is also necessary to return a full page if you navigate on a page via a browser bookmark or by following a link someone gave you.

Fortunately, HTMX has anticipated all of this and it is easy to determine in which case called the action thanks to the HTTP header "HX-Request" available in the HTTP request:

if (Request.Headers.ContainsKey("HX-Request"))
{
  // When we respond to HTMX
  return PartialView(model);
}

return View(model); // If we did not use HTMX

And if I save this piece of code in a "HtmxView()" function, I can search/replace "return View()" with "return HtmxView(" and it will make Michel Street.

Step 5 - Manage RedirectToAction()

After a few different tests, it seems to work pretty well...

Although, when we validate the "Create.cshtml", "Edit.cshtml" or "Delete.cshtml" view form, the browser address bar keeps the current URL from before the POST when it should become the index page URL, aka "https://localhost/movies/".

The problem must come from the fact that it is not possible to use the "hx-target" and "hx-push-url" attributes with "hx-post" (or that I did not succeed to do it). Another possibility is that ASP.NET Core gets a bit lost when following the RedirectToAction() which concludes the successful POSTs (Post / Redirect / Get pattern).

Anyway, I can fix this by adding a "HX-Push" HTTP header to the response when I send the view back. This tells HTMX to show a new URL in the browser address bar.

private IActionResult HtmxView(object model)
{
  if (Request.Headers.ContainsKey("HX-Request"))
  {
    Response.Headers.Add("HX-Push", Request.Path.ToString());
    return PartialView(model);
  }

  return View(model);
}

Note: Obviously, this method should be placed in a "BaseController.cs" file...

The icing on the cake! Since I'm not being picky and I'm returning the HTTP header "HX-Push" with all partial views, I no longer need the hx-push-url="true" I have previously added to <a> links. Thus I can delete them without losing functionalities.

Summary

Once you know what to do, it goes pretty fast:

  1. Add <script src="https://unpkg.com/htmx.org@1.6.1"></script> in the layout.
  2. Replace links <a asp-action="Toto">Tutu</a> with <a asp-action="Toto" hx-target="main" hx-get="/movies/toto/">Tutu</a>
  3. Add hx-target="main" hx-get="/movies/toto/@Un_ID/" to links <a asp-action="Toto" asp-route-id="@Un_ID">Tutu</a>
  4. Rewrite all <form method="post" ... with <form method="post" hx-post="/movies/toto/xxx" ...
  5. Replace all return View(model); with return HtmxView(model);
  6. Add a method private IActionResult HtmxView(object model) { ... } to the controller

In order to clearly visualize and understand all the modifications, their details are visible in the form of diffs in the commit "Add HTMX as simply as possible" in the branch "2-ajout-htmx-basic" on GitHub.

To be continued

Next time, I will explain how to create 2 new TagHelper <a-htmx> and <form-htmx> so that all these modifications are less complicated (and to avoid duplicates between "href" and "hx-get").

Spoiler: we will go from <a asp-action="Toto">Tutu</a> to <a-htmx asp-action="Toto">Tutu</a-htmx>!

Version en français : Utiliser HTMX avec ASP.NET Core MVC.